diff --git a/benchmarks/ArrayValueSetBench.php b/benchmarks/ArrayValueSetBench.php index 83ffd8b..16435ee 100644 --- a/benchmarks/ArrayValueSetBench.php +++ b/benchmarks/ArrayValueSetBench.php @@ -104,7 +104,7 @@ public function benchStrictNestedUnique(): void public function provideValueSets(): array { $workloads = []; - foreach ([16, 32, 48, 64, 96, 128, 256, 512] as $bytes) { + foreach ([16, 32, 48, 64, 96, 128, 256, 512, 768, 1024, 1536, 2048, 4096, 8192] as $bytes) { $workloads[$bytes . 'b-duplicates'] = [ 'bytes' => $bytes, 'distribution' => 'duplicate-heavy', diff --git a/benchmarks/MembershipCrossoverBench.php b/benchmarks/MembershipCrossoverBench.php new file mode 100644 index 0000000..b2719e3 --- /dev/null +++ b/benchmarks/MembershipCrossoverBench.php @@ -0,0 +1,169 @@ + */ + private array $allNeedles = []; + + /** @var array */ + private array $haystack = []; + + /** @var array */ + private array $needles = []; + + /** @var array */ + private array $rows = []; + + /** @param array{size:int, distribution:string} $params */ + public function setUp(array $params): void + { + $this->haystack = range(0, 9999); + $this->rows = array_map( + static fn(int $id): array => ['id' => $id], + $this->haystack, + ); + $this->needles = $this->makeNeedles($params['size'], $params['distribution']); + $this->allNeedles = $this->makeAllNeedles($params['size'], $params['distribution']); + + // Keep autoloading outside the timed subjects so native and adaptive + // membership paths measure only repeated-operation cost. + ArraySingle::containsAll([], [], true); + ArraySingle::containsAny([], [], true); + ArraySingle::diff([], [], true); + ArraySingle::intersect([], [], true); + ArrayMulti::whereIn([], 'id', [], true); + } + + public function benchContainsAllAdaptive(): void + { + ArraySingle::containsAll($this->haystack, $this->allNeedles, true); + } + + public function benchContainsAllScan(): void + { + array_all( + $this->allNeedles, + fn(int $needle): bool => in_array($needle, $this->haystack, true), + ); + } + + public function benchContainsAnyAdaptive(): void + { + ArraySingle::containsAny($this->haystack, $this->needles, true); + } + + public function benchContainsAnyScan(): void + { + array_any( + $this->needles, + fn(int $needle): bool => in_array($needle, $this->haystack, true), + ); + } + + public function benchDiffAdaptive(): void + { + ArraySingle::diff($this->haystack, $this->needles, true); + } + + public function benchDiffScan(): void + { + $results = []; + foreach ($this->haystack as $key => $value) { + if (!in_array($value, $this->needles, true)) { + $results[$key] = $value; + } + } + } + + public function benchIntersectAdaptive(): void + { + ArraySingle::intersect($this->haystack, $this->needles, true); + } + + public function benchIntersectScan(): void + { + $results = []; + foreach ($this->haystack as $key => $value) { + if (in_array($value, $this->needles, true)) { + $results[$key] = $value; + } + } + } + + public function benchWhereInAdaptive(): void + { + ArrayMulti::whereIn($this->rows, 'id', $this->needles, true); + } + + public function benchWhereInScan(): void + { + $results = []; + foreach ($this->rows as $key => $row) { + if (in_array($row['id'], $this->needles, true)) { + $results[$key] = $row; + } + } + } + + /** @return array */ + public function provideMembershipWorkloads(): array + { + $workloads = []; + + foreach ([1, 2, 8, 16, 32, 64, 128, 192, 256, 512] as $size) { + foreach (['hit-first', 'hit-last', 'miss'] as $distribution) { + $workloads[$size . '-' . $distribution] = [ + 'size' => $size, + 'distribution' => $distribution, + ]; + } + } + + return $workloads; + } + + /** + * @param int $size Number of values in the membership set. + * @param string $distribution Hit placement for the workload. + * @return array + */ + private function makeAllNeedles(int $size, string $distribution): array + { + return match ($distribution) { + 'hit-first' => range(0, $size - 1), + 'hit-last' => range(10000 - $size, 9999), + default => range(10000, 10000 + $size - 1), + }; + } + + /** + * @param int $size Number of values in the membership set. + * @param string $distribution Hit placement for the workload. + * @return array + */ + private function makeNeedles(int $size, string $distribution): array + { + $misses = range(10000, 10000 + $size - 1); + + return match ($distribution) { + 'hit-first' => [0, ...array_slice($misses, 1)], + 'hit-last' => [...array_slice($misses, 0, -1), 9999], + default => $misses, + }; + } +} diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index 58dc427..0746772 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -109,8 +109,9 @@ ArraySingle: Search, Partition, Aggregation $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] + $dupes = ArraySingle::duplicates($arr); // [2] (loose by default) + $strictDupes = ArraySingle::duplicates($arr, true); // strict comparison + $unique = ArraySingle::unique($arr); // [0=>1,1=>2,3=>3,4=>4,5=>5,6=>'x'] $sum = ArraySingle::sum($arr); // 17 (non-numeric ignored) $avg = ArraySingle::avg($arr); // 17/6 (non-numeric ignored) $median = ArraySingle::median($arr); // 2.5 @@ -126,8 +127,8 @@ ArraySingle: Numeric and Value Helpers $values = [-2, -1, 0, 1, 2, 3, 'x']; - $positive = ArraySingle::positive($values); // [1,2,3] - $negative = ArraySingle::negative($values); // [-2,-1] + $positive = ArraySingle::positive($values); // [3=>1,4=>2,5=>3] + $negative = ArraySingle::negative($values); // [0=>-2,1=>-1] $isInt = ArraySingle::isInt([1, 2, 3]); // true $isPositive = ArraySingle::isPositive([1, 2, 'x']); // true $isNegative = ArraySingle::isNegative([-1, -2, 'x']); // true @@ -308,7 +309,7 @@ Behavior Matrix - Dot-path support - Wildcard support * - ``ArraySingle`` - - yes (except ``values()``, ``unique()``, positional list helpers) + - yes (except ``values()`` and positional list helpers) - no - no - no @@ -337,14 +338,30 @@ Behavior Notes - Many methods preserve original keys (especially ``slice``, ``where``, ``skip`` variants). - ``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::unique()`` preserves original keys. Both ``unique()`` and + ``duplicates()`` have loose mode (default) plus explicit strict mode. - ``ArraySingle::avg()``, ``sum()``, ``isPositive()``, and ``isNegative()`` ignore non-numeric values. +- Numeric min/max/sum paths retain integer values and precision unless PHP naturally + promotes an arithmetic result to ``float``. - ``ArraySingle::paginate()`` requires ``$page >= 1`` and ``$perPage >= 1``. +- ``chunk()`` rejects non-positive sizes, ``BaseArrayHelper::range()`` rejects a + zero step, ``ArraySingle::combine()`` requires equal counts, and + ``ArrayMulti::transpose()`` requires rows with identical keys. +- ``ArraySingle::mode()`` counts only integer and string values; other value + types are ignored. +- Key-producing callbacks must return an actual integer or string. Missing + string fields are skipped by ``uniqueBy()``, ``duplicatesBy()``, ``keyBy()``, + ``indexBy()``, ``countBy()``, and indexed ``pluck()``; an explicit ``null`` + field remains a present value and throws where an array key is required. - ``ArrayMulti::whereIn()`` / ``whereNotIn()`` treat ``null`` as a real value when the key exists. - ``ArrayMulti::where()`` / ``firstWhere()`` distinguish explicit ``null`` from the two-argument shorthand form. - ``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)``. +- In ``string|callable`` row APIs, strings always identify fields; use a closure or + another non-string callable for callback behavior. - ``ArrayMulti::where()`` uses ``Infocyph\ArrayKit\compare()`` semantics for operators. - ``BaseArrayHelper::random()`` throws ``InvalidArgumentException`` when requested count exceeds array size. +- Recursive/reference-containing arrays are outside the supported input contract + for unguarded equality, fingerprint, flatten, and recursive-sort helpers. diff --git a/docs/collection.rst b/docs/collection.rst index dd7413b..1a27ac0 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -140,6 +140,9 @@ HookedCollection // Dot-notation hooks are supported $c->onGet('user.city', fn ($v) => ucfirst((string) $v)); + // Copies keep registered hooks but have isolated data and pipeline state + $copy = $c->copy(); + echo $c['name']; // ALICE $c['role'] = 'admin'; echo $c['role']; // Role: admin @@ -193,7 +196,6 @@ Structure and reshape: - ``flatten()``, ``flattenByKey()``, ``collapse()`` - ``groupBy()``, ``keyBy()``, ``indexBy()``, ``pluck()``, ``transpose()`` - ``mapWithKeys()``, ``values()``, ``rekey()`` -- ``wrap()``, ``unWrap()`` Ordering and uniqueness: @@ -328,3 +330,6 @@ Behavior Notes - ``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. +- ``HookedCollection::copy()`` preserves hooks while isolating collection data + and cached pipeline state. +- ``duplicates()`` is loose by default and accepts ``true`` for strict comparison. diff --git a/docs/config.rst b/docs/config.rst index 1069d4a..496c891 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -238,6 +238,13 @@ Reading Values $hasAny = $config->hasAny(['missing.path', 'queue.driver']); // true $required = $config->getOrFail('app.name'); // throws if missing +Defaults may be plain values or ``Closure`` instances. A closure is evaluated +only when the requested key is missing; other callable values are returned as +ordinary config values. + +Bulk ``getOrFail([...])`` validates every requested path individually. Existing +``null`` values are valid; the first missing path raises ``OutOfBoundsException``. + Typed Getters ------------- @@ -265,6 +272,10 @@ Single key: $config->set('cache.driver', 'file'); $config->set('db.port', 5432); +``append()`` and ``prepend()`` create a list when the path is missing and modify +an existing array. They throw ``InvalidArgumentException`` for scalar paths so +existing data is never silently discarded. + Bulk set: .. code-block:: php @@ -318,7 +329,7 @@ Merging, Snapshots, and Read-Only Mode $config->snapshot('before-runtime'); $config->merge(['app' => ['env' => 'production']]); // deep merge - $config->overlay(['features' => ['beta' => true]]); // top-level overlay + $config->overlay(['features' => ['beta' => true]]); // recursive overlay $changed = $config->changed('before-runtime'); // true/false $config->restore('before-runtime'); // rollback diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst index 0dfe306..bc8f49e 100644 --- a/docs/dot-notation.rst +++ b/docs/dot-notation.rst @@ -45,6 +45,10 @@ Use ``\\.`` inside a path segment to target literal key dots: DotNotation::set($data, 'service\\.env', 'prod'); DotNotation::forget($data, 'service\\.env'); +Plain dotted strings always identify paths. Direct lookup is reserved for keys +without path syntax, so reads and mutations resolve the same location even when +both a literal dotted key and an equivalent nested path exist. + Reading Multiple Keys --------------------- @@ -80,6 +84,9 @@ Fill vs Set DotNotation::fill($data, 'app.env', 'staging'); // does not overwrite DotNotation::fill($data, 'app.debug', true); // writes +If an intermediate segment is already a scalar, ``fill()`` leaves it unchanged; +it does not replace existing data merely to create a deeper path. + Object properties (including null-valued properties) are treated as existing when filling: @@ -145,6 +152,12 @@ Forgetting Keys // Wildcard remove (all users.*.secret) DotNotation::forget($data, 'users.*.secret'); + // Terminal wildcards clear every item at their target + DotNotation::forget($data, 'users.*'); + +Forgetting an impossible child path never deletes its scalar parent. For example, +``forget($data, 'a.b')`` leaves ``a`` unchanged when ``a`` is scalar. + Wildcards and Special Segments in get() --------------------------------------- @@ -258,7 +271,18 @@ Behavior Notes - Existing keys with ``null`` values return ``null`` (not the default). - Existing object properties with ``null`` values are also treated as present. - Missing integer keys return the provided default. -- Defaults may be plain values or callables, and callables are only evaluated when path resolution fails. +- Plain dotted strings are paths; escape a dot (``foo\\.bar``) to address a + literal dotted key. +- ``get()``, ``set()``, ``fill()``, ``forget()``, ``rename()``, and ``move()`` + share that path-resolution rule. +- ``flatten()`` and ``paths()`` escape literal dots, backslashes, wildcards, and + ``{first}``/``{last}`` selectors, so ``expand(flatten($data))`` round-trips + those keys. +- Object writes support ``stdClass`` additions, existing public writable + properties, and magic ``__set`` handlers. Missing ordinary properties and + inaccessible or readonly properties fail with ``InvalidArgumentException``. +- Defaults may be plain values or ``Closure`` instances. Closures are evaluated + only when path resolution fails; other callable values are returned unchanged. - Wildcard traversal in ``get`` returns arrays of matched results. - ``set`` supports wildcards at any path depth, including multiple wildcards. - ``forget`` supports wildcard and nested removal across arrays. diff --git a/docs/installation.rst b/docs/installation.rst index 5c0a5d8..42d9c27 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -13,6 +13,7 @@ Requirements ArrayKit has the following requirements: * **PHP 8.4+** +* **ext-hash** (used by collision-safe strict value-set acceleration) Autoload is PSR-4 based and loads namespaced helper functions from ``Infocyph\ArrayKit\*`` by default. diff --git a/docs/migration.rst b/docs/migration.rst index b67de8c..0c42aa6 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -30,7 +30,8 @@ Recent Additions Compatibility Notes ------------------- -- ``unWrap()`` is the current helper/pipeline method name. +- ``wrap()`` and ``unWrap()`` remain array-helper methods but are no longer + exposed on ``Pipeline`` because pipeline state is always an array. - Pipeline methods are mutable by design: most transformation methods update the same collection instance and return it. - Use ``copy()`` or ``immutable()`` before pipeline operations when functional immutability is preferred. @@ -47,6 +48,14 @@ Behavior Changes - ``Collection`` relies on ``IteratorAggregate``. Calls to the former manual pointer surface (``current()``, ``key()``, ``next()``, ``rewind()``, and ``valid()``) should be replaced with ``foreach`` or ``getIterator()``. +- Plain dotted strings now consistently identify DotNotation paths across reads + and mutations. Escape literal dots (for example ``service\\.name``). +- ``duplicates()`` now mirrors ``unique()`` with loose comparison by default and + an optional ``$strict`` flag. +- Strings in ``string|callable`` row APIs always identify field names. Use a + closure or another non-string callable to select callback behavior. +- Numeric selection and accumulation preserve integer precision instead of + coercing all values to ``float``. Recommended Upgrade Checklist ----------------------------- diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index 6793681..f7a4bd5 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -114,6 +114,8 @@ Helper Functions 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 + function Infocyph\ArrayKit\env(?string $key = null, mixed $default = null): mixed + function Infocyph\ArrayKit\dotenv(): ModuleProxy // Optional globals (manual include of src/functions.php) function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool @@ -131,6 +133,8 @@ ArrayKit Facade public static function multi(): ModuleProxy public static function helper(): ModuleProxy public static function dot(): ModuleProxy + public static function env(): ModuleProxy + public static function dotenv(): ModuleProxy public static function config(array $items = []): Config public static function lazyConfig(string $directory, string $extension = 'php', array $items = [], ?string $namespaceCacheDirectory = null): LazyFileConfig public static function collection(mixed $data = []): Collection @@ -143,6 +147,7 @@ Facade ModuleProxy .. code-block:: php + public function __construct(private string $targetClass) public function __call(string $method, array $arguments): mixed BaseArrayHelper @@ -203,7 +208,7 @@ ArraySingle public static function positive(array $array): array public static function negative(array $array): array public static function nth(array $array, int $step, int $offset = 0): array - public static function duplicates(array $array): array + public static function duplicates(array $array, bool $strict = false): array public static function paginate(array $array, int $page, int $perPage): array public static function combine(array $keys, array $values): array public static function where(array $array, ?callable $callback = null): array @@ -397,6 +402,7 @@ HookedCollection extends ``Collection`` and adds hook behavior (from ``HookTrait .. code-block:: php + public function copy(): static public function offsetGet(mixed $offset): mixed public function offsetSet(mixed $offset, mixed $value): void public function onGet(string $offset, callable $callback): static @@ -412,7 +418,7 @@ Pipeline public function values(): Collection public function rekey(array|callable $mapper): Collection public function nth(int $step, int $offset = 0): Collection - public function duplicates(): Collection + public function duplicates(bool $strict = false): Collection public function slice(int $offset, ?int $length = null): Collection public function paginate(int $page, int $perPage): Collection public function combine(array $values): Collection @@ -454,8 +460,6 @@ Pipeline 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 shuffle(?int $seed = null): Collection public function sum(?callable $callback = null): float|int public function min(string|callable|null $keyOrCallback = null): float|int|null @@ -490,19 +494,20 @@ Config uses ``BaseConfigTrait``. Public API: .. code-block:: php public function loadFile(string $path): bool + public function loadEnvFile(string $path): bool public function loadArray(array $resource): bool public function all(): array public function has(string|array $keys): bool 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 getOrFail(string|int|array $key): mixed + public function getString(string|int $key, ?string $default = null): ?string + public function getInt(string|int $key, ?int $default = null): ?int + public function getFloat(string|int $key, ?float $default = null): ?float + public function getBool(string|int $key, ?bool $default = null): ?bool + public function getArray(string|int $key, ?array $default = null): ?array + public function getList(string|int $key, ?array $default = null): ?array + public function getEnum(string|int $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 @@ -511,6 +516,7 @@ Config uses ``BaseConfigTrait``. Public API: public function replace(array $items): bool public function reload(array|string $source): bool public function merge(array $items): bool + public function mergeEnvFile(string $path): bool public function overlay(array $overlay): bool public function exportCache(string $path): bool public function loadCache(string $path): bool @@ -531,12 +537,19 @@ Calling ``all()`` throws by design because lazy configuration requires a key. .. code-block:: php + public function __construct(protected string $directory, protected string $extension = 'php', array $items = [], ?string $namespaceCacheDirectory = null) public function get(string|int|array|null $key = null, mixed $default = null): mixed public function has(string|array $keys): bool public function hasAny(string|array $keys): bool 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 + public function loadArray(array $resource): bool + public function loadFile(string $path): bool + public function merge(array $items): bool + public function reload(array|string $source): bool + public function replace(array $items): bool + public function restore(string $name = 'default'): bool public function preload(string|array $namespaces): static public function isLoaded(string $namespace): bool public function loaded(string $namespace): bool @@ -581,6 +594,44 @@ HookTrait public function onGet(string $offset, callable $callback): static public function onSet(string $offset, callable $callback): static +EnvParser +---------------------------------- + +.. code-block:: php + + public static function parse(string $contents): array + public static function parseFile(string $path): array + public static function parseFileRaw(string $path): array + public static function parseLines(iterable $lines): array + public static function parseLinesRaw(iterable $lines): array + public static function parseRaw(string $contents): array + +Environment +---------------------------------- + +.. code-block:: php + + public static function all(bool $includeHttpServerValues = false): array + public static function get(?string $key = null, mixed $default = null): mixed + public static function has(string $key): bool + public static function ref(string $key, mixed $default = null): EnvReference + +DTO +---------------------------------- + +``DTO`` is an optional abstract base using ``DTOTrait``. It exposes the same +methods listed in the ``DTOTrait`` section. + +.. code-block:: php + + 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 + LazyCollection ---------------------------------- diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index f515257..2eeb14c 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\Array; use Infocyph\ArrayKit\Array\Concerns\ArrayMultiQuerySortTrait; +use InvalidArgumentException; class ArrayMulti { @@ -17,7 +18,7 @@ class ArrayMulti public static function chunk(array $array, int $size, bool $preserveKeys = false): array { if ($size <= 0) { - return [$array]; + throw new InvalidArgumentException('Chunk size must be greater than 0.'); } return array_chunk($array, $size, $preserveKeys); @@ -44,7 +45,7 @@ public static function collapse(array $array): array */ public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool { - if (is_callable($valueOrCallback)) { + if (!is_string($valueOrCallback) && is_callable($valueOrCallback)) { return static::some($array, $valueOrCallback); } @@ -336,15 +337,15 @@ public static function transpose(array $matrix): array } $firstRow = current($matrix); if (!is_array($firstRow)) { - return []; + throw new InvalidArgumentException('Matrix rows must be arrays for transpose.'); } $keys = array_keys($firstRow); $results = array_fill_keys($keys, []); foreach ($matrix as $row) { - if (!is_array($row)) { - continue; + if (!is_array($row) || array_keys($row) !== $keys) { + throw new InvalidArgumentException('Matrix rows must have identical keys for transpose.'); } foreach ($row as $col => $value) { diff --git a/src/Array/ArraySharedOps.php b/src/Array/ArraySharedOps.php index 1a28d7b..72f9097 100644 --- a/src/Array/ArraySharedOps.php +++ b/src/Array/ArraySharedOps.php @@ -4,6 +4,7 @@ namespace Infocyph\ArrayKit\Array; +/** @internal */ final class ArraySharedOps { public static function asString(mixed $value): string @@ -51,7 +52,7 @@ public static function each(array $array, callable $callback): array */ public static function every(array $array, callable $callback): bool { - return array_all($array, static fn(mixed $value, int|string $key): bool => (bool) $callback($value, $key)); + return array_all($array, $callback); } public static function normalizeArrayKey(mixed $value): int|string diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index ccc2091..c10290e 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -20,7 +20,7 @@ class ArraySingle */ public static function avg(array $array): float|int { - $total = 0.0; + $total = 0; $count = 0; foreach ($array as $value) { @@ -46,8 +46,7 @@ public static function avg(array $array): float|int * Break an array into smaller chunks of a specified size. * * This function splits the input array into multiple smaller arrays, each - * containing up to the specified number of elements. If the specified size - * is less than or equal to zero, the entire array is returned as a single chunk. + * containing up to the specified number of elements. * * @param array $array The array to be chunked. * @param int $size The size of each chunk. @@ -57,7 +56,7 @@ public static function avg(array $array): float|int public static function chunk(array $array, int $size, bool $preserveKeys = false): array { if ($size <= 0) { - return [$array]; + throw new InvalidArgumentException('Chunk size must be greater than 0.'); } return array_chunk($array, $size, $preserveKeys); @@ -67,8 +66,7 @@ public static function chunk(array $array, int $size, bool $preserveKeys = false * Combine two arrays into one array with corresponding key-value pairs. * * The function takes two arrays, one of keys and one of values, and combines them - * into a single array. If the two arrays are not of equal length, the function - * will truncate the longer array to match the length of the shorter array. + * into a single array. Both arrays must contain the same number of items. * * @param array $keys The array of keys. * @param array $values The array of values. @@ -80,13 +78,11 @@ public static function combine(array $keys, array $values): array $valueCount = count($values); if ($keyCount !== $valueCount) { - $size = ($keyCount > $valueCount) ? $valueCount : $keyCount; - $keys = array_slice($keys, 0, $size); - $values = array_slice($values, 0, $size); + throw new InvalidArgumentException('Keys and values must contain the same number of items.'); } $normalizedKeys = array_map( - self::normalizeArrayKey(...), + static fn(mixed $key): int|string => self::requireArrayKey($key, 'combine'), array_values($keys), ); @@ -112,7 +108,7 @@ public static function combine(array $keys, array $values): array */ public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool { - if (is_callable($valueOrCallback)) { + if (!is_string($valueOrCallback) && is_callable($valueOrCallback)) { return static::some($array, $valueOrCallback); } @@ -157,7 +153,7 @@ public static function countBy(array $array, ?callable $by = null): array foreach ($array as $key => $value) { $bucket = $by ? $by($value, $key) : $value; - $normalized = self::normalizeArrayKey($bucket); + $normalized = self::requireArrayKey($bucket, 'countBy'); $counts[$normalized] = ($counts[$normalized] ?? 0) + 1; } @@ -184,9 +180,9 @@ public static function diff(array $array, array $values, bool $strict = false): * @param array $array The array to search for duplicates. * @return array An array of duplicate values. */ - public static function duplicates(array $array): array + public static function duplicates(array $array, bool $strict = false): array { - return ArraySingleOps::duplicates($array); + return ArraySingleOps::duplicates($array, $strict); } /** @@ -400,7 +396,7 @@ public static function mapWithKeys(array $array, callable $callback): array } foreach ($mapped as $mappedKey => $mappedValue) { - $results[self::normalizeArrayKey($mappedKey)] = $mappedValue; + $results[$mappedKey] = $mappedValue; } } @@ -443,8 +439,9 @@ public static function median(array $array): float|int { $values = []; foreach ($array as $value) { - if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) { - $values[] = (float) $value; + $numeric = ArraySingleOps::numericValue($value); + if ($numeric !== null) { + $values[] = $numeric; } } @@ -733,7 +730,7 @@ public static function rekey(array $array, array|callable $mapper): array ? $mapper($key, $value) : ($mapper[$key] ?? $key); - $results[self::normalizeArrayKey($nextKey)] = $value; + $results[self::requireArrayKey($nextKey, 'rekey')] = $value; } return $results; @@ -766,9 +763,9 @@ public static function same(array $left, array $right, bool $strict = false): bo */ public static function search(array $array, mixed $needle): int|string|null { - if (is_callable($needle)) { + if (!is_string($needle) && is_callable($needle)) { foreach ($array as $key => $value) { - if ($needle($value, $key) === true) { + if ($needle($value, $key)) { return $key; } } @@ -899,7 +896,7 @@ public static function slice(array $array, int $offset, ?int $length = null): ar */ public static function some(array $array, callable $callback): bool { - return array_any($array, static fn(mixed $value, int|string $key): bool => (bool) $callback($value, $key)); + return array_any($array, $callback); } /** @@ -914,7 +911,7 @@ public static function some(array $array, callable $callback): bool */ public static function sum(array $array, ?callable $callback = null): float|int { - $total = 0.0; + $total = 0; if ($callback === null) { foreach ($array as $value) { @@ -926,7 +923,7 @@ public static function sum(array $array, ?callable $callback = null): float|int $total += $numeric; } - return fmod($total, 1.0) === 0.0 ? (int) $total : $total; + return $total; } foreach ($array as $key => $value) { @@ -939,7 +936,7 @@ public static function sum(array $array, ?callable $callback = null): float|int $total += $numeric; } - return fmod($total, 1.0) === 0.0 ? (int) $total : $total; + return $total; } /** @@ -1010,16 +1007,19 @@ private static function normalizeArrayKey(mixed $value): int|string return ArraySharedOps::normalizeArrayKey($value); } - private static function toNumericOrNull(mixed $value): ?float + private static function requireArrayKey(mixed $value, string $operation): int|string { - if (is_int($value) || is_float($value)) { - return (float) $value; + if (is_int($value) || is_string($value)) { + return $value; } - if (is_string($value) && is_numeric($value)) { - return (float) $value; - } + throw new InvalidArgumentException( + $operation . ' derived key must be an integer or string; ' . get_debug_type($value) . ' given.', + ); + } - return null; + private static function toNumericOrNull(mixed $value): float|int|null + { + return ArraySingleOps::numericValue($value); } } diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index fb3db66..42058b9 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -4,6 +4,7 @@ namespace Infocyph\ArrayKit\Array; +/** @internal */ final class ArraySingleOps { /** @@ -38,9 +39,9 @@ public static function diff(array $array, array $values, bool $strict): array * @param array $array * @return array */ - public static function duplicates(array $array): array + public static function duplicates(array $array, bool $strict): array { - return ArrayValueSetOps::duplicates($array); + return ArrayValueSetOps::duplicates($array, $strict); } /** @@ -93,6 +94,22 @@ public static function minBy(array $array, callable $callback): mixed return self::pickBy($array, $callback, pickMax: false); } + /** + * Convert numeric input without discarding integer precision. + */ + public static function numericValue(mixed $value): float|int|null + { + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value) && is_numeric($value)) { + return $value + 0; + } + + return null; + } + /** * @param array $left * @param array $right @@ -171,7 +188,10 @@ private static function pickBy(array $array, callable $callback, bool $pickMax): continue; } - $numeric = (float) $score; + $numeric = self::numericValue($score); + if ($numeric === null) { + continue; + } if (!$found || ($pickMax ? ($numeric > $bestScore) : ($numeric < $bestScore))) { $best = $value; $bestScore = $numeric; @@ -190,20 +210,16 @@ private static function selectNumeric(array $array, bool $pickMax): float|int|nu $selected = null; foreach ($array as $value) { - if (!is_numeric($value)) { + $numeric = self::numericValue($value); + if ($numeric === null) { continue; } - $numeric = (float) $value; if ($selected === null || ($pickMax ? ($numeric > $selected) : ($numeric < $selected))) { $selected = $numeric; } } - if ($selected === null) { - return null; - } - - return fmod($selected, 1.0) === 0.0 ? (int) $selected : $selected; + return $selected; } } diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php index 6a28a81..d4d672f 100644 --- a/src/Array/ArrayValueSetOps.php +++ b/src/Array/ArrayValueSetOps.php @@ -9,7 +9,13 @@ */ final class ArrayValueSetOps { - private const int XXH128_MIN_FINGERPRINT_BYTES = 64; + private const int CONTAINS_ALL_LOOKUP_MIN_NEEDLES = 512; + + private const int CONTAINS_ANY_LOOKUP_MIN_NEEDLES = 192; + + private const int FILTER_LOOKUP_MIN_VALUES = 192; + + private const int XXH128_MIN_FINGERPRINT_BYTES = 1024; /** * @param array $array @@ -21,6 +27,15 @@ public static function containsAll(array $array, array $needles, bool $strict): return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, false)); } + if (count($needles) < self::CONTAINS_ALL_LOOKUP_MIN_NEEDLES) { + return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); + } + + $firstKey = array_key_first($needles); + if (!in_array($needles[$firstKey], $array, true)) { + return false; + } + $lookup = self::buildStrictLookup($array); if ($lookup === null) { return array_all($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); @@ -42,6 +57,15 @@ public static function containsAny(array $array, array $needles, bool $strict): return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, false)); } + if (count($needles) < self::CONTAINS_ANY_LOOKUP_MIN_NEEDLES) { + return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); + } + + $firstKey = array_key_first($needles); + if (in_array($needles[$firstKey], $array, true)) { + return true; + } + $lookup = self::buildStrictLookup($array); if ($lookup === null) { return array_any($needles, static fn(mixed $needle): bool => in_array($needle, $array, true)); @@ -67,10 +91,10 @@ public static function diff(array $array, array $values, bool $strict): array * @param array $array * @return array */ - public static function duplicates(array $array): array + public static function duplicates(array $array, bool $strict): array { - if (!self::allStrictHashable($array)) { - return self::duplicatesByScan($array); + if (!$strict || !self::allStrictHashable($array)) { + return self::duplicatesByScan($array, $strict); } $strictLookup = []; @@ -241,20 +265,25 @@ private static function countsByFingerprint(array $array): array * @param array $array * @return array */ - private static function duplicatesByScan(array $array): array + private static function duplicatesByScan(array $array, bool $strict): array { $seen = []; $duplicates = []; foreach ($array as $value) { - if (!in_array($value, $seen, true)) { + $seenKey = array_find_key( + $seen, + static fn(mixed $seenValue): bool => $strict ? $value === $seenValue : $value == $seenValue, + ); + if ($seenKey === null) { $seen[] = $value; continue; } - if (!in_array($value, $duplicates, true)) { - $duplicates[] = $value; + $representative = $seen[$seenKey]; + if (!in_array($representative, $duplicates, $strict)) { + $duplicates[] = $representative; } } @@ -268,7 +297,9 @@ private static function duplicatesByScan(array $array): array */ private static function filterByMembership(array $array, array $values, bool $strict, bool $keepMatches): array { - $lookup = $strict ? self::buildStrictLookup($values) : null; + $lookup = $strict && count($values) >= self::FILTER_LOOKUP_MIN_VALUES + ? self::buildStrictLookup($values) + : null; $results = []; foreach ($array as $key => $value) { @@ -286,10 +317,9 @@ private static function filterByMembership(array $array, array $values, bool $st /** * Use XXH128 only for long canonical keys, retaining canonical values in - * each digest bucket so a hash collision can never change equality. A PHP - * 8.4 CLI microbenchmark (100 runs of 1,000 nested 128-byte values) measured - * 110.8 ms with verified digest buckets versus 138.6 ms with canonical keys. - * The threshold avoids the measured hashing regression for short scalars. + * each digest bucket so a hash collision can never change equality. The + * current PHP 8.4 payload matrix did not show a stable digest advantage up + * through 512-byte payloads, so hashing is reserved for much longer keys. * * @param array $seen * @param array> $digestBuckets @@ -411,6 +441,10 @@ private static function fingerprintStrict(mixed $value): string private static function isStrictHashable(mixed $value): bool { + if (gettype($value) === 'resource (closed)') { + return false; + } + if (is_float($value)) { return !is_nan($value); } diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index d9b058f..7414f6b 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -69,7 +69,7 @@ public static function doReject(array $array, mixed $callback): array { $results = []; - if (is_callable($callback)) { + if (!is_string($callback) && is_callable($callback)) { foreach ($array as $key => $value) { if (!(bool) $callback($value, $key)) { $results[$key] = $value; @@ -98,7 +98,7 @@ public static function doReject(array $array, mixed $callback): array public static function findKey(array $array, callable $callback): int|string|null { foreach ($array as $key => $value) { - if ($callback($value, $key) === true) { + if ($callback($value, $key)) { return $key; } } @@ -171,7 +171,7 @@ public static function hasAny(array $array, int|string|array $keys): bool */ public static function haveAny(array $array, callable $callback): bool { - return array_any($array, fn($value, $key) => $callback($value, $key) === true); + return array_any($array, $callback); } /** @@ -183,7 +183,7 @@ public static function haveAny(array $array, callable $callback): bool */ public static function isAll(array $array, callable $callback): bool { - return array_all($array, fn($value, $key) => !($callback($value, $key) === false)); + return array_all($array, $callback); } /** @@ -198,8 +198,11 @@ public static function isAll(array $array, callable $callback): bool */ public static function isMultiDimensional(mixed $array): bool { - return is_array($array) - && count($array) !== count($array, COUNT_RECURSIVE); + if (!is_array($array)) { + return false; + } + + return array_any($array, fn($value) => is_array($value)); } /** @@ -245,7 +248,7 @@ public static function random(array $array, ?int $number = null, bool $preserveK * Generate an array containing a sequence of numbers. * * This function creates an array of numbers starting from $start up to $end, - * incrementing by $step. If $step is zero, an empty array is returned. + * incrementing by $step. * * @param int $start The starting number of the sequence. * @param int $end The ending number of the sequence. @@ -255,7 +258,7 @@ public static function random(array $array, ?int $number = null, bool $preserveK public static function range(int $start, int $end, int $step = 1): array { if ($step === 0) { - return []; + throw new InvalidArgumentException('Range step must not be zero.'); } return range($start, $end, $step); diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index fd9cff8..7e5c042 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -12,8 +12,11 @@ use function Infocyph\ArrayKit\compare; +/** @internal */ trait ArrayMultiQuerySortTrait { + private const int ROW_MEMBERSHIP_LOOKUP_MIN_VALUES = 256; + /** * Filter a 2D array by a single key's comparison (like "where 'age' between 18 and 65"). * @@ -46,7 +49,7 @@ public static function between(array $array, string $key, float|int $from, float public static function countBy(array $array, string|callable $groupBy): array { $counts = []; - $useCallback = is_callable($groupBy); + $useCallback = !is_string($groupBy); foreach ($array as $key => $row) { if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) { @@ -112,12 +115,16 @@ public static function firstWhere( public static function firstWhereIn(array $array, string $key, array $values, bool $strict = false, mixed $default = null): mixed { $lookup = self::buildInLookup($values, $strict); + if ($lookup === null) { + return self::firstWhereInByScan($array, $key, $values, $strict, $default); + } + foreach ($array as $row) { if (!is_array($row) || !array_key_exists($key, $row)) { continue; } - if (self::inLookupContains($lookup, $values, $row[$key], $strict)) { + if (self::rowLookupContains($lookup, $values, $row[$key], $strict)) { return $row; } } @@ -134,7 +141,7 @@ public static function firstWhereIn(array $array, string $key, array $values, bo public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array { $results = []; - $useCallback = is_callable($groupBy); + $useCallback = !is_string($groupBy); foreach ($array as $key => $row) { if (!$useCallback && (!is_array($row) || !array_key_exists($groupBy, $row))) { @@ -174,7 +181,7 @@ public static function indexBy(array $array, string|callable $indexBy): array public static function keyBy(array $array, string|callable $keyBy): array { $results = []; - $useCallback = is_callable($keyBy); + $useCallback = !is_string($keyBy); foreach ($array as $index => $row) { if (!$useCallback && (!is_array($row) || !array_key_exists($keyBy, $row))) { @@ -228,7 +235,7 @@ public static function mapWithKeys(array $array, callable $callback): array } foreach ($mapped as $mappedKey => $mappedValue) { - $results[self::normalizeArrayKey($mappedKey)] = $mappedValue; + $results[$mappedKey] = $mappedValue; } } @@ -292,10 +299,14 @@ public static function pluck(array $array, string $column, ?string $indexBy = nu } $value = $row[$column]; - if ($indexBy !== null && array_key_exists($indexBy, $row)) { - $results[self::requireArrayKey($row[$indexBy], 'pluck')] = $value; - } else { + if ($indexBy === null) { $results[] = $value; + + continue; + } + + if (array_key_exists($indexBy, $row)) { + $results[self::requireArrayKey($row[$indexBy], 'pluck')] = $value; } } @@ -311,7 +322,7 @@ 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)) { + if (!is_string($callback) && is_callable($callback)) { foreach ($array as $key => $row) { if (!$callback($row, $key)) { $results[$key] = $row; @@ -337,7 +348,7 @@ public static function reject(array $array, mixed $callback = true): array */ public static function some(array $array, callable $callback): bool { - return array_any($array, static fn(mixed $row, int|string $key): bool => (bool) $callback($row, $key)); + return array_any($array, $callback); } /** @@ -354,7 +365,7 @@ public static function sortBy( bool $desc = false, int $options = \SORT_REGULAR, ): array { - if (is_callable($by)) { + if (!is_string($by)) { $scores = []; foreach ($array as $key => $row) { $scores[$key] = self::invokeRowCallback($by, $row, $key); @@ -501,7 +512,7 @@ public static function sum(array $array, string|callable|null $keyOrCallback = n $total += self::extractSummableValue($row, $keyOrCallback, $key); } - return fmod($total, 1.0) === 0.0 ? (int) $total : $total; + return $total; } /** @@ -621,12 +632,16 @@ public static function whereIn(array $array, string $key, array $values, bool $s $results = []; $lookup = self::buildInLookup($values, $strict); + if ($lookup === null) { + return self::whereInByScan($array, $key, $values, $strict, true); + } + foreach ($array as $index => $row) { if (!is_array($row) || !array_key_exists($key, $row)) { continue; } - if (self::inLookupContains($lookup, $values, $row[$key], $strict)) { + if (self::rowLookupContains($lookup, $values, $row[$key], $strict)) { $results[$index] = $row; } } @@ -677,6 +692,10 @@ public static function whereNotIn(array $array, string $key, array $values, bool $results = []; $lookup = self::buildInLookup($values, $strict); + if ($lookup === null) { + return self::whereInByScan($array, $key, $values, $strict, false); + } + foreach ($array as $index => $row) { if (!is_array($row) || !array_key_exists($key, $row)) { $results[$index] = $row; @@ -684,7 +703,7 @@ public static function whereNotIn(array $array, string $key, array $values, bool continue; } - if (!self::inLookupContains($lookup, $values, $row[$key], $strict)) { + if (!self::rowLookupContains($lookup, $values, $row[$key], $strict)) { $results[$index] = $row; } } @@ -736,17 +755,9 @@ private static function applySortDirection(int $comparison, bool $desc): int return $desc ? -$comparison : $comparison; } - private static function asNumeric(mixed $value): float + private static function asNumeric(mixed $value): float|int { - if (is_int($value) || is_float($value)) { - return (float) $value; - } - - if (is_string($value) && is_numeric($value)) { - return (float) $value; - } - - return 0.0; + return ArraySingleOps::numericValue($value) ?? 0; } private static function asString(mixed $value): string @@ -760,6 +771,10 @@ private static function asString(mixed $value): string */ private static function buildInLookup(array $values, bool $strict): ?array { + if (count($values) < self::ROW_MEMBERSHIP_LOOKUP_MIN_VALUES) { + return null; + } + if ($strict) { $lookup = []; foreach ($values as $value) { @@ -822,36 +837,70 @@ private static function collectByDerivedKey( bool $strict, bool $keepDuplicates, ): array { - $results = []; - $useCallback = is_callable($keyOrCallback); - if (!$strict) { - $seen = []; - foreach ($array as $index => $row) { - $derived = $useCallback - ? self::invokeRowCallback($keyOrCallback, $row, $index) - : self::resolveDerivedValue($row, $keyOrCallback, $index); - $alreadySeen = in_array($derived, $seen, false); - - if ($alreadySeen === $keepDuplicates) { - $results[$index] = $row; - } - if (!$alreadySeen) { - $seen[] = $derived; + return self::collectByDerivedKeyLoose($array, $keyOrCallback, $keepDuplicates); + } + + return self::collectByDerivedKeyStrict($array, $keyOrCallback, $keepDuplicates); + } + + /** + * @param array $array + * @return array + */ + private static function collectByDerivedKeyLoose( + array $array, + string|callable $keyOrCallback, + bool $keepDuplicates, + ): array { + $results = []; + $seen = []; + foreach ($array as $index => $row) { + if (is_string($keyOrCallback)) { + if (!is_array($row) || !array_key_exists($keyOrCallback, $row)) { + continue; } - } - return $results; + $derived = $row[$keyOrCallback]; + } else { + $derived = self::invokeRowCallback($keyOrCallback, $row, $index); + } + $alreadySeen = in_array($derived, $seen, false); + if ($alreadySeen === $keepDuplicates) { + $results[$index] = $row; + } + if (!$alreadySeen) { + $seen[] = $derived; + } } + return $results; + } + + /** + * @param array $array + * @return array + */ + private static function collectByDerivedKeyStrict( + array $array, + string|callable $keyOrCallback, + bool $keepDuplicates, + ): array { + $results = []; $seen = []; $digestBuckets = []; $fallback = []; foreach ($array as $index => $row) { - $derived = $useCallback - ? self::invokeRowCallback($keyOrCallback, $row, $index) - : self::resolveDerivedValue($row, $keyOrCallback, $index); + if (is_string($keyOrCallback)) { + if (!is_array($row) || !array_key_exists($keyOrCallback, $row)) { + continue; + } + + $derived = $row[$keyOrCallback]; + } else { + $derived = self::invokeRowCallback($keyOrCallback, $row, $index); + } $alreadySeen = ArrayValueSetOps::strictValueAlreadySeen( $derived, $seen, @@ -922,16 +971,16 @@ private static function containsNonReflexiveStrictValue(mixed $value): bool return array_any($value, self::containsNonReflexiveStrictValue(...)); } - private static function extractComparableValue(mixed $row, string|callable $keyOrCallback, int|string $key): ?float + private static function extractComparableValue(mixed $row, string|callable $keyOrCallback, int|string $key): float|int|null { - if (is_callable($keyOrCallback)) { + if (!is_string($keyOrCallback)) { $result = self::invokeRowCallback($keyOrCallback, $row, $key); - return is_numeric($result) ? (float) $result : null; + return ArraySingleOps::numericValue($result); } - if (is_array($row) && array_key_exists($keyOrCallback, $row) && is_numeric($row[$keyOrCallback])) { - return (float) $row[$keyOrCallback]; + if (is_array($row) && array_key_exists($keyOrCallback, $row)) { + return ArraySingleOps::numericValue($row[$keyOrCallback]); } return null; @@ -951,23 +1000,23 @@ private static function extractRowTextValue(mixed $row, string $key): ?string return (string) $value; } - private static function extractSummableValue(mixed $row, string|callable|null $keyOrCallback, int|string $key): float + private static function extractSummableValue(mixed $row, string|callable|null $keyOrCallback, int|string $key): float|int { if ($keyOrCallback === null) { - return is_numeric($row) ? (float) $row : 0.0; + return ArraySingleOps::numericValue($row) ?? 0; } - if (is_callable($keyOrCallback)) { + if (!is_string($keyOrCallback)) { $result = self::invokeRowCallback($keyOrCallback, $row, $key); - return is_numeric($result) ? (float) $result : 0.0; + return ArraySingleOps::numericValue($result) ?? 0; } - if (is_array($row) && isset($row[$keyOrCallback]) && is_numeric($row[$keyOrCallback])) { - return (float) $row[$keyOrCallback]; + if (is_array($row) && isset($row[$keyOrCallback])) { + return ArraySingleOps::numericValue($row[$keyOrCallback]) ?? 0; } - return 0.0; + return 0; } /** @@ -1011,42 +1060,34 @@ private static function filterByTextMatch(array $array, string $key, callable $m return $results; } - private static function formatNumericResult(?float $value): float|int|null - { - if ($value === null) { - return null; - } - - return fmod($value, 1.0) === 0.0 ? (int) $value : $value; - } - /** - * @param array|null $lookup + * @param array $array * @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)]); - } - - if (is_string($candidate) && !is_numeric($candidate)) { - return isset($lookup['value:' . strlen($candidate) . ':' . $candidate]); + private static function firstWhereInByScan( + array $array, + string $key, + array $values, + bool $strict, + mixed $default, + ): mixed { + foreach ($array as $row) { + if (is_array($row) && array_key_exists($key, $row) && in_array($row[$key], $values, $strict)) { + return $row; } } - return in_array($candidate, $values, $strict); + return $default; } - private static function invokeRowCallback(callable $callback, mixed $row, int|string $key): mixed + private static function formatNumericResult(float|int|null $value): float|int|null { - return $callback($row, $key); + return $value; } - private static function normalizeArrayKey(mixed $value): int|string + private static function invokeRowCallback(callable $callback, mixed $row, int|string $key): mixed { - return ArraySharedOps::normalizeArrayKey($value); + return $callback($row, $key); } /** @@ -1117,7 +1158,7 @@ private static function requireArrayKey(mixed $value, string $operation): int|st private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed { - if (is_callable($keyOrCallback)) { + if (!is_string($keyOrCallback)) { return self::invokeRowCallback($keyOrCallback, $row, $index); } @@ -1126,13 +1167,30 @@ private static function resolveDerivedValue(mixed $row, string|callable $keyOrCa private static function resolveSortByManyValue(mixed $row, string|callable $by, int|string $key): mixed { - if (is_callable($by)) { + if (!is_string($by)) { return self::invokeRowCallback($by, $row, $key); } return is_array($row) ? ($row[$by] ?? null) : null; } + /** + * @param array $lookup + * @param array $values + */ + private static function rowLookupContains(array $lookup, array $values, mixed $candidate, bool $strict): bool + { + if ($strict) { + return isset($lookup[ArraySingleOps::fingerprint($candidate, true)]); + } + + if (is_string($candidate) && !is_numeric($candidate)) { + return isset($lookup['value:' . strlen($candidate) . ':' . $candidate]); + } + + return in_array($candidate, $values, false); + } + /** * @param array $array */ @@ -1161,7 +1219,7 @@ private static function selectExtremeRow(array $array, string|callable $keyOrCal /** * @param array $array */ - private static function selectExtremeValue(array $array, string|callable $keyOrCallback, bool $pickMax): ?float + private static function selectExtremeValue(array $array, string|callable $keyOrCallback, bool $pickMax): float|int|null { $selected = null; @@ -1229,4 +1287,29 @@ private static function sortRecursiveWithGuards( return $array; } + + /** + * @param array $array + * @param array $values + * @return array + */ + private static function whereInByScan( + array $array, + string $key, + array $values, + bool $strict, + bool $keepMatches, + ): array { + $results = []; + foreach ($array as $index => $row) { + $matches = is_array($row) + && array_key_exists($key, $row) + && in_array($row[$key], $values, $strict); + if ($matches === $keepMatches) { + $results[$index] = $row; + } + } + + return $results; + } } diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index f300c6d..0048e86 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -7,6 +7,7 @@ use Infocyph\ArrayKit\Array\ArraySingle; use InvalidArgumentException; +/** @internal */ trait DotNotationPublicApiTrait { /** @@ -189,7 +190,7 @@ public static function has(array $array, array|string $keys): bool return false; } - if (is_string($keys) && ArraySingle::exists($array, $keys)) { + if (is_string($keys) && self::isDirectKey($keys) && ArraySingle::exists($array, $keys)) { return true; } @@ -197,7 +198,7 @@ public static function has(array $array, array|string $keys): bool $missing = self::missing(); foreach ($keys as $key) { $resolvedKey = (string) $key; - if (ArraySingle::exists($array, $resolvedKey)) { + if (self::isDirectKey($resolvedKey) && ArraySingle::exists($array, $resolvedKey)) { continue; } if (self::segmentExact($array, $resolvedKey, $missing) === $missing) { @@ -359,8 +360,8 @@ public static function rename(array &$array, string $from, string $to, bool $ove } $value = self::get($array, $from); - self::set($array, $to, $value, $overwrite); self::forget($array, $from); + self::set($array, $to, $value, $overwrite); return true; } diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index 1050097..b2ff86b 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -10,6 +10,11 @@ class DotNotation { use DotNotationPublicApiTrait; + private static function escapePathSegment(string $segment): string + { + return DotNotationPathOps::escapePathSegment($segment); + } + /** * @param array $array * @param array $result @@ -17,13 +22,14 @@ class DotNotation private static function flattenInto(array $array, string $prepend, array &$result): void { foreach ($array as $key => $value) { + $path = $prepend . self::escapePathSegment((string) $key); if (is_array($value) && $value !== []) { - self::flattenInto($value, $prepend . $key . '.', $result); + self::flattenInto($value, $path . '.', $result); continue; } - $result[$prepend . $key] = $value; + $result[$path] = $value; } } @@ -42,16 +48,22 @@ private static function forgetBySegments(array &$array, array $segments, int $po $next = $position + 1; if ($segment === '*') { - if ($next < $segmentCount) { - self::forgetEach($array, $segments, $next); + if ($next >= $segmentCount) { + $array = []; + + return; } + self::forgetEach($array, $segments, $next); + return; } $normalized = self::unescapeSegment($segment); - if ($next < $segmentCount && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) { - self::forgetBySegments($array[$normalized], $segments, $next); + if ($next < $segmentCount) { + if (ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) { + self::forgetBySegments($array[$normalized], $segments, $next); + } return; } @@ -117,6 +129,49 @@ private static function handleWildcardSet( } } + private static function hasWritableObjectProperty(object $target, string $propertyName): bool + { + $propertyExists = property_exists($target, $propertyName); + if ($target instanceof \stdClass) { + return $propertyExists; + } + + if (!$propertyExists) { + if (method_exists($target, '__set')) { + return false; + } + + throw new \InvalidArgumentException( + 'Object property [' . $target::class . '::$' . $propertyName + . '] does not exist and no magic setter is available.', + ); + } + + $property = new \ReflectionProperty($target, $propertyName); + if ($property->isReadOnly()) { + throw new \InvalidArgumentException( + 'Object property [' . $target::class . '::$' . $propertyName . '] is readonly.', + ); + } + + if ($property->isPublic()) { + return true; + } + + if (method_exists($target, '__set')) { + return false; + } + + throw new \InvalidArgumentException( + 'Object property [' . $target::class . '::$' . $propertyName . '] is not publicly writable.', + ); + } + + private static function isDirectKey(int|string $key): bool + { + return is_int($key) || (!str_contains($key, '.') && !str_contains($key, '\\')); + } + /** * Get a stable sentinel that represents a missing key path. */ @@ -139,7 +194,7 @@ private static function resolveValue( ?int $maxNodes = null, bool $throwOnTooDeep = false, ): mixed { - if (is_array($target) && ArraySingle::exists($target, $key)) { + if (self::isDirectKey($key) && is_array($target) && ArraySingle::exists($target, $key)) { return $target[$key]; } @@ -239,6 +294,10 @@ private static function setValueBySegments( if ($segment === '*') { if (!is_array($target)) { + if (!$overwrite) { + return; + } + $target = []; } @@ -269,6 +328,10 @@ private static function setValueFallback( mixed $value, bool $overwrite, ): void { + if (!$overwrite) { + return; + } + $segment = self::unescapeSegment($segment); $target = []; if ($position < count($segments)) { @@ -292,11 +355,15 @@ private static function setValueObject( bool $overwrite, ): void { $segment = self::unescapeSegment($segment); - $propertyExists = property_exists($target, $segment); + $propertyExists = self::hasWritableObjectProperty($target, $segment); if ($position < count($segments)) { if (!$propertyExists) { - $target->{$segment} = []; + $nested = []; + self::setValueBySegments($nested, $segments, $position, $value, $overwrite); + $target->{$segment} = $nested; + + return; } self::setValueBySegments($target->{$segment}, $segments, $position, $value, $overwrite); diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index b8570a8..45a49db 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -4,6 +4,7 @@ namespace Infocyph\ArrayKit\Array; +/** @internal */ final class DotNotationPathOps { /** @@ -31,6 +32,15 @@ public static function accessSegment(mixed $target, int|string $segment, object } } + public static function escapePathSegment(string $segment): string + { + return str_replace( + ['\\', '.', '*', '{first}', '{last}'], + ['\\\\', '\\.', '\\*', '\\{first}', '\\{last}'], + $segment, + ); + } + /** * Normalize a dot-notation segment by replacing escaped values and resolving * special values such as '{first}' and '{last}'. diff --git a/src/Collection/Concerns/BaseCollectionTrait.php b/src/Collection/Concerns/BaseCollectionTrait.php index 291fff2..c6b7b66 100644 --- a/src/Collection/Concerns/BaseCollectionTrait.php +++ b/src/Collection/Concerns/BaseCollectionTrait.php @@ -11,6 +11,7 @@ use JsonSerializable; use Traversable; +/** @internal */ trait BaseCollectionTrait { /** diff --git a/src/Collection/HookedCollection.php b/src/Collection/HookedCollection.php index 2411bcb..e0dcefb 100644 --- a/src/Collection/HookedCollection.php +++ b/src/Collection/HookedCollection.php @@ -16,6 +16,18 @@ class HookedCollection extends Collection { use HookTrait; + /** + * Create an isolated copy while retaining registered hooks. + */ + #[\Override] + public function copy(): static + { + $copy = parent::copy(); + $copy->hooks = $this->hooks; + + return $copy; + } + /** * Gets an item at the given offset. * diff --git a/src/Collection/Pipeline.php b/src/Collection/Pipeline.php index d2ecca5..10b7df8 100644 --- a/src/Collection/Pipeline.php +++ b/src/Collection/Pipeline.php @@ -105,9 +105,9 @@ public function diff(array $values, bool $strict = false): Collection * Keep only duplicate values, using ArraySingle::duplicates. * (Typically this means setting $this->working to the *list of duplicates*.) */ - public function duplicates(): Collection + public function duplicates(bool $strict = false): Collection { - $this->working = ArraySingle::duplicates($this->working); + $this->working = ArraySingle::duplicates($this->working, $strict); return $this->collection; } @@ -639,17 +639,6 @@ public function unless(bool $condition, callable $callback, ?callable $default = return $this->when(!$condition, $callback, $default); } - /** - * Example: Unwrap an array if it has exactly one element, from BaseArrayHelper::unWrap. - */ - public function unWrap(): Collection - { - $unwrapped = BaseArrayHelper::unWrap($this->working); - $this->working = is_array($unwrapped) ? $unwrapped : [$unwrapped]; - - return $this->collection; - } - /** * Reindex the working set numerically. */ @@ -794,16 +783,6 @@ public function whereStartsWith(string $key, string $prefix, bool $caseSensitive return $this->collection; } - /** - * Wrap the entire array if it's not already an array, from BaseArrayHelper::wrap - */ - public function wrap(): Collection - { - return $this->mutateWorking( - fn(array $working): array => BaseArrayHelper::wrap($working), - ); - } - /** * Apply a transform to the current working set and keep the chain alive. * diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index 8db5ad3..bf0eeda 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -13,6 +13,7 @@ use UnexpectedValueException; use UnitEnum as TEnum; +/** @internal */ trait BaseConfigTrait { private const int MAX_READ_CACHE_ENTRIES = 1024; @@ -57,9 +58,12 @@ public function append(string $key, mixed $value): bool { $this->assertWritable(); - $array = $this->get($key, []); - if (!is_array($array)) { + $missing = $this->missingValueMarker(); + $array = $this->get($key, $missing); + if ($array === $missing) { $array = []; + } elseif (!is_array($array)) { + throw new InvalidArgumentException("Config value [{$key}] must be an array."); } $array[] = $value; @@ -159,11 +163,10 @@ public function get(string|int|array|null $key = null, mixed $default = null): m /** * 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 + public function getArray(string|int $key, ?array $default = null): ?array { $value = $this->get($key, $default); @@ -172,10 +175,8 @@ public function getArray(string|int|array|null $key, ?array $default = null): ?a /** * 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 + public function getBool(string|int $key, ?bool $default = null): ?bool { $value = $this->get($key, $default); @@ -187,10 +188,9 @@ public function getBool(string|int|array|null $key, ?bool $default = null): ?boo * * @template TEnum of \UnitEnum * - * @param string|int|array|null $key * @param class-string $enumClass */ - public function getEnum(string|int|array|null $key, string $enumClass, ?\UnitEnum $default = null): ?\UnitEnum + public function getEnum(string|int $key, string $enumClass, ?\UnitEnum $default = null): ?\UnitEnum { if (!enum_exists($enumClass)) { throw new InvalidArgumentException("Enum class [{$enumClass}] does not exist."); @@ -224,10 +224,8 @@ public function getEnum(string|int|array|null $key, string $enumClass, ?\UnitEnu /** * 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 + public function getFloat(string|int $key, ?float $default = null): ?float { $value = $this->get($key, $default); @@ -236,10 +234,8 @@ public function getFloat(string|int|array|null $key, ?float $default = null): ?f /** * 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 + public function getInt(string|int $key, ?int $default = null): ?int { $value = $this->get($key, $default); @@ -249,11 +245,10 @@ public function getInt(string|int|array|null $key, ?int $default = null): ?int /** * 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 + public function getList(string|int $key, ?array $default = null): ?array { $value = $this->get($key, $default); if (!is_array($value) || !array_is_list($value)) { @@ -266,15 +261,28 @@ public function getList(string|int|array|null $key, ?array $default = null): ?ar /** * Get a required configuration value or throw when missing. * - * @param string|int|array|null $key + * @param string|int|array $key */ - public function getOrFail(string|int|array|null $key): mixed + public function getOrFail(string|int|array $key): mixed { $missing = new \stdClass(); - $value = $this->get($key, $missing); + if (is_array($key)) { + $values = []; + foreach ($key as $path) { + $value = $this->get($path, $missing); + if ($value === $missing) { + throw new OutOfBoundsException("Required config key [{$path}] is missing."); + } + $values[(string) $path] = $value; + } + + return $values; + } + + $value = $this->get($key, $missing); if ($value === $missing) { - throw new OutOfBoundsException('Required config key is missing.'); + throw new OutOfBoundsException("Required config key [{$key}] is missing."); } return $value; @@ -282,10 +290,8 @@ public function getOrFail(string|int|array|null $key): mixed /** * 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 + public function getString(string|int $key, ?string $default = null): ?string { $value = $this->get($key, $default); @@ -421,9 +427,12 @@ public function prepend(string $key, mixed $value): bool { $this->assertWritable(); - $array = $this->get($key, []); - if (!is_array($array)) { + $missing = $this->missingValueMarker(); + $array = $this->get($key, $missing); + if ($array === $missing) { $array = []; + } elseif (!is_array($array)) { + throw new InvalidArgumentException("Config value [{$key}] must be an array."); } array_unshift($array, $value); diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 02f2fd7..74cd239 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -7,8 +7,11 @@ use RuntimeException; use UnexpectedValueException; +/** @internal */ trait LazyFileConfigCacheTrait { + private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; + /** * @var array */ @@ -27,22 +30,29 @@ public function flushNamespaceCache(string|array|null $namespaces = null): stati return $this; } - if ($namespaces === null) { - $this->flushAllNamespaceCacheFiles(); + if (!is_dir($this->namespaceCacheDirectory)) { + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; return $this; } - foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { - $path = $this->cachedNamespacePath($namespace); - if ($path !== null && is_file($path)) { - unlink($path); + return $this->withNamespaceCacheLock(function () use ($namespaces): void { + if ($namespaces === null) { + $this->flushAllNamespaceCacheFiles(); + + return; } - } - $this->writeFlatLeafIndexFromCacheDirectory(); + foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { + $path = $this->cachedNamespacePath($namespace); + if ($path !== null && is_file($path)) { + unlink($path); + } + } - return $this; + $this->writeFlatLeafIndexFromCacheDirectory(); + }); } public function namespaceCache(?string $directory): static @@ -75,24 +85,24 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); } - foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { - $this->loadNamespace($namespace); + return $this->withNamespaceCacheLock(function () use ($namespaces): void { + foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { + $this->loadNamespace($namespace); - if (!array_key_exists($namespace, $this->items) || !is_array($this->items[$namespace])) { - throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); - } + if (!array_key_exists($namespace, $this->items) || !is_array($this->items[$namespace])) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } - $export = var_export($this->materializeCacheValue($this->items[$namespace]), true); - $path = $this->cachedNamespacePath($namespace); + $export = var_export($this->materializeCacheValue($this->items[$namespace]), true); + $path = $this->cachedNamespacePath($namespace); - if ($path === null || !$this->writeCacheFile($path, "writeCacheFile($path, "writeFlatLeafIndexFromCacheDirectory(); - return $this; + $this->writeFlatLeafIndexFromCacheDirectory(); + }); } protected function cachedNamespacePath(string $namespace): ?string @@ -111,6 +121,10 @@ protected function cachedNamespacePath(string $namespace): ?string protected function collectFlatLeafIndex(string $namespace, array $namespaceData, array &$index, string $prefix = ''): void { foreach ($namespaceData as $key => $value) { + if (!$this->isFlatPathSafeSegment((string) $key)) { + continue; + } + $path = $prefix === '' ? $namespace . '.' . $key : $prefix . '.' . $key; @@ -368,6 +382,14 @@ private function flushAllNamespaceCacheFiles(): void $this->flatLeafIndexLoaded = false; } + private function isFlatPathSafeSegment(string $segment): bool + { + return !str_contains($segment, '.') + && !str_contains($segment, '\\') + && !str_contains($segment, '*') + && !str_contains($segment, '{'); + } + private function isOwnedNamespaceCacheEntry(string $entry): bool { if ($entry === self::FLAT_INDEX_FILE) { @@ -383,4 +405,33 @@ private function isOwnedNamespaceCacheEntry(string $entry): bool return $namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1; } + + /** + * Hold one exclusive lock across namespace writes/deletes and flat-index rebuilding. + */ + private function withNamespaceCacheLock(\Closure $operation): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + $lock = fopen($directory . DIRECTORY_SEPARATOR . self::CACHE_LOCK_FILE, 'c+'); + if ($lock === false) { + throw new RuntimeException('Unable to open lazy-config cache lock.'); + } + + try { + if (!flock($lock, LOCK_EX)) { + throw new RuntimeException('Unable to acquire lazy-config cache lock.'); + } + + $operation(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + + return $this; + } } diff --git a/src/Config/Support/EnvLineParser.php b/src/Config/Support/EnvLineParser.php index 07fa0a1..80373f7 100644 --- a/src/Config/Support/EnvLineParser.php +++ b/src/Config/Support/EnvLineParser.php @@ -200,7 +200,7 @@ private static function stripInlineComment(string $value): string { $length = strlen($value); for ($index = 0; $index < $length; $index++) { - if ($value[$index] === '#' && $index > 0 && ctype_space($value[$index - 1])) { + if ($value[$index] === '#' && $index > 0 && str_contains(" \t\n\r\0\x0B", $value[$index - 1])) { return substr($value, 0, $index); } } diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php index ae6f171..2f559da 100644 --- a/src/DTO/Concerns/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\DTO\Concerns; use ReflectionNamedType; +use ReflectionObject; use ReflectionProperty; /** @@ -47,7 +48,7 @@ public function hydrate(array $values, array $mapping = [], bool $coerce = false { foreach ($values as $key => $value) { $property = is_string($key) && isset($mapping[$key]) ? $mapping[$key] : $key; - if (!is_string($property) || !property_exists($this, $property)) { + if (!is_string($property) || !$this->isHydratableProperty($property)) { continue; } @@ -67,7 +68,7 @@ public function hydrateNested(array $values, array $mapping = [], bool $coerce = { foreach ($values as $key => $value) { $property = is_string($key) && isset($mapping[$key]) ? $mapping[$key] : $key; - if (!is_string($property) || !property_exists($this, $property)) { + if (!is_string($property) || !$this->isHydratableProperty($property)) { continue; } @@ -94,7 +95,14 @@ public function replaceFromArray(array $values, array $mapping = [], bool $coerc */ public function toArray(): array { - return get_object_vars($this); + $result = []; + foreach (new ReflectionObject($this)->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if (!$property->isStatic() && $property->isInitialized($this)) { + $result[$property->getName()] = $property->getValue($this); + } + } + + return $result; } /** @@ -105,7 +113,7 @@ public function toArray(): array public function toArrayDeep(): array { $result = []; - foreach (get_object_vars($this) as $key => $value) { + foreach ($this->toArray() as $key => $value) { $result[$key] = $this->exportValue($value); } @@ -122,6 +130,12 @@ private function assignProperty(string $property, mixed $value, bool $coerce): v $reflection = new ReflectionProperty($this, $property); $type = $reflection->getType(); + if ($value === null && $type?->allowsNull()) { + $this->{$property} = null; + + return; + } + if (!$type instanceof ReflectionNamedType || $type->isBuiltin() === false) { $this->{$property} = $value; @@ -162,6 +176,17 @@ private function exportValue(mixed $value): mixed return $value; } + private function isHydratableProperty(string $property): bool + { + if (!property_exists($this, $property)) { + return false; + } + + $reflection = new ReflectionProperty($this, $property); + + return $reflection->isPublic() && !$reflection->isStatic(); + } + private function resolveNestedValue(string $property, mixed $value): mixed { if (!is_array($value)) { diff --git a/src/DTO/DTO.php b/src/DTO/DTO.php new file mode 100644 index 0000000..b8b6a19 --- /dev/null +++ b/src/DTO/DTO.php @@ -0,0 +1,17 @@ + Config::class, 'LazyFileConfig' => LazyFileConfig::class, 'Config Hook-Aware Variants' => Config::class, + 'EnvParser' => EnvParser::class, + 'Environment' => Environment::class, + 'DTO' => DTO::class, 'DTOTrait' => DTOTrait::class, 'HookTrait' => HookTrait::class, 'LazyCollection' => LazyCollection::class, @@ -113,6 +119,8 @@ expect($lines)->toBeArray(); $activeClass = null; + $documentedMethods = []; + $documentedFunctions = []; foreach ($lines as $index => $line) { $nextLine = $lines[$index + 1] ?? ''; if (preg_match('/^-{3,}$/', $nextLine) === 1) { @@ -121,10 +129,37 @@ continue; } + if (preg_match('/^\s+function Infocyph\\\\ArrayKit\\\\(?[A-Za-z_][A-Za-z0-9_]*)\((?.*)\)(?:: (?[^\/]+))?/', $line, $functionMatches) === 1) { + $functionName = 'Infocyph\\ArrayKit\\' . $functionMatches['name']; + $function = new ReflectionFunction($functionName); + $documentedFunctions[$functionName] = true; + $documentedParameters = trim($functionMatches['parameters']) === '' + ? [] + : array_map($parseParameter, preg_split('/,\s*/', $functionMatches['parameters'])); + + expect($documentedParameters)->toHaveCount(count($function->getParameters()), $functionName) + ->and($reflectionType($function->getReturnType()))->toBe( + $normalizeType(isset($functionMatches['return']) ? trim($functionMatches['return']) : null), + $functionName . ' return type', + ); + + foreach ($function->getParameters() as $parameterIndex => $actual) { + $documented = $documentedParameters[$parameterIndex]; + expect($documented['name'])->toBe($actual->getName(), $functionName) + ->and($documented['type'])->toBe($reflectionType($actual->getType()), $functionName . ' $' . $actual->getName()) + ->and($documented['reference'])->toBe($actual->isPassedByReference(), $functionName . ' $' . $actual->getName()) + ->and($documented['hasDefault'])->toBe($actual->isDefaultValueAvailable(), $functionName . ' $' . $actual->getName()); + } + + continue; + } + if ($activeClass === null || preg_match('/^\s+public (?static )?function (?[A-Za-z_][A-Za-z0-9_]*)\((?.*)\)(?:: (?[^\/]+))?/', $line, $matches) !== 1) { continue; } + $documentedMethods[$activeClass][$matches['name']] = true; + $method = new ReflectionMethod($activeClass, $matches['name']); $declaringType = $method->getDeclaringClass()->getShortName(); $documentedParameters = trim($matches['parameters']) === '' @@ -164,4 +199,33 @@ ->and($documented['default'])->toBe($actualDefault, $activeClass . '::' . $method->getName() . ' $' . $actual->getName()); } } + + foreach (array_unique(array_values($sections)) as $class) { + $reflection = new ReflectionClass($class); + $actualMethods = []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() === $reflection->getName()) { + $actualMethods[] = $method->getName(); + } + } + + $documented = array_keys($documentedMethods[$class] ?? []); + sort($actualMethods); + sort($documented); + expect($documented)->toBe($actualMethods, $class . ' documented method set'); + } + + $actualFunctions = []; + $helperPath = realpath(__DIR__ . '/../../src/namespaced-functions.php'); + foreach (get_defined_functions()['user'] as $functionName) { + $function = new ReflectionFunction($functionName); + if ($function->getFileName() === $helperPath) { + $actualFunctions[] = $function->getName(); + } + } + + $documentedFunctionNames = array_keys($documentedFunctions); + sort($actualFunctions); + sort($documentedFunctionNames); + expect($documentedFunctionNames)->toBe($actualFunctions, 'Namespaced helper function set'); }); diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index f2513dc..f8ed7fe 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -373,6 +373,16 @@ ]); }); +it('keeps whereIn results stable around its adaptive lookup boundary', function () { + $rows = array_map( + static fn(int $id): array => ['id' => $id], + range(0, 999), + ); + + expect(ArrayMulti::whereIn($rows, 'id', range(0, 254), true))->toHaveCount(255) + ->and(ArrayMulti::whereIn($rows, 'id', range(0, 255), true))->toHaveCount(256); +}); + it('preserves PHP loose comparison semantics in whereIn()', function () { $rows = [ ['id' => 1, 'role' => true], @@ -382,6 +392,17 @@ expect(ArrayMulti::whereIn($rows, 'role', ['admin']))->toBe($rows); }); +it('preserves loose boolean and string comparisons in optimized row membership', function () { + $values = array_map(static fn(int $index): string => 'role-'.$index, range(1, 256)); + $rows = [['role' => true], ['role' => false]]; + + expect(ArrayMulti::whereIn($rows, 'role', $values))->toBe([ + 0 => ['role' => true], + ])->and(ArrayMulti::whereNotIn($rows, 'role', $values))->toBe([ + 1 => ['role' => false], + ])->and(ArrayMulti::firstWhereIn($rows, 'role', $values))->toBe(['role' => true]); +}); + it('distinguishes explicit null from shorthand row comparisons', function () { $rows = [ ['id' => 1, 'role' => null], @@ -416,9 +437,69 @@ ])->and(ArrayMulti::countBy($rows, 'role'))->toBe([ '_undefined' => 1, '' => 1, + ])->and(ArrayMulti::uniqueBy($rows, 'role'))->toBe([ + 0 => ['id' => 1, 'role' => '_undefined'], + 1 => ['id' => 2, 'role' => ''], + ])->and(ArrayMulti::duplicatesBy($rows, 'role'))->toBe([]) + ->and(ArrayMulti::pluck($rows, 'id', 'role'))->toBe([ + '_undefined' => 1, + '' => 2, + ]); +}); + +it('treats explicit null as a derived value while skipping missing fields', function () { + $rows = [ + ['id' => 1, 'role' => null], + ['id' => 2], + ['id' => 3, 'role' => null], + ]; + + expect(ArrayMulti::uniqueBy($rows, 'role', true))->toBe([ + 0 => ['id' => 1, 'role' => null], + ])->and(ArrayMulti::duplicatesBy($rows, 'role', true))->toBe([ + 2 => ['id' => 3, 'role' => null], ]); }); +it('always treats strings as field names in field-or-callback APIs', function () { + $rows = [ + 10 => ['trim' => 'b', 'amount' => 2], + 20 => ['trim' => 'a', 'amount' => 3], + 30 => ['trim' => 'b', 'amount' => 4], + ]; + + expect(ArrayMulti::groupBy($rows, 'trim'))->toHaveKeys(['a', 'b']) + ->and(ArrayMulti::keyBy($rows, 'trim')['a'])->toBe($rows[20]) + ->and(ArrayMulti::countBy($rows, 'trim'))->toBe(['b' => 2, 'a' => 1]) + ->and(ArrayMulti::uniqueBy($rows, 'trim'))->toBe([ + 10 => $rows[10], + 20 => $rows[20], + ])->and(ArrayMulti::duplicatesBy($rows, 'trim'))->toBe([ + 30 => $rows[30], + ])->and(array_keys(ArrayMulti::sortBy($rows, 'trim')))->toBe([20, 10, 30]) + ->and(ArrayMulti::sum($rows, 'amount'))->toBe(9); +}); + +it('preserves large integer precision in row numeric operations', function () { + $low = 9007199254740992; + $high = 9007199254740993; + $rows = [ + 'low' => ['score' => $low], + 'high' => ['score' => $high], + ]; + + expect(ArrayMulti::min($rows, 'score'))->toBe($low) + ->and(ArrayMulti::max($rows, 'score'))->toBe($high) + ->and(ArrayMulti::maxBy($rows, 'score'))->toBe($rows['high']) + ->and(ArrayMulti::sum([['score' => $high], ['score' => -$low]], 'score'))->toBe(1) + ->and(array_keys(ArrayMulti::sortBy($rows, 'score', options: SORT_NUMERIC)))->toBe(['low', 'high']); +}); + +it('treats callable strings as values in ambiguous row APIs', function () { + expect(ArrayMulti::contains(['trim', 'other'], 'trim'))->toBeTrue() + ->and(ArrayMulti::reject(['trim', 'other'], 'trim'))->toBe([1 => 'other']); +}); + it('rejects null and other invalid derived array keys', function (mixed $invalid) { $rows = [['id' => 1, 'group' => $invalid]]; @@ -696,3 +777,19 @@ function (array $row, string $key) use (&$calls): int { ->and(fn () => ArrayMulti::sortRecursiveGuarded($deep, maxDepth: 2, throwOnTooDeep: true)) ->toThrow(RuntimeException::class); }); + +it('rejects invalid chunks and ragged transpose matrices', function () { + expect(fn () => ArrayMulti::chunk([[1]], 0))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArrayMulti::transpose([[1, 2], [3]]))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArrayMulti::transpose([1, 2]))->toThrow(InvalidArgumentException::class); +}); + +it('handles closed resources through strict scan fallback', function () { + $resource = fopen('php://memory', 'rb'); + fclose($resource); + + expect(ArrayMulti::uniqueBy([ + ['value' => $resource], + ['value' => $resource], + ], 'value', true))->toHaveCount(1); +}); diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index 878a04c..ba9ed9b 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -45,7 +45,7 @@ }); it('ignores non-numeric values when calculating median', function () { - expect(ArraySingle::median([1, '2', 7.5, 'ignore', null]))->toBe(2.0) + expect(ArraySingle::median([1, '2', 7.5, 'ignore', null]))->toBe(2) ->and(ArraySingle::median(['ignore', null]))->toBe(0); }); @@ -65,6 +65,19 @@ ->and(ArraySingle::containsAny($data, ['x', '2'], true))->toBeFalse(); }); +it('keeps strict membership results stable across adaptive strategy boundaries', function () { + $haystack = range(0, 999); + $small = range(10, 24); + $large = range(10, 521); + + expect(ArraySingle::containsAll($haystack, $small, true))->toBeTrue() + ->and(ArraySingle::containsAll($haystack, $large, true))->toBeTrue() + ->and(ArraySingle::containsAny($haystack, [...range(1000, 1190), 500], true))->toBeTrue() + ->and(ArraySingle::containsAny($haystack, range(1000, 1191), true))->toBeFalse() + ->and(ArraySingle::intersect($haystack, $large, true))->toBe(array_combine($large, $large)) + ->and(ArraySingle::diff($haystack, $large, true))->toHaveCount(488); +}); + it('preserves PHP loose comparison semantics for mixed scalar membership', function () { expect(ArraySingle::containsAny(['enabled'], [true]))->toBeTrue() ->and(ArraySingle::containsAny([null], ['0']))->toBeFalse() @@ -129,7 +142,7 @@ expect(ArraySingle::sum($arr))->toBe(3) ->and(ArraySingle::sum($arr, fn ($value, $key) => is_numeric($value) ? ((float) $value + $key) : null)) - ->toBe(9); + ->toBe(9.0); }); it('filters non-empty values without crashing on mixed data', function () { @@ -153,6 +166,33 @@ ->toBe([1, '1', 2, 3]); // Strict comparison }); +it('supports loose and strict duplicate detection explicitly', function () { + $values = [1, '1']; + + expect(ArraySingle::duplicates($values))->toBe([1]) + ->and(ArraySingle::duplicates($values, true))->toBe([]); +}); + +it('preserves large integer precision in numeric selection and accumulation', function () { + $low = 9007199254740992; + $high = 9007199254740993; + + expect(ArraySingle::min([$high, $low]))->toBe($low) + ->and(ArraySingle::max([$low, $high]))->toBe($high) + ->and(ArraySingle::median([$high]))->toBe($high) + ->and(ArraySingle::sum([$high, -$low]))->toBe(1) + ->and(ArraySingle::maxBy( + [['score' => $low], ['score' => $high]], + static fn(array $row): int => $row['score'], + ))->toBe(['score' => $high]); +}); + +it('treats callable strings as values in ambiguous value-or-callback APIs', function () { + expect(ArraySingle::contains(['trim', 'other'], 'trim'))->toBeTrue() + ->and(ArraySingle::search(['trim', 'other'], 'trim'))->toBe(0) + ->and(ArraySingle::reject(['trim', 'other'], 'trim'))->toBe([1 => 'other']); +}); + it('handles unique() with mixed values in loose and strict modes', function () { $arr = [1, '1', true, [1], ['1']]; @@ -254,6 +294,21 @@ ->and(fn () => ArraySingle::paginate([1, 2, 3], 1, 0))->toThrow(InvalidArgumentException::class); }); +it('throws for invalid structural arguments and derived keys', function () { + expect(fn () => ArraySingle::chunk([1, 2], 0))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArraySingle::combine(['a'], [1, 2]))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArraySingle::countBy([1], fn () => null))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArraySingle::rekey(['a' => 1], fn () => false))->toThrow(InvalidArgumentException::class); +}); + +it('uses ordinary callback truthiness for search', function () { + expect(ArraySingle::search([0, 2], fn (int $value): int => $value))->toBe(1); +}); + +it('calculates mode from integer and string values only', function () { + expect(ArraySingle::mode([1, 1, true, true, null, 1.0, ['value']]))->toBe([1]); +}); + it('paginates arrays for valid page and per-page values', function () { $arr = [1, 2, 3, 4, 5]; diff --git a/tests/Feature/BaseArrayHelperTest.php b/tests/Feature/BaseArrayHelperTest.php index 37a2244..a76578d 100644 --- a/tests/Feature/BaseArrayHelperTest.php +++ b/tests/Feature/BaseArrayHelperTest.php @@ -40,8 +40,20 @@ expect($res)->toBeTrue(); }); +it('uses normal truthy callback semantics for any and all checks', function () { + expect(BaseArrayHelper::haveAny([0, 2], static fn(int $value): int => $value)) + ->toBeTrue() + ->and(BaseArrayHelper::isAll([1, 2], static fn(int $value): int => $value))->toBeTrue() + ->and(BaseArrayHelper::isAll([1, 0], static fn(int $value): int => $value))->toBeFalse(); +}); + it('finds the first key matching a callback', function () { $data = ['a' => 10, 'b' => 15, 'c' => 20]; $key = BaseArrayHelper::findKey($data, fn ($val) => $val > 10); - expect($key)->toBe('b'); + expect($key)->toBe('b') + ->and(BaseArrayHelper::findKey([0, 2], fn (int $value): int => $value))->toBe(1); +}); + +it('rejects a zero range step', function () { + expect(fn () => BaseArrayHelper::range(1, 5, 0))->toThrow(InvalidArgumentException::class); }); diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 48280ac..b076b3b 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -51,6 +51,29 @@ ->and(fn () => $cfg->getOrFail('app.missing'))->toThrow(\OutOfBoundsException::class); }); +it('validates every required key in bulk getOrFail calls', function () { + $cfg = new Config; + $cfg->loadArray(['app' => ['name' => 'ArrayKit', 'nullable' => null]]); + + expect($cfg->getOrFail(['app.name', 'app.nullable']))->toBe([ + 'app.name' => 'ArrayKit', + 'app.nullable' => null, + ])->and(fn () => $cfg->getOrFail(['app.name', 'app.missing'])) + ->toThrow(OutOfBoundsException::class); +}); + +it('appends and prepends only to missing or array config values', function () { + $cfg = new Config; + $cfg->append('plugins', 'cache'); + $cfg->prepend('plugins', 'auth'); + $cfg->set('scalar', 'value'); + + expect($cfg->get('plugins'))->toBe(['auth', 'cache']) + ->and(fn () => $cfg->append('scalar', 'lost'))->toThrow(InvalidArgumentException::class) + ->and(fn () => $cfg->prepend('scalar', 'lost'))->toThrow(InvalidArgumentException::class) + ->and($cfg->get('scalar'))->toBe('value'); +}); + it('supports typed getters with default fallbacks', function () { $cfg = new Config; $cfg->loadArray([ diff --git a/tests/Feature/DTOTraitTest.php b/tests/Feature/DTOTraitTest.php index 68a1d9a..5fa616c 100644 --- a/tests/Feature/DTOTraitTest.php +++ b/tests/Feature/DTOTraitTest.php @@ -70,3 +70,28 @@ 'address' => ['city' => 'Paris'], ]); }); + +it('hydrates and exports only public instance properties', function () { + $dto = new class + { + use DTOTrait; + + public static string $shared = 'original'; + + public ?string $nickname = 'before'; + + protected string $protectedValue = 'protected'; + + private string $privateValue = 'private'; + }; + + $dto->hydrate([ + 'shared' => 'changed', + 'nickname' => null, + 'protectedValue' => 'changed', + 'privateValue' => 'changed', + ], coerce: true); + + expect($dto->toArray())->toBe(['nickname' => null]) + ->and($dto::$shared)->toBe('original'); +}); diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index f439755..ed35354 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -28,6 +28,60 @@ ]); }); +it('round trips literal path-control characters through flatten and expand', function () { + $source = [ + 'service.name' => [ + 'path\\part' => [ + '*' => ['{first}' => ['{last}' => 'value']], + ], + ], + ]; + + $flat = DotNotation::flatten($source); + + expect(DotNotation::expand($flat))->toBe($source) + ->and(DotNotation::paths($source))->toBe(array_keys($flat)); +}); + +it('writes only to supported object properties', function () { + $object = new class + { + public array $profile = []; + + public readonly string $identifier; + + private string $secret = 'hidden'; + + public function __construct() + { + $this->identifier = 'fixed'; + } + }; + $target = ['object' => $object, 'dynamic' => new stdClass]; + + DotNotation::set($target, 'object.profile.name', 'Ada'); + DotNotation::set($target, 'dynamic.created', true); + + expect($object->profile)->toBe(['name' => 'Ada']) + ->and($target['dynamic']->created)->toBeTrue() + ->and(fn () => DotNotation::set($target, 'object.secret', 'visible')) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => DotNotation::set($target, 'object.missing', 'value')) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => DotNotation::set($target, 'object.identifier', 'changed')) + ->toThrow(InvalidArgumentException::class); +}); + +it('renames overlapping parent and child paths without losing the captured value', function () { + $parentToChild = ['a' => ['b' => 1, 'c' => 2]]; + $childToParent = ['a' => ['b' => 1, 'c' => 2]]; + + expect(DotNotation::rename($parentToChild, 'a', 'a.moved'))->toBeTrue() + ->and($parentToChild)->toBe(['a' => ['moved' => ['b' => 1, 'c' => 2]]]) + ->and(DotNotation::move($childToParent, 'a.b', 'a'))->toBeTrue() + ->and($childToParent)->toBe(['a' => 1]); +}); + it('gets a nested value with dot notation', function () { $array = ['db' => ['host' => 'localhost', 'port' => 3306]]; expect(DotNotation::get($array, 'db.port')) @@ -47,6 +101,69 @@ expect($array)->toBe(['user' => ['name' => 'Alice']]); }); +it('uses dotted strings as paths and escaped dots as literal keys consistently', function () { + $array = [ + 'foo.bar' => 1, + 'foo' => ['bar' => 2], + ]; + + expect(DotNotation::get($array, 'foo.bar'))->toBe(2) + ->and(DotNotation::get($array, 'foo\\.bar'))->toBe(1) + ->and(DotNotation::has($array, 'foo.bar'))->toBeTrue() + ->and(DotNotation::has($array, 'foo\\.bar'))->toBeTrue(); + + DotNotation::set($array, 'foo.bar', 3); + DotNotation::set($array, 'foo\\.bar', 4); + + expect($array)->toBe([ + 'foo.bar' => 4, + 'foo' => ['bar' => 3], + ]); + + DotNotation::forget($array, 'foo.bar'); + DotNotation::forget($array, 'foo\\.bar'); + + expect($array)->toBe(['foo' => []]); +}); + +it('does not remove or replace scalar parents when a nested path cannot be filled or forgotten', function () { + $array = ['a' => 'scalar']; + + DotNotation::forget($array, 'a.b'); + DotNotation::fill($array, 'a.b', 1); + + expect($array)->toBe(['a' => 'scalar']); +}); + +it('forgets terminal wildcards at root and nested paths', function () { + $nested = [ + 'users' => [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ], + 'meta' => true, + ]; + DotNotation::forget($nested, 'users.*'); + + expect($nested)->toBe(['users' => [], 'meta' => true]); + + DotNotation::forget($nested, '*'); + expect($nested)->toBe([]); +}); + +it('renames the same path location that it reads', function () { + $array = [ + 'foo.bar' => 'literal', + 'foo' => ['bar' => 'nested'], + ]; + + expect(DotNotation::rename($array, 'foo.bar', 'foo.baz'))->toBeTrue() + ->and($array)->toBe([ + 'foo.bar' => 'literal', + 'foo' => ['baz' => 'nested'], + ]); +}); + // // Test flatten() and expand() // diff --git a/tests/Feature/HookedCollectionTest.php b/tests/Feature/HookedCollectionTest.php index 37a8378..330c9f8 100644 --- a/tests/Feature/HookedCollectionTest.php +++ b/tests/Feature/HookedCollectionTest.php @@ -50,3 +50,16 @@ ->toBeInstanceOf(HookedCollection::class) ->and($filtered->all())->toBe([1 => 2, 3 => 4]); }); + +it('preserves hooks when copied while keeping pipeline state independent', function () { + $collection = new HookedCollection(['name' => 'alice']); + $collection->onGet('name', static fn(string $value): string => strtoupper($value)); + $collection->process(); + + $copy = $collection->copy(); + $copy->set('name', 'bob'); + + expect($copy['name'])->toBe('BOB') + ->and($collection['name'])->toBe('ALICE') + ->and($copy->process())->not->toBe($collection->process()); +}); diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index 61a6c34..8f4d0b9 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -411,6 +411,83 @@ function lazyConfigFlatIndex(string $directory): array ]); }); +it('excludes path-sensitive keys from the flat leaf index and falls back to namespace data', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', [ + 'safe' => 'indexed', + 'literal.key' => 'namespace', + '*' => 'wildcard', + '{first}' => 'selector', + ]); + + $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + $config->warmNamespaceCache('app'); + + expect(lazyConfigFlatIndex($this->cachePath))->toBe(['app.safe' => 'indexed']); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + expect($fresh->get('app.literal\\.key'))->toBe('namespace') + ->and($fresh->loaded('app'))->toBeTrue(); +}); + +it('can atomically replace existing namespace and flat cache files', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', ['version' => 1]); + $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + $config->warmNamespaceCache('app'); + + $config->replace(['app' => ['version' => 2]]); + $config->warmNamespaceCache('app'); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + expect($fresh->get('app.version'))->toBe(2) + ->and(lazyConfigFlatIndex($this->cachePath))->toBe(['app.version' => 2]); +}); + +it('keeps namespace and flat cache rebuilds coherent across two processes', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', ['version' => 1, 'name' => 'ArrayKit']); + + $worker = <<<'PHP' +require '__AUTOLOAD__'; +$config = new \Infocyph\ArrayKit\Config\LazyFileConfig('__CONFIG__', namespaceCacheDirectory: '__CACHE__'); +for ($index = 0; $index < 20; $index++) { + if (__FLUSH__) { + $config->flushNamespaceCache('app'); + } + $config->warmNamespaceCache('app'); +} +PHP; + $replacements = [ + '__AUTOLOAD__' => addslashes(dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php'), + '__CONFIG__' => addslashes($this->configPath), + '__CACHE__' => addslashes($this->cachePath), + ]; + $warmCode = strtr($worker, [...$replacements, '__FLUSH__' => 'false']); + $flushCode = strtr($worker, [...$replacements, '__FLUSH__' => 'true']); + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + + $warm = proc_open([PHP_BINARY, '-r', $warmCode], $descriptors, $warmPipes); + $flush = proc_open([PHP_BINARY, '-r', $flushCode], $descriptors, $flushPipes); + + expect(is_resource($warm))->toBeTrue() + ->and(is_resource($flush))->toBeTrue(); + + $warmError = stream_get_contents($warmPipes[2]); + $flushError = stream_get_contents($flushPipes[2]); + foreach ([...$warmPipes, ...$flushPipes] as $pipe) { + fclose($pipe); + } + + expect(proc_close($warm))->toBe(0, $warmError) + ->and(proc_close($flush))->toBe(0, $flushError); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + expect($fresh->get('app.version'))->toBe(1) + ->and($fresh->get('app.name'))->toBe('ArrayKit') + ->and(lazyConfigFlatIndex($this->cachePath))->toBe([ + 'app.name' => 'ArrayKit', + 'app.version' => 1, + ]); +}); + it('can resolve exact scalar paths from the flat index when namespace structure is unavailable', function () { lazyConfigWriteArrayFile($this->configPath, 'db', [ 'host' => 'localhost', diff --git a/tests/Feature/PipelineTest.php b/tests/Feature/PipelineTest.php index cc4dcff..b6ed91b 100644 --- a/tests/Feature/PipelineTest.php +++ b/tests/Feature/PipelineTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Infocyph\ArrayKit\Collection\Collection; +use Infocyph\ArrayKit\Collection\Pipeline; it('supports keyBy/indexBy/mapWithKeys/countBy in pipelines', function () { $rows = Collection::make([ @@ -48,7 +49,7 @@ ->and($rows->process()->firstWhere('score', '>=', 20))->toBe(['id' => 2, 'score' => 40]); }); -it('supports values, rekey, deep merge helpers, and unwrap alias', function () { +it('supports values, rekey, and deep merge helpers', function () { $collection = Collection::make(['first_name' => 'Ada', 'last_name' => 'Lovelace']); $renamed = $collection->copy()->rekey(['first_name' => 'firstName'])->all(); @@ -64,9 +65,9 @@ ]); $single = Collection::make(['only']); - expect($single->copy()->unwrap()->all())->toBe(['only']) - ->and($single->copy()->unWrap()->all())->toBe(['only']) - ->and($single->copy()->values()->all())->toBe(['only']); + expect($single->copy()->values()->all())->toBe(['only']) + ->and(method_exists(Pipeline::class, 'wrap'))->toBeFalse() + ->and(method_exists(Pipeline::class, 'unWrap'))->toBeFalse(); }); it('supports sortByMany and row-query convenience helpers in pipelines', function () {