diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index e7839d3..5eecba0 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,17 +5,75 @@ on: - cron: "0 0 * * 0" push: branches: [ "main", "master" ] + tags: [ "v*", "[0-9]*" ] pull_request: branches: [ "main", "master", "develop", "development" ] jobs: + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: infocyph/phpforge/.github/workflows/release.yml@main + permissions: + contents: write + secrets: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + phpforge: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') uses: infocyph/phpforge/.github/workflows/security-standards.yml@main with: integration_services: '[]' service_topologies: '{}' + benchmark_composer_script: "benchmark:representative" + benchmark_result_file: "build/release-candidate.json" + benchmark_baseline_file: "build/release-baseline.json" + benchmark_max_regression_percent: 2 + benchmark_stable_environment: false permissions: security-events: write actions: read contents: read + release-acceptance: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') + name: "Release Acceptance" + needs: phpforge + runs-on: ubuntu-latest + env: + XDEBUG_MODE: off + steps: + - uses: actions/checkout@v7 + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer:v2 + extensions: opcache + ini-values: opcache.enable_cli=1 + coverage: none + - name: Install acceptance dependencies + run: composer install --no-interaction --prefer-dist --no-progress + - name: Run ArrayKit release acceptance tests + run: >- + php vendor/bin/pest + --configuration vendor/infocyph/phpforge/resources/pest.xml + --bootstrap vendor/autoload.php + tests/Feature/Release530BatchFTest.php + - name: Run persistent-worker soak + run: | + mkdir -p build + composer ic:soak:worker \ + --duration=300 \ + --warmup=10 \ + --sample-interval=1 \ + --max-growth-mb=16 \ + --report=build/release-worker-soak.json \ + -- php -d opcache.enable_cli=1 tests/Support/release530-persistent-worker.php + - name: Upload worker-soak evidence + if: always() && hashFiles('build/release-worker-soak.json') != '' + uses: actions/upload-artifact@v7 + with: + name: release-worker-soak + path: build/release-worker-soak.json + if-no-files-found: error + retention-days: 61 diff --git a/README.md b/README.md index 6135d13..47e1d6c 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ configuration or object models. | **Collection** | OOP array wrapper implementing `ArrayAccess`, `IteratorAggregate`, `Countable`, `JsonSerializable`. | | **HookedCollection** | Extends `Collection` with **on-get/on-set hooks** for real-time transformation of values. | | **Pipeline** | Functional-style pipeline for chaining operations on collections. | -| **LazyCollection** | Repeatable lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`), including one-shot generators and renewable factories. | +| **LazyCollection** | Repeatable lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`), including one-shot generators, renewable factories, and optional passed-instance Runwire cancellation/yield checkpoints. | | **BaseCollectionTrait** | Shared collection behavior. | @@ -90,6 +90,8 @@ configuration or object models. * **PHP 8.4** or higher +Runwire is optional. ArrayKit 5.3 tests its lazy-runtime integration against `infocyph/runwire` 2.1.1; ordinary ArrayKit installation has no Runwire runtime dependency. + ## Installation @@ -295,7 +297,7 @@ $row = ArrayShape::require( $config = new LazyFileConfig(__DIR__ . '/config', namespaceCacheDirectory: __DIR__ . '/bootstrap/cache/config'); $config->warmNamespaceCache(['db', 'cache']); -// Exact scalar leaf reads can hit bootstrap/cache/config/__flat.php first. +// Exact scalar leaf reads can hit the active generation's .arraykit-flat.php first. $host = $config->get('db.host'); ``` @@ -308,7 +310,7 @@ $host = $config->get('db.host'); - `DotNotation` treats existing `null` keys/properties as present (does not fall back to defaults). - `DotNotation::hasWildcard()`, `paths()`, `matches()`, `rename()`, and `move()` are available for wildcard/path operations. - For untrusted/deep payloads, use bounded traversal variants: `DotNotation::getSafe()`, `ArrayMulti::depthGuarded()`, `flattenGuarded()`, and `sortRecursiveGuarded()`. -- `LazyFileConfig` namespace cache writes one cache file per namespace plus a shared `__flat.php` file containing only final scalar/null leaf values for exact-key fast paths. +- `LazyFileConfig` publishes immutable cache generations selected by `.arraykit-generation`; each generation contains namespace files plus `.arraykit-flat.php` for exact scalar/null fast paths. ## Security diff --git a/benchmarks/CollectionBench.php b/benchmarks/CollectionBench.php index 366b514..0e9908d 100644 --- a/benchmarks/CollectionBench.php +++ b/benchmarks/CollectionBench.php @@ -37,6 +37,11 @@ public function benchCollectionMap(): void Collection::make($this->data)->map(static fn(int $value): int => $value * 2); } + public function benchLazyArrayReplayMaterialization(): void + { + LazyCollection::from($this->data)->all(); + } + public function benchLazyChunkMaterialization(): void { LazyCollection::fromFactory(fn(): array => $this->data) @@ -44,6 +49,11 @@ public function benchLazyChunkMaterialization(): void ->all(); } + public function benchLazyFactoryArrayMaterialization(): void + { + LazyCollection::fromFactory(fn(): array => $this->data)->all(); + } + public function benchLazyFilterMaterialization(): void { LazyCollection::fromFactory(fn(): array => $this->data) @@ -51,6 +61,16 @@ public function benchLazyFilterMaterialization(): void ->all(); } + public function benchLazyGeneratorReplayMaterialization(): void + { + $data = $this->data; + $source = (static function () use ($data): \Generator { + yield from $data; + })(); + + LazyCollection::from($source)->all(); + } + public function benchLazyMapFilterTake(): void { LazyCollection::fromFactory(fn(): array => $this->data) diff --git a/benchmarks/LazyFileConfigBench.php b/benchmarks/LazyFileConfigBench.php index 2be78f0..e3806f7 100644 --- a/benchmarks/LazyFileConfigBench.php +++ b/benchmarks/LazyFileConfigBench.php @@ -58,29 +58,29 @@ public function setUp(): void public function tearDown(): void { - foreach (['app.php', '__flat.php'] as $file) { - $cachePath = $this->cacheDirectory . DIRECTORY_SEPARATOR . $file; - if (is_file($cachePath)) { - unlink($cachePath); + $remove = static function (string $path) use (&$remove): void { + if (is_file($path)) { + unlink($path); + + return; } - } - $sourcePath = $this->sourceDirectory . DIRECTORY_SEPARATOR . 'app.php'; - if (is_file($sourcePath)) { - unlink($sourcePath); - } + if (!is_dir($path)) { + return; + } - if (is_dir($this->cacheDirectory)) { - rmdir($this->cacheDirectory); - } - if (is_dir($this->sourceDirectory)) { - rmdir($this->sourceDirectory); - } + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } - $base = dirname($this->sourceDirectory); - if (is_dir($base)) { - rmdir($base); - } + $remove($path . DIRECTORY_SEPARATOR . $entry); + } + + rmdir($path); + }; + + $remove(dirname($this->sourceDirectory)); } public function benchAlreadyLoadedNamespace(): void diff --git a/benchmarks/RunwireLazyCollectionBench.php b/benchmarks/RunwireLazyCollectionBench.php new file mode 100644 index 0000000..22f5e31 --- /dev/null +++ b/benchmarks/RunwireLazyCollectionBench.php @@ -0,0 +1,74 @@ + */ + private array $data = []; + + private RequestContext $request; + + private RuntimeContext $runtime; + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->data = range(1, $params['size']); + $this->runtime = RuntimeContext::standalone(); + $this->request = RequestContext::create($this->runtime); + } + + public function benchBoundRequestMapFilter(): void + { + LazyCollection::from($this->data) + ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->all(); + } + + public function benchBoundRequestMaterialization(): void + { + LazyCollection::from($this->data) + ->withRunwire($this->runtime, $this->request, checkpointEvery: 256) + ->all(); + } + + public function benchUnboundMapFilter(): void + { + LazyCollection::from($this->data) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->all(); + } + + public function benchUnboundMaterialization(): void + { + LazyCollection::from($this->data)->all(); + } + + /** @return array */ + public function provideSizes(): array + { + return [ + '1k' => ['size' => 1000], + '10k' => ['size' => 10000], + '100k' => ['size' => 100000], + ]; + } +} diff --git a/benchmarks/release-representative.php b/benchmarks/release-representative.php new file mode 100644 index 0000000..ccd87d6 --- /dev/null +++ b/benchmarks/release-representative.php @@ -0,0 +1,461 @@ + $workloadSpecs */ +$workloadSpecs = [ + ['name' => 'array-query', 'operations' => 100, 'warmup' => 20], + ['name' => 'dot-config', 'operations' => 100, 'warmup' => 20], + ['name' => 'lazy-array', 'operations' => 100, 'warmup' => 20], + ['name' => 'lazy-file-cold', 'operations' => 40, 'warmup' => 10], + ['name' => 'lazy-file-generated', 'operations' => 60, 'warmup' => 10], +]; + +$root = dirname(__DIR__); +$worker = __DIR__ . '/release-workload.php'; +$buildDirectory = $root . '/build'; +$baselineDirectory = sys_get_temp_dir() . '/arraykit-release-baseline-' . getmypid(); + +if (!is_dir($buildDirectory) && !mkdir($buildDirectory, 0777, true)) { + throw new RuntimeException('Unable to create release benchmark build directory.'); +} + +releasePrepareBaseline($root, $baselineDirectory); + +try { + $environment = releaseEnvironment(); + $baselineSha = trim(releaseProcess(['git', 'rev-parse', RELEASE_BASELINE_TAG . '^{commit}'], $root)); + $candidateSha = trim(releaseProcess(['git', 'rev-parse', 'HEAD'], $root)); + + $baseline = releaseBenchmarkDocument( + $baselineDirectory, + $worker, + RELEASE_BASELINE_TAG . '@' . $baselineSha, + $environment, + $workloadSpecs, + ); + $candidate = releaseBenchmarkDocument( + $root, + $worker, + 'candidate@' . $candidateSha, + $environment, + $workloadSpecs, + ); + + releaseWriteJson($buildDirectory . '/release-baseline.json', $baseline); + releaseWriteJson($buildDirectory . '/release-candidate.json', $candidate); + + fwrite(STDOUT, sprintf( + "Representative release benchmark generated for %s and %s.\n", + $baseline['environment']['release'], + $candidate['environment']['release'], + )); +} finally { + releaseRemoveBaseline($root, $baselineDirectory); +} + +/** + * @param array $environment + * @param list $specs + * @return array + */ +function releaseBenchmarkDocument( + string $root, + string $worker, + string $release, + array $environment, + array $specs, +): array { + $workloads = []; + + foreach ($specs as $spec) { + foreach ([1, 2, 4] as $concurrency) { + $workloads[] = releaseBenchmarkWorkload( + $root, + $worker, + $spec, + $concurrency, + ); + } + } + + return [ + 'schema_version' => 1, + 'generated_at' => gmdate(DATE_ATOM), + 'environment' => [ + ...$environment, + 'release' => $release, + ], + 'workloads' => $workloads, + ]; +} + +/** + * @param array{name:string,operations:int,warmup:int} $spec + * @return array + */ +function releaseBenchmarkWorkload( + string $root, + string $worker, + array $spec, + int $concurrency, +): array { + $trials = []; + + for ($trial = 0; $trial < RELEASE_REPETITIONS; $trial++) { + $trials[] = releaseBenchmarkTrial( + $root, + $worker, + $spec['name'], + $spec['operations'], + $spec['warmup'], + $concurrency, + ); + } + + $rpms = array_column($trials, 'successful_rpm'); + $latencies = []; + $attempted = 0; + $successful = 0; + $failed = 0; + $cpuAverages = []; + $cpuPeaks = []; + $memoryAverages = []; + $memoryPeaks = []; + $memoryGrowth = []; + + foreach ($trials as $trial) { + $attempted += $trial['attempted']; + $successful += $trial['successful']; + $failed += $trial['failed']; + $latencies = [...$latencies, ...$trial['latencies_ms']]; + $cpuAverages[] = $trial['cpu_average_percent']; + $cpuPeaks[] = $trial['cpu_peak_percent']; + $memoryAverages[] = $trial['memory_average_mb']; + $memoryPeaks[] = $trial['memory_peak_mb']; + $memoryGrowth[] = $trial['memory_growth_mb']; + } + + $medianRpm = releasePercentile($rpms, 50); + $spread = $medianRpm > 0 + ? ((max($rpms) - min($rpms)) / $medianRpm) * 100 + : 100.0; + + return [ + 'name' => $spec['name'] . '-c' . $concurrency, + 'type' => 'component', + 'metadata' => [ + 'suite' => 'arraykit-5.3-release', + 'dataset' => 'deterministic-v1', + 'operations_per_worker' => $spec['operations'], + 'valid_output_only' => true, + ], + 'repetitions' => RELEASE_REPETITIONS, + 'warmup_operations' => $spec['warmup'], + 'duration_seconds' => 0.0, + 'concurrency' => $concurrency, + 'result' => [ + 'attempted_operations' => $attempted, + 'successful_operations' => $successful, + 'failed_operations' => $failed, + 'timeouts' => 0, + 'successful_rpm' => $medianRpm, + 'error_rate' => $attempted > 0 ? $failed / $attempted : 0.0, + 'latency_ms' => [ + 'minimum' => $latencies === [] ? null : min($latencies), + 'average' => $latencies === [] ? null : array_sum($latencies) / count($latencies), + 'p50' => releasePercentile($latencies, 50), + 'p95' => releasePercentile($latencies, 95), + 'p99' => releasePercentile($latencies, 99), + 'maximum' => $latencies === [] ? null : max($latencies), + ], + 'cpu' => [ + 'average_percent' => releaseAverage($cpuAverages), + 'peak_percent' => $cpuPeaks === [] ? null : max($cpuPeaks), + ], + 'memory' => [ + 'average_mb' => releaseAverage($memoryAverages), + 'peak_mb' => $memoryPeaks === [] ? null : max($memoryPeaks), + 'growth_mb' => $memoryGrowth === [] ? null : max($memoryGrowth), + ], + 'stability' => [ + 'status' => $failed === 0 && $spread <= RELEASE_STABILITY_SPREAD_PERCENT + ? 'stable' + : 'unstable', + 'spread_percent' => max(0.0, $spread), + ], + ], + ]; +} + +/** + * @return array{ + * attempted:int, + * successful:int, + * failed:int, + * successful_rpm:float, + * latencies_ms:list, + * cpu_average_percent:float, + * cpu_peak_percent:float, + * memory_average_mb:float, + * memory_peak_mb:float, + * memory_growth_mb:float + * } + */ +function releaseBenchmarkTrial( + string $root, + string $worker, + string $workload, + int $operations, + int $warmup, + int $concurrency, +): array { + $processes = []; + $startedAt = hrtime(true); + + for ($workerIndex = 0; $workerIndex < $concurrency; $workerIndex++) { + $process = new Process([ + PHP_BINARY, + '-d', + 'opcache.enable_cli=1', + '-d', + 'opcache.jit=0', + $worker, + $root, + $workload, + (string) $operations, + (string) $warmup, + ]); + $process->setTimeout(120); + $process->start(); + $processes[] = $process; + } + + $results = []; + + foreach ($processes as $process) { + $exitCode = $process->wait(); + if ($exitCode !== 0) { + throw new RuntimeException( + 'Release benchmark worker failed: ' . trim($process->getErrorOutput()), + ); + } + + $decoded = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new RuntimeException('Release benchmark worker returned invalid JSON.'); + } + + $results[] = $decoded; + } + + $elapsedSeconds = max((hrtime(true) - $startedAt) / 1_000_000_000, 0.000001); + $attempted = 0; + $successful = 0; + $failed = 0; + $latencies = []; + $cpuSeconds = 0.0; + $cpuPeaks = []; + $memoryInitial = []; + $memoryFinal = []; + $memoryPeak = []; + + foreach ($results as $result) { + $attempted += (int) ($result['attempted'] ?? 0); + $successful += (int) ($result['successful'] ?? 0); + $failed += (int) ($result['failed'] ?? 0); + $latencies = [ + ...$latencies, + ...array_map('floatval', is_array($result['latencies_ms'] ?? null) ? $result['latencies_ms'] : []), + ]; + $cpuSeconds += (float) ($result['cpu_seconds'] ?? 0.0); + $cpuPeaks[] = (float) ($result['cpu_percent'] ?? 0.0); + $memoryInitial[] = (float) ($result['memory_initial_mb'] ?? 0.0); + $memoryFinal[] = (float) ($result['memory_final_mb'] ?? 0.0); + $memoryPeak[] = (float) ($result['memory_peak_mb'] ?? 0.0); + } + + $averageInitial = releaseAverage($memoryInitial) ?? 0.0; + $averageFinal = releaseAverage($memoryFinal) ?? 0.0; + + return [ + 'attempted' => $attempted, + 'successful' => $successful, + 'failed' => $failed, + 'successful_rpm' => ($successful / $elapsedSeconds) * 60, + 'latencies_ms' => $latencies, + 'cpu_average_percent' => ($cpuSeconds / $elapsedSeconds) * 100, + 'cpu_peak_percent' => $cpuPeaks === [] ? 0.0 : max($cpuPeaks), + 'memory_average_mb' => $averageFinal, + 'memory_peak_mb' => $memoryPeak === [] ? 0.0 : max($memoryPeak), + 'memory_growth_mb' => max(0.0, $averageFinal - $averageInitial), + ]; +} + +/** + * @return array + */ +function releaseEnvironment(): array +{ + $extensions = get_loaded_extensions(); + sort($extensions); + + $cpuModel = 'unknown'; + $cpuInfo = is_readable('/proc/cpuinfo') ? file_get_contents('/proc/cpuinfo') : false; + if (is_string($cpuInfo) && preg_match('/^model name\s*:\s*(.+)$/m', $cpuInfo, $matches) === 1) { + $cpuModel = trim($matches[1]); + } + + $operatingSystem = php_uname('s') . ' ' . php_uname('r'); + $runner = getenv('RUNNER_NAME'); + $runner = is_string($runner) && $runner !== '' ? $runner : php_uname('n'); + $fingerprintPayload = implode('|', [ + PHP_VERSION, + PHP_SAPI, + $operatingSystem, + $cpuModel, + $runner, + implode(',', $extensions), + ]); + + return [ + 'stable' => getenv('ARRAYKIT_BENCHMARK_STABLE') === '1', + 'fingerprint' => hash('sha256', $fingerprintPayload), + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'operating_system' => $operatingSystem, + 'cpu_model' => $cpuModel, + 'memory_limit' => (string) ini_get('memory_limit'), + 'opcache' => 'cli-enabled', + 'jit' => false, + 'xdebug' => extension_loaded('xdebug'), + 'extensions' => $extensions, + 'runner' => $runner, + ]; +} + +function releaseAverage(array $values): ?float +{ + return $values === [] ? null : array_sum($values) / count($values); +} + +function releasePercentile(array $values, int $percentile): ?float +{ + if ($values === []) { + return null; + } + + sort($values, SORT_NUMERIC); + $index = (int) ceil(($percentile / 100) * count($values)) - 1; + $index = max(0, min(count($values) - 1, $index)); + + return (float) $values[$index]; +} + +function releasePrepareBaseline(string $root, string $baselineDirectory): void +{ + releaseRemoveBaseline($root, $baselineDirectory); + + $verify = new Process(['git', 'rev-parse', '--verify', RELEASE_BASELINE_TAG . '^{commit}'], $root); + $verify->run(); + + if (!$verify->isSuccessful()) { + releaseProcess([ + 'git', + 'fetch', + '--depth=1', + 'origin', + 'refs/tags/' . RELEASE_BASELINE_TAG . ':refs/tags/' . RELEASE_BASELINE_TAG, + ], $root); + } + + releaseProcess( + ['git', 'worktree', 'add', '--detach', '--force', $baselineDirectory, RELEASE_BASELINE_TAG], + $root, + ); + releaseProcess([ + 'composer', + 'install', + '--no-dev', + '--no-interaction', + '--prefer-dist', + '--no-progress', + '--classmap-authoritative', + ], $baselineDirectory, 240); +} + +function releaseProcess(array $command, string $workingDirectory, int $timeout = 120): string +{ + $process = new Process($command, $workingDirectory, ['XDEBUG_MODE' => 'off']); + $process->setTimeout($timeout); + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf( + "Command failed: %s\n%s", + implode(' ', $command), + trim($process->getErrorOutput()), + )); + } + + return $process->getOutput(); +} + +function releaseRemoveBaseline(string $root, string $baselineDirectory): void +{ + if (!is_dir($baselineDirectory)) { + return; + } + + $remove = new Process( + ['git', 'worktree', 'remove', '--force', $baselineDirectory], + $root, + ); + $remove->setTimeout(60); + $remove->run(); + + if (is_dir($baselineDirectory)) { + releaseRemoveDirectory($baselineDirectory); + } +} + +function releaseRemoveDirectory(string $directory): void +{ + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + releaseRemoveDirectory($path); + } elseif (is_file($path) || is_link($path)) { + unlink($path); + } + } + + rmdir($directory); +} + +/** + * @param array $document + */ +function releaseWriteJson(string $path, array $document): void +{ + $encoded = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + if (file_put_contents($path, $encoded . PHP_EOL) === false) { + throw new RuntimeException('Unable to write release benchmark result: ' . $path); + } +} diff --git a/benchmarks/release-workload.php b/benchmarks/release-workload.php new file mode 100644 index 0000000..4afc3a4 --- /dev/null +++ b/benchmarks/release-workload.php @@ -0,0 +1,274 @@ + ', + ); +} + +require rtrim($root, DIRECTORY_SEPARATOR) . '/vendor/autoload.php'; + +$operations = max(1, (int) $operations); +$warmup = max(0, (int) $warmup); +$temporaryDirectories = []; + +$operation = match ($workload) { + 'array-query' => releaseArrayQueryWorkload(), + 'dot-config' => releaseDotConfigWorkload(), + 'lazy-array' => releaseLazyArrayWorkload(), + 'lazy-file-cold' => releaseLazyFileColdWorkload($temporaryDirectories), + 'lazy-file-generated' => releaseLazyFileGeneratedWorkload($temporaryDirectories), + default => throw new RuntimeException('Unknown release benchmark workload: ' . $workload), +}; + +for ($index = 0; $index < $warmup; $index++) { + $operation(); +} + +$usageBefore = getrusage(); +$memoryBefore = memory_get_usage(true); +$startedAt = hrtime(true); +$latencies = []; +$successes = 0; +$failures = 0; + +for ($index = 0; $index < $operations; $index++) { + $operationStartedAt = hrtime(true); + + try { + $operation(); + $successes++; + } catch (\Throwable) { + $failures++; + } + + $latencies[] = (hrtime(true) - $operationStartedAt) / 1_000_000; +} + +$elapsedSeconds = max((hrtime(true) - $startedAt) / 1_000_000_000, 0.000001); +$usageAfter = getrusage(); +$cpuSeconds = releaseCpuSeconds($usageAfter) - releaseCpuSeconds($usageBefore); +$memoryAfter = memory_get_usage(true); + +foreach ($temporaryDirectories as $directory) { + releaseRemoveDirectory($directory); +} + +fwrite(STDOUT, json_encode([ + 'attempted' => $operations, + 'successful' => $successes, + 'failed' => $failures, + 'elapsed_seconds' => $elapsedSeconds, + 'latencies_ms' => $latencies, + 'cpu_seconds' => max(0.0, $cpuSeconds), + 'cpu_percent' => max(0.0, ($cpuSeconds / $elapsedSeconds) * 100), + 'memory_initial_mb' => $memoryBefore / 1_048_576, + 'memory_final_mb' => $memoryAfter / 1_048_576, + 'memory_peak_mb' => memory_get_peak_usage(true) / 1_048_576, +], JSON_THROW_ON_ERROR) . PHP_EOL); + +/** + * @return \Closure(): void + */ +function releaseArrayQueryWorkload(): \Closure +{ + $rows = []; + for ($index = 1; $index <= 320; $index++) { + $rows[] = [ + 'id' => $index, + 'name' => 'user-' . $index, + 'group' => $index % 7, + ]; + } + + $needles = range(1, 256); + + return static function () use ($rows, $needles): void { + $matched = ArrayMulti::whereIn($rows, 'id', $needles, true); + $like = ArrayMulti::whereLike($rows, 'name', 'user-%', true); + $page = ArraySingle::paginate($rows, 4, 50); + + if (count($matched) !== 256 || count($like) !== 320 || count($page) !== 50) { + throw new RuntimeException('Array/query release workload returned invalid data.'); + } + }; +} + +function releaseCpuSeconds(array $usage): float +{ + $user = ((int) ($usage['ru_utime.tv_sec'] ?? 0)) + + (((int) ($usage['ru_utime.tv_usec'] ?? 0)) / 1_000_000); + $system = ((int) ($usage['ru_stime.tv_sec'] ?? 0)) + + (((int) ($usage['ru_stime.tv_usec'] ?? 0)) / 1_000_000); + + return $user + $system; +} + +/** + * @return \Closure(): void + */ +function releaseDotConfigWorkload(): \Closure +{ + $rows = []; + for ($index = 1; $index <= 64; $index++) { + $rows[] = ['id' => $index, 'profile' => ['active' => true]]; + } + + $data = ['rows' => $rows]; + $config = new Config(); + $config->loadArray([ + 'app' => [ + 'name' => 'arraykit', + 'nested' => ['value' => 42], + ], + ]); + + return static function () use ($config, $data): void { + $ids = DotNotation::getSafe( + $data, + 'rows.*.id', + maxDepth: 8, + maxNodes: 1_000, + throwOnTooDeep: true, + ); + $flat = ArrayMulti::flattenGuarded( + [['a' => 1], ['b' => 2], ['c' => 3]], + maxDepth: 8, + maxNodes: 32, + throwOnTooDeep: true, + ); + + if ( + count($ids) !== 64 + || $flat !== [1, 2, 3] + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Dot/config release workload returned invalid data.'); + } + }; +} + +/** + * @return \Closure(): void + */ +function releaseLazyArrayWorkload(): \Closure +{ + $values = range(1, 256); + + return static function () use ($values): void { + $result = LazyCollection::from($values) + ->mapLazy(static fn(int $value): int => $value * 2) + ->filterLazy(static fn(int $value): bool => ($value % 3) === 0) + ->take(64) + ->all(); + + if (count($result) !== 64) { + throw new RuntimeException('Lazy array release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return \Closure(): void + */ +function releaseLazyFileColdWorkload(array &$temporaryDirectories): \Closure +{ + [$source] = releaseLazyFileDirectories($temporaryDirectories); + + return static function () use ($source): void { + $config = new LazyFileConfig($source); + + if ( + $config->get('app.name') !== 'arraykit' + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Cold lazy-file release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return \Closure(): void + */ +function releaseLazyFileGeneratedWorkload(array &$temporaryDirectories): \Closure +{ + [$source, $cache] = releaseLazyFileDirectories($temporaryDirectories); + + (new LazyFileConfig($source, namespaceCacheDirectory: $cache)) + ->warmNamespaceCache('app'); + + return static function () use ($cache, $source): void { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + if ( + $config->get('app.name') !== 'arraykit' + || $config->get('app.nested.value') !== 42 + ) { + throw new RuntimeException('Generated lazy-file release workload returned invalid data.'); + } + }; +} + +/** + * @param list $temporaryDirectories + * @return array{0:string,1:string} + */ +function releaseLazyFileDirectories(array &$temporaryDirectories): array +{ + $suffix = bin2hex(random_bytes(5)); + $source = sys_get_temp_dir() . '/arraykit-release-source-' . $suffix; + $cache = sys_get_temp_dir() . '/arraykit-release-cache-' . $suffix; + + if (!mkdir($source, 0777, true) || !mkdir($cache, 0777, true)) { + throw new RuntimeException('Unable to create release benchmark directories.'); + } + + $payload = " 'arraykit', 'nested' => ['value' => 42]];\n"; + if (file_put_contents($source . '/app.php', $payload) === false) { + throw new RuntimeException('Unable to write release benchmark config.'); + } + + $temporaryDirectories[] = $source; + $temporaryDirectories[] = $cache; + + return [$source, $cache]; +} + +function releaseRemoveDirectory(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + releaseRemoveDirectory($path); + } elseif (is_file($path) || is_link($path)) { + unlink($path); + } + } + + rmdir($directory); +} diff --git a/composer.json b/composer.json index e60268a..07bcb13 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,11 @@ "ext-hash": "*" }, "require-dev": { - "infocyph/phpforge": "dev-main@dev" + "infocyph/phpforge": "dev-main@dev", + "infocyph/runwire": "2.1.1" + }, + "suggest": { + "infocyph/runwire": "Optional LazyCollection cancellation and cooperative-yield integration; ArrayKit 5.3 is tested against Runwire 2.1.1." }, "minimum-stability": "stable", "prefer-stable": true, @@ -49,5 +53,8 @@ "classmap-authoritative": true, "optimize-autoloader": true, "sort-packages": true + }, + "scripts": { + "benchmark:representative": "@php benchmarks/release-representative.php" } } diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index 0746772..fa02073 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -358,6 +358,7 @@ Behavior Notes the two-argument shorthand form. - ``ArrayMulti::flatten($array, 0)`` returns unchanged top-level values. - Use ``depthGuarded()``, ``flattenGuarded()``, and ``sortRecursiveGuarded()`` when processing untrusted/deep inputs. +- A non-throwing recursive sort retains the order of a parent whose child traversal was cut short, instead of comparing unvisited nested values. Throwing mode rejects the limit breach. - ``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. diff --git a/docs/collection.rst b/docs/collection.rst index 1a27ac0..d46acce 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -273,10 +273,13 @@ LazyCollection -------------- Use ``LazyCollection`` for generator-backed transformations over large iterables. -Collections built with ``from()`` replay values already read from a one-shot -generator without eagerly materializing the source. That replay cache grows with -the portion consumed, so use ``fromFactory()`` for long-lived or unbounded -sources when each traversal can create a fresh iterable. +Array sources without top-level PHP references are replayed directly without +allocating a per-entry replay memo. Reference-bearing arrays use the same +consumed-value memo as one-shot iterators: unread references remain live, while +values already consumed are replayed. One-shot iterators and generators replay values already +read without eagerly materializing the remaining source; their replay cache grows +with the portion consumed. Use ``fromFactory()`` for long-lived or unbounded +renewable sources when each traversal can create a fresh iterable. .. code-block:: php @@ -304,6 +307,75 @@ sources when each traversal can create a fresh iterable. yield from fetchEvents(); }); +Optional Runwire Binding +------------------------ + +Runwire is optional. Ordinary ``LazyCollection`` use does not require or load +Runwire. When a host already owns a Runwire runtime, pass those exact instances +to ``withRunwire()``: + +.. code-block:: php + + withRunwire( + $runtime, + request: $request, + checkpointEvery: 256, + ) + ->filterLazy(fn (array $row): bool => $row['active']) + ->mapLazy(fn (array $row): int => $row['id']) + ->all(); + +An active ``CoroutineScope`` may also be passed. ArrayKit calls +``yieldNow()`` only when that scope is present **and** the supplied runtime +advertises Runwire coroutine capability. Request and scope cancellation are +checked before source consumption and periodically before source advancement. +Derived lazy operations forward the same binding through upstream work, so +filtered-out rows are covered too. + +``checkpointEvery`` is an item-consumption interval from 1 through 1,000,000. +A completed request or a request belonging to another ``RuntimeContext`` is +rejected at the binding boundary. Completion after binding is checked again +before consumption and at checkpoints, including after cooperative resumption. +Cancellation, expired deadlines, a closed +active scope, source errors, and callback errors remain terminal exceptions. + +ArrayKit never creates or drives a Runwire runtime/event loop, completes a +request, closes a scope, or stores these bindings globally. Missing coroutine +capability disables cooperative yielding; a public, read-only task-local lookup +checks the scope's lifecycle without creating work or modifying task-local state. +Synchronous traversal and applicable +cancellation checks remain available. ``take(0)`` stays fully lazy. + +An intermediary library should forward the exact host-owned instances instead +of reconstructing runtime metadata. + +.. code-block:: php + + withRunwire($runtime, $request, $scope); + } + + Terminal calculations: .. code-block:: php diff --git a/docs/config-layering.rst b/docs/config-layering.rst index 941742e..05e8dec 100644 --- a/docs/config-layering.rst +++ b/docs/config-layering.rst @@ -38,7 +38,7 @@ LayeredLazyFileConfig ``LayeredLazyFileConfig`` composes three layers with this precedence: -``fallback < lazy source < overrides`` +``fallback < lazy source < overrides < runtime mutations`` Only the requested namespace is materialized. Exact path reads are resolved from the fully merged namespace, so list replacement and scalar shadowing cannot leak @@ -76,9 +76,9 @@ cache miss and retries the authoritative source namespace. Invalid source files still fail normally; resilience applies only to disposable generated cache artifacts. -Malformed or invalid ``__flat.php`` indexes are also treated as cache misses. -This keeps an acceleration artifact from preventing source configuration from -loading. +Malformed generated namespace files and invalid ``.arraykit-flat.php`` indexes +are disposable cache artifacts. Generated files live in immutable generations +selected by ``.arraykit-generation``; authoritative source failures remain visible. Environment Enumeration ----------------------- diff --git a/docs/config.rst b/docs/config.rst index 496c891..dec88dc 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -435,8 +435,11 @@ Compiled Cache + Read Memoization $host = $cached->get('db.host'); When ``exportCache()`` writes the PHP cache file, ``Environment::ref()`` values -and closures are recursively resolved first. The generated cache contains only -the resolved values, not closures or reference objects. +and closures are recursively resolved first. Export accepts scalar/null values, +arrays of supported values, and enum cases. Unsupported objects/resources and +cyclic value graphs raise ``UnexpectedValueException`` before publication, so an +existing valid cache file is left intact. Generated PHP syntax is validated +before the atomic rename. Method Summary -------------- @@ -458,10 +461,12 @@ Config methods: - ``readonly()``, ``isReadonly()`` Read memoization is bounded to 1,024 resolved paths for predictable memory use -in persistent workers. Plain top-level string and integer keys use direct array -lookup instead of entering the path cache; nested, escaped, and wildcard paths -remain memoized. Every mutation family, restore, and reload invalidates the -memoized values. +in persistent workers. Plain top-level keys use direct array lookup. Nested +scalar/null paths through ordinary non-referenced arrays can be memoized; +object-backed, referenced, escaped, wildcard, selector, and structural values +are resolved live so externally mutable state cannot become stale. Every +mutation family, restore, reload, and generated-cache source transition +invalidates affected memoized values. Hook-aware methods: diff --git a/docs/facade.rst b/docs/facade.rst index de2d75f..0b57485 100644 --- a/docs/facade.rst +++ b/docs/facade.rst @@ -55,6 +55,7 @@ Behavior Notes - ``env()`` reads the current runtime environment. - ``dotenv()`` exposes the ``.env`` file parser. - Proxy calls map directly to target static methods. +- Reference mutations accept native named arguments: ``dot()->forget(target: $data, keys: 'path')`` and ``helper()->forget(array: $data, keys: 'key')``. Dot callers may also use the proxy's existing ``array:`` argument; supplying both aliases is rejected. - Calling a missing method via proxy throws ``BadMethodCallException``. Related Guides diff --git a/docs/index.rst b/docs/index.rst index f99499e..892dde3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,8 +26,10 @@ Contents config lazy-config config-layering + lifecycle traits-and-helpers migration + release-5.3.0 rule-reference The feature pages above are guide-style usage docs. diff --git a/docs/lazy-config.rst b/docs/lazy-config.rst index 362ff83..70ce487 100644 --- a/docs/lazy-config.rst +++ b/docs/lazy-config.rst @@ -50,8 +50,9 @@ Important Behavior Namespace Cache --------------- -``LazyFileConfig`` can warm one cache file per namespace plus a shared exact-leaf -index. +``LazyFileConfig`` publishes generated namespace caches for deployment/bootstrap +use. Publication uses immutable generations rather than overwriting files that +active workers may already be reading. .. code-block:: php @@ -68,11 +69,15 @@ index. Cache behavior: -- ``namespaceCache()`` configures an optional per-namespace cache directory. -- ``warmNamespaceCache()`` writes cached namespace files and a shared ``__flat.php`` exact-leaf index. -- Exact-key scalar reads check ``__flat.php`` first. -- Structural, wildcard, and namespace reads fall back to namespace cache files. -- ``Environment::ref()`` values and closures are resolved before namespace cache files are written. +- ``namespaceCache()`` configures an optional generated-cache root. +- ``warmNamespaceCache()`` builds a complete hidden generation and atomically switches ``.arraykit-generation`` only after publication succeeds. +- Each generation contains one file per cached namespace plus ``.arraykit-flat.php`` for exact scalar/null leaves. +- Exact-key scalar reads may use the flat index without materializing the namespace. +- Structural, wildcard, and namespace reads use the same pinned immutable generation as exact reads. +- ``Environment::ref()`` values and closures are resolved before publication. +- Warm-up rereads the authoritative source unless the caller explicitly supplied or mutated that namespace in memory; an older generated cache does not feed a new source-backed generation. +- Readers do not take the writer lock. The first cache lookup pins a generation for that instance. External publication does not change its view. Construct a new instance or call ``namespaceCache()`` explicitly to refresh; warm-up and flush on the instance also reset its generated state, retaining intentional runtime overrides. +- Writers copy unmodified namespaces from the latest published generation even when that writer was previously a pinned reader. Partial merges retain source/cache origins for untouched namespaces. Environment Values in Namespace Files ------------------------------------- @@ -90,9 +95,9 @@ Example namespace file with delayed environment values: 'port' => fn () => env('DB_PORT', 3306), ]; -``warmNamespaceCache('db')`` resolves those values before writing -``bootstrap/cache/config/db.php`` and before adding scalar leaves to -``bootstrap/cache/config/__flat.php``. +``warmNamespaceCache('db')`` resolves those values before writing the namespace +inside the newly published generation and before adding scalar leaves to that +generation's ``.arraykit-flat.php``. Generated namespace cache file: @@ -100,7 +105,7 @@ Generated namespace cache file: /db.php return [ 'host' => 'localhost', 'port' => 3306, @@ -112,7 +117,7 @@ Generated flat leaf index: /.arraykit-flat.php return [ 'db.host' => 'localhost', 'db.port' => 3306, @@ -153,7 +158,7 @@ Warm the lazy cache during deployment or first boot: namespaceCacheDirectory: $basePath.'/bootstrap/cache/config', ); - // Writes bootstrap/cache/config/db.php and updates __flat.php. + // Publishes a new immutable generation containing db.php + .arraykit-flat.php. $config->warmNamespaceCache('db'); Read from the warmed cache on later requests: @@ -170,7 +175,7 @@ Read from the warmed cache on later requests: namespaceCacheDirectory: $basePath.'/bootstrap/cache/config', ); - // Exact scalar reads can come from __flat.php without loading db.php. + // Exact scalar reads can come from the active .arraykit-flat.php without loading db.php. $host = $config->get('db.host'); // Structural reads load bootstrap/cache/config/db.php when available. @@ -178,17 +183,26 @@ Read from the warmed cache on later requests: Important lazy-cache details: -- ``warmNamespaceCache('db')`` writes ``bootstrap/cache/config/db.php``. -- ``warmNamespaceCache(['db', 'cache'])`` writes one file per namespace. -- ``__flat.php`` stores exact scalar/null leaves such as ``db.host``. -- Original source files are preferred only when namespace cache files do not - exist or are not readable. -- If env values change, rerun ``warmNamespaceCache()`` or flush and rebuild the - namespace cache. -- A namespace is marked loaded only after its source returns a valid array, so - a corrected file can be retried after a failed read. -- A full cache flush removes only ``__flat.php`` and valid namespace cache - files; unrelated files in the configured directory are preserved. +- ``warmNamespaceCache(['db', 'cache'])`` publishes both namespace files and one shared ``.arraykit-flat.php`` inside a new immutable generation. +- ``.arraykit-generation`` is the atomic pointer to the active generation. +- Source-backed warm-up rereads current source/environment values; explicit in-memory runtime overrides remain authoritative. +- ``flushNamespaceCache()`` publishes a generation with the selected cached namespaces removed; unrelated root files are preserved. +- Namespace and generated cache PHP files are trusted deployment-owned inputs, not a sandbox boundary. +- Prefer deployment/bootstrap/admin warm-up, not request-time regeneration. +- Retire old generation directories only after workers that may still reference them have been replaced. + + +5.3 Cache Migration +------------------- + +ArrayKit 5.2 used direct root namespace files plus ``__flat.php`` as an internal +flat index. ArrayKit 5.3 keeps ``__flat`` valid as a caller namespace and moves +internal metadata to ``.arraykit-flat.php`` inside immutable generations. + +After upgrading, rebuild the namespace cache. The old ``__flat.php`` acceleration +artifact is deliberately not interpreted as the 5.3 flat index because it is +ambiguous with the valid ``__flat`` namespace. Ordinary legacy namespace files +remain a compatibility fallback until a 5.3 generation is published. Method Summary -------------- diff --git a/docs/lifecycle.rst b/docs/lifecycle.rst new file mode 100644 index 0000000..f3a1770 --- /dev/null +++ b/docs/lifecycle.rst @@ -0,0 +1,99 @@ +Runtime Ownership and Trust Boundaries +======================================= + +ArrayKit keeps runtime state local to the object that owns it. This matters in +persistent workers, queue consumers, application servers, and long-running +CLI processes where process lifetime is longer than a single request or job. + +Ownership Model +--------------- + +``Config`` + Configuration items, snapshots, hooks, and resolved-value memoization are + instance-owned. Mutations invalidate the affected memo state. Reuse an + instance across requests only when cross-request configuration state is + intentional. + +``LazyFileConfig`` + Loaded namespaces, source/cache origin tracking, and read memoization are + instance-owned. Namespace cache files are deployment-owned generated + artifacts. Build or refresh them during deployment/admin work rather than + normal request handling. + +``LazyCollection`` + Array sources without top-level PHP references are replayed directly from + the captured array and do not allocate a per-entry replay memo. Reference + arrays and one-shot iterators/generators retain the + consumed prefix for repeatable traversal and preserve a terminal source + failure at its stream boundary. That replay state lives as long as the + collection. Use ``fromFactory()`` when a fresh source can be created per + traversal or when retaining a long/unbounded consumed prefix is undesirable. + +DTO graph guards + ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()`` use call-local + depth/node accounting and active-path cycle detection. No graph state is + stored after the call. + +Environment references + ``Environment::ref()`` is process-environment backed and resolves when the + value is materialized. Generated configuration caches intentionally freeze + the resolved value for that cache generation. + +Trusted and Untrusted Inputs +---------------------------- + +- PHP configuration source files and generated PHP cache files are executable + deployment inputs. Keep their directories deployment-owned and non-writable + by untrusted request data. +- Dot-path and guarded array APIs can accept user-controlled structures when + callers set limits appropriate to the request budget. ``getSafe()``, + ``flattenGuarded()``, ``depthGuarded()``, and ``sortRecursiveGuarded()`` are + the bounded entry points. +- Ordinary deep DTO export/hydration is intended for trusted, already-bounded + graphs. Use the guarded variants at external-data boundaries. +- Callbacks, closures, hooks, and factories execute application code. Their + own CPU, I/O, and side effects are application-owned and are not sandboxed by + ArrayKit traversal limits. + +Generated Cache Lifecycle +------------------------- + +Lazy namespace cache publication uses immutable generations plus an active +generation pointer. A rebuild is prepared separately, validated, and only then +activated. Readers therefore keep using the previous valid generation if a +new build fails. The flat leaf index is internal metadata named +``.arraykit-flat.php``; ``__flat`` remains a valid caller namespace. + +On upgrade to 5.3, rebuild generated lazy-config artifacts instead of copying +old ``__flat.php`` metadata forward. Old generation directories are disposable +after all readers pinned to them have finished, including readers in other +workers. If OPcache is used for generated PHP cache files, deployment tooling +remains responsible for its normal invalidation or restart policy. PHP also +retains names of included files for the process lifetime; frequent publication +of unique generations requires a bounded deployment cadence and host-managed +worker replacement. + +Persistent Worker Guidance +-------------------------- + +- Prefer request/job-scoped ``Config`` and ``LazyFileConfig`` objects when + runtime mutation is request-specific. +- Do not keep one-shot ``LazyCollection`` instances globally when an unbounded + stream can be consumed indefinitely; replay state is intentionally retained + for repeatability. +- Prefer ``LazyCollection::fromFactory()`` for renewable database cursors, + event streams, and worker jobs that can create a fresh iterator. +- Treat generated cache warm-up as deployment/admin work, not a request-time + recovery path. +- Avoid static/global application bindings for request cancellation or worker + context. Optional runtime integrations should be passed explicitly to the + collection that uses them. + +Mutation and Concurrency +------------------------ + +ArrayKit objects do not provide cross-thread synchronization for in-memory +mutation. Keep mutable instances within one request/job execution context. +Generated lazy-config publication uses filesystem locking for writers and +immutable generations for readers; the generation pointer is the publication +boundary. diff --git a/docs/migration.rst b/docs/migration.rst index 0c42aa6..205f921 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -3,6 +3,39 @@ Migration and Compatibility This page highlights behavior and API additions that may affect usage patterns. +5.3 Upgrade +----------- + +ArrayKit 5.3 is a correctness and runtime-hardening release. Existing ordinary +array, collection, config, DTO, and lazy-collection entry points remain +available; the bounded DTO and Runwire APIs are additive. + +Before deploying 5.3: + +1. Rebuild ``LazyFileConfig`` generated namespace caches. Do not carry the old + 5.2 ``__flat.php`` acceleration artifact forward as internal metadata. +2. Replace/restart workers according to the application's normal deployment and + OPcache policy after publishing new generated PHP cache files. +3. If nested DTO graphs cross an external-data boundary, prefer + ``hydrateNestedGuarded()`` / ``toArrayDeepGuarded()`` with limits appropriate + to that boundary. +4. Runwire integration is optional. Applications that use it should forward the + exact host-owned ``RuntimeContext`` and optional ``RequestContext`` / + ``CoroutineScope`` to ``LazyCollection::withRunwire()``. ArrayKit does not + create or drive a runtime, event loop, request lifecycle, or coroutine scope. + +Notable 5.3 behavior corrections include strict membership equivalence at +lookup thresholds, wildcard path-presence detection independent of leaf +truthiness, true whole-string SQL-like matching, overflow-safe pagination, +coherent config read-memo invalidation, source-authoritative namespace cache +warming, failure-safe generated-cache publication, and repeatable terminal +errors for one-shot lazy streams. + +Generated-cache metadata now lives in ``.arraykit-flat.php`` inside an immutable +generation selected by ``.arraykit-generation``. The caller namespace +``__flat`` is therefore no longer ambiguous with ArrayKit's internal flat +index. + Recent Additions ---------------- diff --git a/docs/release-5.3.0.rst b/docs/release-5.3.0.rst new file mode 100644 index 0000000..a3a60ce --- /dev/null +++ b/docs/release-5.3.0.rst @@ -0,0 +1,133 @@ +ArrayKit 5.3.0 +============== + +Highlights +---------- + +- Correctness hardening for bounded recursive traversal, strict membership, + wildcard path presence, SQL-like matching, pagination, config memoization, + generated cache publication, and one-shot lazy-stream failures. +- Immutable ``LazyFileConfig`` cache generations with atomic activation and an + internal ``.arraykit-flat.php`` index that no longer reserves ``__flat``. +- Additive bounded DTO graph entry points: + ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()``. +- Lower replay overhead for array-backed ``LazyCollection`` sources while + retaining repeatability for one-shot iterators. +- Optional Runwire 2.1.1 integration through + ``LazyCollection::withRunwire()``. The host passes its existing + ``RuntimeContext`` and optional request/scope objects; ArrayKit does not own + Runwire lifecycle management. +- Expanded regression, boundary, benchmark, lifecycle, and optional-integration + coverage under the PHPForge quality gates. + +Correctness Changes +------------------- + +Strict membership +~~~~~~~~~~~~~~~~~ + +Threshold-optimized membership now preserves native PHP strict-comparison +semantics rather than relying on fingerprints for values that cannot safely be +represented by the fast lookup. + +Wildcard presence +~~~~~~~~~~~~~~~~~ + +``DotNotation::matches()`` now answers path existence independently from the +resolved leaf value. Existing ``null``, ``false``, ``0``, empty strings, and +empty arrays are present values. + +SQL-like matching +~~~~~~~~~~~~~~~~~ + +``ArrayMulti::whereLike()`` is anchored to the true beginning/end of the +subject, supports multiline wildcard consumption, and surfaces PCRE execution +failures instead of silently treating them as no match. + +Pagination +~~~~~~~~~~ + +``ArraySingle::paginate()`` validates reachability before calculating an +offset, avoiding integer overflow for valid but extremely large page numbers. + +Configuration and Cache Lifecycle +--------------------------------- + +Resolved read memoization is invalidated when configuration/cache sources +change. Generated namespace warm-up reads the authoritative source unless the +caller explicitly supplied or mutated that namespace in memory. +Partial merges retain the source/cache origin of untouched namespaces. + +Each reader pins one cache generation for exact and structural lookups. +``namespaceCache()`` explicitly refreshes that selection while preserving +intentional runtime overrides. Writers independently use the latest published +generation when copying namespaces that are not being rebuilt. + +Namespace caches are built as immutable generations and activated only after a +successful build. A failed build leaves the previous valid generation active. +Unsupported/cyclic values are rejected before replacing a valid compiled +artifact. + +Upgrade deployments should rebuild 5.2 namespace caches. The former internal +``__flat.php`` metadata must not be reused as the 5.3 flat index; +``.arraykit-flat.php`` is used inside the active generation instead. Retire old +generation directories only after workers that may still reference them have +been replaced. Apply the application's normal OPcache invalidation/restart +policy to generated PHP artifacts. + +DTO Graph Guards +---------------- + +Use ``hydrateNestedGuarded()`` and ``toArrayDeepGuarded()`` for graphs that can +be large, recursive, or influenced by external input. They enforce one shared +depth/node budget per call and reject active-path object/array cycles while +allowing shared acyclic objects. Existing ``hydrateNested()`` and +``toArrayDeep()`` remain available for trusted, already-bounded graphs. +Guarded export walks and produces its output in one bounded pass. Custom +``toArray()`` / ``toArrayDeep()`` implementations are rejected before invocation +by the guarded API; ordinary export continues to support them. Application +property getters and callbacks remain trusted application code. + +Optional Runwire Integration +---------------------------- + +Runwire is not a production dependency. ArrayKit 5.3 tests the optional +integration against ``infocyph/runwire`` 2.1.1. + +``LazyCollection::withRunwire()`` accepts the host's exact ``RuntimeContext`` +and optional ``RequestContext`` / ``CoroutineScope``. Cancellation is checked +at the traversal boundary and at the configured item cadence. Cooperative +``yieldNow()`` calls are made only when an active scope is passed and the +runtime advertises Runwire coroutine capability. +Completed requests and closed scopes are rejected at traversal checkpoints, +including after cooperative resumption and when yielding is unavailable. + +Bindings propagate through derived lazy operations and can be explicitly +rebound for a new request. ArrayKit never starts/stops a runtime or event loop, +completes a request, closes a scope, or stores request bindings globally. + +Compatibility +------------- + +The release remains PHP 8.4+ and keeps Runwire optional. Existing synchronous +LazyCollection use continues to work without Runwire installed. Public API +additions are additive; corrected edge cases listed above may change results +where 5.2 behavior was demonstrably inconsistent with the documented/native +contract. + +See :doc:`migration`, :doc:`lazy-config`, :doc:`collection`, +:doc:`traits-and-helpers`, and :doc:`lifecycle` for operational details. + +Final Review Corrections +------------------------ + +- Missing safe dot lookups consume the shared node budget. Throwing multi-key + lookup rejects unfinished work while accepting an exactly completed budget. +- Non-throwing guarded sort preserves ancestor ordering after traversal is + cut short, avoiding recursive comparisons of unvisited children. +- Runwire lifecycle, partial config origin tracking, generation consistency, + and guarded DTO output enforce the boundaries described above. +- Facade ``forget()`` accepts native named reference arguments: + ``target:`` for the dot module and ``array:`` for the helper module. +- Reference-bearing lazy arrays preserve memoized consumed scalar values; + unconsumed entries remain lazy. diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index f7a4bd5..e75b299 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -149,6 +149,13 @@ Facade ModuleProxy public function __construct(private string $targetClass) public function __call(string $method, array $arguments): mixed + public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + public function fill(array &$array, array|string $keys, mixed $value = null): void + public function forget(?array &$array = null, array|string|int|null $keys = null, ?array &$target = null): void + public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + public function move(array &$array, string $from, string $to, bool $overwrite = true): bool + public function offsetSet(array &$array, string $key, mixed $value): void + public function offsetUnset(array &$array, string $key): void BaseArrayHelper --------------------------------------- @@ -582,8 +589,10 @@ DTOTrait 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 hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static public function toArray(): array public function toArrayDeep(): array + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static HookTrait @@ -628,8 +637,10 @@ methods listed in the ``DTOTrait`` section. 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 hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static public function toArray(): array public function toArrayDeep(): array + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static LazyCollection @@ -647,4 +658,5 @@ LazyCollection public function chunkLazy(int $size, bool $preserveKeys = false): self public function take(int $limit): self public function takeUntil(callable $callback): self + public function withRunwire(RuntimeContext $runtime, ?RequestContext $request = null, ?CoroutineScope $scope = null, int $checkpointEvery = 256): self public function all(): array diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index 5972919..59157d7 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -19,8 +19,10 @@ Main methods: - ``fromArray(array $values): static`` (hydrate current instance) - ``hydrate(array $values, array $mapping = [], bool $coerce = false): static`` - ``hydrateNested(array $values, array $mapping = [], bool $coerce = false): static`` +- ``hydrateNestedGuarded(array $values, array $mapping = [], bool $coerce = false, int $maxDepth = 64, int $maxNodes = 100000): static`` - ``toArray(): array`` (export public properties) - ``toArrayDeep(): array`` (recursive export) +- ``toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array`` - ``replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static`` Basic DTO Flow @@ -58,6 +60,41 @@ Incremental Hydration $user->fromArray(['name' => 'Bob']); $user->fromArray(['age' => 32]); +Bounded DTO Graphs +~~~~~~~~~~~~~~~~~~ + +Use the guarded entry points when nested DTO or array graphs can be large, +recursive, or influenced by external input. Hydration validates the input graph +before mutation. Deep export builds the actual output in one bounded traversal +of standard ``DTOTrait`` public properties. ``maxDepth`` and ``maxNodes`` are shared +across the whole call; both must be positive. Cyclic array references, cyclic +public object references, or a limit breach raise ``RuntimeException``. + +Guarded export rejects custom ``toArray()`` / ``toArrayDeep()`` implementations +with ``InvalidArgumentException`` before invoking them, because their arbitrary +work and generated output cannot be bounded by the public-property budget. +Property getters are read once during export; their own execution must be +trusted and bounded by the host. Custom serializers remain supported by the +ordinary ``toArrayDeep()`` API. + +Shared acyclic objects are valid and may appear in more than one branch. The +ordinary ``hydrateNested()`` and ``toArrayDeep()`` contracts are unchanged and +remain the lower-overhead choice for trusted, already-bounded graphs. + +.. code-block:: php + + hydrateNestedGuarded( + $payload, + maxDepth: 32, + maxNodes: 10_000, + ); + + $safe = $user->toArrayDeepGuarded( + maxDepth: 32, + maxNodes: 10_000, + ); + Unknown Keys ~~~~~~~~~~~~ diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 2eeb14c..6e04a67 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -376,27 +376,22 @@ public static function values(array $array): array return array_values($array); } - private static function assertTraversalWithinLimits( - int $currentDepth, + /** + * @phpstan-impure + */ + private static function consumeTraversalNode( int &$visitedNodes, - int $maxDepth, int $maxNodes, bool $throwOnTooDeep, ): bool { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); - - return false; - } - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { - self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max node count.'); - - return false; + if ($maxNodes <= 0 || $visitedNodes <= $maxNodes) { + return true; } - return true; + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max node count.'); + + return false; } /** @@ -429,30 +424,110 @@ private static function flattenIntoGuarded( int $maxNodes, bool $throwOnTooDeep, ): array { - if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { return []; } $result = []; foreach ($array as $item) { - if (!is_array($item)) { - $result[] = $item; - - continue; + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; } - $values = ($depth === 1) - ? array_values($item) - : self::flattenIntoGuarded($item, $depth - 1, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep); + if (is_array($item)) { + self::flattenNestedGuarded( + $item, + $depth, + $currentDepth, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $result, + ); + } else { + $result[] = $item; + } - foreach ($values as $value) { - $result[] = $value; + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { + break; } } return $result; } + /** + * @param array $item + * @param array $result + */ + private static function flattenNestedGuarded( + array $item, + float|int $depth, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + array &$result, + ): void { + if ($depth === 1) { + self::flattenOneLevelGuarded( + $item, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $result, + ); + + return; + } + + foreach (self::flattenIntoGuarded( + $item, + $depth - 1, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ) as $value) { + $result[] = $value; + } + } + + /** + * @param array $item + * @param array $result + */ + private static function flattenOneLevelGuarded( + array $item, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + array &$result, + ): void { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { + return; + } + + foreach ($item as $value) { + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + return; + } + + $result[] = $value; + + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { + return; + } + } + } + private static function handleTraversalLimit(bool $throwOnTooDeep, string $message): void { if ($throwOnTooDeep) { @@ -487,22 +562,54 @@ private static function measureDepthGuarded( int $maxNodes, bool $throwOnTooDeep, ): int { - if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::traversalDepthAllowed($currentDepth, $maxDepth, $throwOnTooDeep)) { return 0; } $resolvedMaxDepth = 1; foreach ($array as $value) { - if (!is_array($value) || $value === []) { - continue; + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; } - $resolvedMaxDepth = max( - $resolvedMaxDepth, - self::measureDepthGuarded($value, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep) + 1, - ); + if (is_array($value) && $value !== []) { + $resolvedMaxDepth = max( + $resolvedMaxDepth, + self::measureDepthGuarded( + $value, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ) + 1, + ); + } + + if (!$throwOnTooDeep && self::traversalBudgetExhausted($visitedNodes, $maxNodes)) { + break; + } } return $resolvedMaxDepth; } + + private static function traversalBudgetExhausted(int $visitedNodes, int $maxNodes): bool + { + return $maxNodes > 0 && $visitedNodes >= $maxNodes; + } + + private static function traversalDepthAllowed( + int $currentDepth, + int $maxDepth, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth <= 0 || $currentDepth <= $maxDepth) { + return true; + } + + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); + + return false; + } } diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index c10290e..c81bc55 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -605,6 +605,16 @@ public static function paginate(array $array, int $page, int $perPage): array throw new InvalidArgumentException('Per-page value must be greater than or equal to 1.'); } + $count = count($array); + if ($count === 0) { + return []; + } + + $lastPage = intdiv($count - 1, $perPage) + 1; + if ($page > $lastPage) { + return []; + } + return array_slice( $array, ($page - 1) * $perPage, diff --git a/src/Array/ArrayValueSetOps.php b/src/Array/ArrayValueSetOps.php index d4d672f..e8c6bfd 100644 --- a/src/Array/ArrayValueSetOps.php +++ b/src/Array/ArrayValueSetOps.php @@ -23,27 +23,12 @@ final class ArrayValueSetOps */ public static function containsAll(array $array, array $needles, bool $strict): bool { - if (!$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)); - } - - return array_all( + return self::containsByMembership( + $array, $needles, - static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + $strict, + self::CONTAINS_ALL_LOOKUP_MIN_NEEDLES, + requireAll: true, ); } @@ -53,27 +38,12 @@ public static function containsAll(array $array, array $needles, bool $strict): */ public static function containsAny(array $array, array $needles, bool $strict): bool { - if (!$strict) { - return array_any($needles, 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)); - } - - return array_any( + return self::containsByMembership( + $array, $needles, - static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + $strict, + self::CONTAINS_ANY_LOOKUP_MIN_NEEDLES, + requireAll: false, ); } @@ -158,6 +128,24 @@ public static function same(array $left, array $right, bool $strict): bool return $leftCounts === $rightCounts; } + /** + * @param array $values + * @return array|null + */ + public static function strictLookup(array $values): ?array + { + return self::buildStrictLookup($values); + } + + /** + * @param array $lookup + */ + public static function strictLookupContains(array $lookup, mixed $value): bool + { + return self::isStrictHashable($value) + && isset($lookup[self::fingerprintStrict($value)]); + } + /** * Track strict membership with canonical fingerprints and a safe scan fallback. * @@ -246,6 +234,50 @@ private static function buildStrictLookup(array $array): ?array return $lookup; } + /** + * @param array $array + * @param array $needles + */ + private static function containsByMembership( + array $array, + array $needles, + bool $strict, + int $lookupThreshold, + bool $requireAll, + ): bool { + $matcher = $requireAll ? 'array_all' : 'array_any'; + + if (!$strict || count($needles) < $lookupThreshold) { + return $matcher( + $needles, + static fn(mixed $needle): bool => in_array($needle, $array, $strict), + ); + } + + $firstKey = array_key_first($needles); + if ($firstKey === null) { + return $requireAll; + } + + $firstMatches = in_array($needles[$firstKey], $array, true); + if ($firstMatches !== $requireAll) { + return !$requireAll; + } + + $lookup = self::buildStrictLookup($array); + if ($lookup === null) { + return $matcher( + $needles, + static fn(mixed $needle): bool => in_array($needle, $array, true), + ); + } + + return $matcher( + $needles, + static fn(mixed $needle): bool => self::strictLookupContains($lookup, $needle), + ); + } + /** * @param array $array * @return array diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 7e5c042..b876f4c 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -485,6 +485,7 @@ public static function sortRecursiveGuarded( bool $throwOnTooDeep = false, ): array { $visitedNodes = 0; + $complete = true; return self::sortRecursiveWithGuards( $array, @@ -495,6 +496,7 @@ public static function sortRecursiveGuarded( $maxDepth, $maxNodes, $throwOnTooDeep, + $complete, ); } @@ -658,7 +660,7 @@ public static function whereIn(array $array, string $key, array $values, bool $s public static function whereLike(array $array, string $key, string $pattern, bool $caseSensitive = false): array { $quoted = preg_quote($pattern, '/'); - $regex = '/^' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '$/' . ($caseSensitive ? '' : 'i'); + $regex = '/\\A' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '\\z/s' . ($caseSensitive ? '' : 'i'); $results = []; foreach ($array as $index => $row) { @@ -671,8 +673,12 @@ public static function whereLike(array $array, string $key, string $pattern, boo continue; } - $text = (string) $value; - if (preg_match($regex, $text) === 1) { + $matched = preg_match($regex, (string) $value); + if ($matched === false) { + throw new \RuntimeException('SQL-like pattern matching failed: ' . preg_last_error_msg()); + } + + if ($matched === 1) { $results[$index] = $row; } } @@ -776,16 +782,7 @@ private static function buildInLookup(array $values, bool $strict): ?array } if ($strict) { - $lookup = []; - foreach ($values as $value) { - if (self::containsNonReflexiveStrictValue($value)) { - return null; - } - - $lookup[ArraySingleOps::fingerprint($value, true)] = true; - } - - return $lookup; + return ArrayValueSetOps::strictLookup($values); } $lookup = ['type:non-numeric-string' => true]; @@ -800,33 +797,6 @@ private static function buildInLookup(array $values, bool $strict): ?array return $lookup; } - private static function canTraverse( - int $currentDepth, - int &$visitedNodes, - int $maxDepth, - int $maxNodes, - bool $throwOnTooDeep, - ): bool { - if ($maxDepth > 0 && $currentDepth > $maxDepth) { - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort exceeded max depth.'); - } - - return false; - } - - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { - if ($throwOnTooDeep) { - throw new \RuntimeException('Recursive sort exceeded max node count.'); - } - - return false; - } - - return true; - } - /** * @param array $array * @return array @@ -958,19 +928,6 @@ private static function compareSortValues(mixed $left, mixed $right, int $option }; } - private static function containsNonReflexiveStrictValue(mixed $value): bool - { - if (is_float($value)) { - return is_nan($value); - } - - if (!is_array($value)) { - return false; - } - - return array_any($value, self::containsNonReflexiveStrictValue(...)); - } - private static function extractComparableValue(mixed $row, string|callable $keyOrCallback, int|string $key): float|int|null { if (!is_string($keyOrCallback)) { @@ -1156,6 +1113,39 @@ private static function requireArrayKey(mixed $value, string $operation): int|st ); } + /** + * @param array $array + */ + private static function reserveSortNodes( + array $array, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max depth.'); + } + + return false; + } + + $requiredNodes = count($array); + if ($maxNodes > 0 && $requiredNodes > ($maxNodes - $visitedNodes)) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max node count.'); + } + + return false; + } + + $visitedNodes += $requiredNodes; + + return true; + } + private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed { if (!is_string($keyOrCallback)) { @@ -1181,7 +1171,7 @@ private static function resolveSortByManyValue(mixed $row, string|callable $by, private static function rowLookupContains(array $lookup, array $values, mixed $candidate, bool $strict): bool { if ($strict) { - return isset($lookup[ArraySingleOps::fingerprint($candidate, true)]); + return ArrayValueSetOps::strictLookupContains($lookup, $candidate); } if (is_string($candidate) && !is_numeric($candidate)) { @@ -1250,23 +1240,41 @@ private static function sortRecursiveWithGuards( int $maxDepth, int $maxNodes, bool $throwOnTooDeep, + bool &$complete, ): array { - if (!self::canTraverse($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + if (!self::reserveSortNodes( + $array, + $currentDepth, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + )) { + $complete = false; + return $array; } foreach ($array as &$value) { - if (is_array($value)) { - $value = self::sortRecursiveWithGuards( - $value, - $options, - $descending, - $currentDepth + 1, - $visitedNodes, - $maxDepth, - $maxNodes, - $throwOnTooDeep, - ); + if (!is_array($value)) { + continue; + } + + $value = self::sortRecursiveWithGuards( + $value, + $options, + $descending, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $complete, + ); + if (!$complete) { + unset($value); + + return $array; } } unset($value); diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index 0048e86..1f6b536 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -5,6 +5,7 @@ namespace Infocyph\ArrayKit\Array\Concerns; use Infocyph\ArrayKit\Array\ArraySingle; +use Infocyph\ArrayKit\Array\DotNotationPathOps; use InvalidArgumentException; /** @internal */ @@ -160,9 +161,15 @@ public static function getSafe( return []; } + $visitedNodes = 0; + if (is_array($keys)) { $results = []; foreach ($keys as $k) { + if (!self::canResolveSafeKey($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + $resolvedKey = (string) $k; $results[$resolvedKey] = self::getValueSafe( $array, @@ -171,13 +178,22 @@ public static function getSafe( $maxDepth, $maxNodes, $throwOnTooDeep, + $visitedNodes, ); } return $results; } - return self::getValueSafe($array, $keys, $default, $maxDepth, $maxNodes, $throwOnTooDeep); + return self::getValueSafe( + $array, + $keys, + $default, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $visitedNodes, + ); } /** @@ -264,10 +280,7 @@ public static function matches(array $array, string $path): bool return self::has($array, $path); } - $missing = self::missing(); - $resolved = self::get($array, $path, $missing); - - return self::containsResolvedValue($resolved, $missing); + return DotNotationPathOps::matchesPath($array, self::splitPath($path)); } /** @@ -412,17 +425,4 @@ public static function tap(array $array, callable $callback): array return $array; } - - private static function containsResolvedValue(mixed $value, object $missing): bool - { - if ($value === $missing) { - return false; - } - - if (!is_array($value)) { - return true; - } - - return array_any($value, fn($item) => self::containsResolvedValue($item, $missing)); - } } diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index b2ff86b..03d3081 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -10,6 +10,19 @@ class DotNotation { use DotNotationPublicApiTrait; + private static function canResolveSafeKey(int $visitedNodes, int $maxNodes, bool $throwOnTooDeep): bool + { + if ($maxNodes <= 0 || $visitedNodes < $maxNodes) { + return true; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return false; + } + private static function escapePathSegment(string $segment): string { return DotNotationPathOps::escapePathSegment($segment); @@ -101,8 +114,43 @@ private static function getValueSafe( int $maxDepth, int $maxNodes, bool $throwOnTooDeep, + int &$visitedNodes, ): mixed { - return self::resolveValue($target, $key, $default, $maxDepth, $maxNodes, $throwOnTooDeep); + if (self::isDirectKey($key)) { + $visitedNodes++; + if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return self::value($default); + } + + return is_array($target) && ArraySingle::exists($target, $key) + ? $target[$key] + : self::value($default); + } + + $keyPath = (string) $key; + if (!str_contains($keyPath, '.') && !str_contains($keyPath, '\\')) { + return self::value($default); + } + + $missing = self::missing(); + $resolved = DotNotationPathOps::traverseGet( + $target, + self::splitPath($keyPath), + $default, + $missing, + static fn(mixed $value): mixed => self::value($value), + $maxDepth, + $maxNodes, + $throwOnTooDeep, + 1, + $visitedNodes, + ); + + return $resolved === $missing ? self::value($default) : $resolved; } /** diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index 45a49db..adce193 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -41,6 +41,38 @@ public static function escapePathSegment(string $segment): string ); } + /** + * Determine whether a path resolves to at least one existing value. + * + * @param array $segments + */ + public static function matchesPath(mixed $target, array $segments, int $position = 0): bool + { + if ($position >= count($segments)) { + return true; + } + + $segment = $segments[$position]; + if ($segment === '*') { + $target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target; + if (!is_array($target)) { + return false; + } + + return array_any( + $target, + static fn(mixed $item): bool => self::matchesPath($item, $segments, $position + 1), + ); + } + + $missing = new \stdClass(); + $normalized = self::normalizeSegment($segment, $target); + $next = self::accessSegment($target, $normalized, $missing); + + return $next !== $missing + && self::matchesPath($next, $segments, $position + 1); + } + /** * Normalize a dot-notation segment by replacing escaped values and resolving * special values such as '{first}' and '{last}'. @@ -157,13 +189,11 @@ public static function traverseGet( ): mixed { $segmentCount = count($segments); for ($index = $position; $index < $segmentCount; $index++) { - $segment = $segments[$index]; - - $visitedNodes++; - if ($maxNodes > 0 && $visitedNodes > $maxNodes) { - return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max node count.'); + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + return $missing; } + $segment = $segments[$index]; if ($segment === '*') { return self::traverseWildcard( $target, @@ -174,7 +204,7 @@ public static function traverseGet( $maxDepth, $maxNodes, $throwOnTooDeep, - $currentDepth, + $currentDepth + 1, $visitedNodes, $index + 1, ); @@ -214,6 +244,26 @@ public static function unescapeSegment(string $segment): string ); } + /** + * @phpstan-impure + */ + private static function consumeTraversalNode( + int &$visitedNodes, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + $visitedNodes++; + if ($maxNodes <= 0 || $visitedNodes <= $maxNodes) { + return true; + } + + if ($throwOnTooDeep) { + throw new \RuntimeException('Dot path traversal exceeded max node count.'); + } + + return false; + } + private static function handleTraversalLimit(object $missing, bool $throwOnTooDeep, string $message): mixed { if ($throwOnTooDeep) { @@ -280,6 +330,50 @@ private static function resolveLast(mixed $target): string|int|null return '{last}'; } + /** + * @param array $segments + * @param callable(mixed): mixed $defaultResolver + */ + private static function resolveWildcardItem( + mixed $item, + array $segments, + mixed $default, + object $missing, + callable $defaultResolver, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + int $currentDepth, + int &$visitedNodes, + int $position, + ): mixed { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + + return $defaultResolver($default); + } + + if ($position >= count($segments)) { + return $item; + } + + $resolved = self::traverseGet( + $item, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth, + $visitedNodes, + $position, + ); + + return $resolved === $missing ? $defaultResolver($default) : $resolved; + } + /** * Traverse a target array/object using dot-notation with wildcard support. * @@ -307,7 +401,11 @@ private static function traverseWildcard( $result = []; foreach ($target as $item) { - $resolved = self::traverseGet( + if (!self::consumeTraversalNode($visitedNodes, $maxNodes, $throwOnTooDeep)) { + break; + } + + $result[] = self::resolveWildcardItem( $item, $segments, $default, @@ -320,11 +418,14 @@ private static function traverseWildcard( $visitedNodes, $position, ); - $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; + + if (!$throwOnTooDeep && $maxNodes > 0 && $visitedNodes >= $maxNodes) { + break; + } } if (self::hasWildcardFrom($segments, $position)) { - $result = ArrayMulti::collapse($result); + return ArrayMulti::collapse($result); } return $result; diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 54cbf8e..b3b1b25 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -5,6 +5,9 @@ namespace Infocyph\ArrayKit\Collection; use Generator; +use Infocyph\Runwire\Coroutine\CoroutineScope; +use Infocyph\Runwire\RequestContext; +use Infocyph\Runwire\RuntimeContext; use IteratorAggregate; use Traversable; @@ -17,9 +20,13 @@ final readonly class LazyCollection implements IteratorAggregate { /** - * @param \Closure(): iterable $factory + * @param \Closure(?RunwireLazyBinding): iterable $factory */ - private function __construct(private \Closure $factory) {} + private function __construct( + private \Closure $factory, + private ?RunwireLazyBinding $runwire = null, + private bool $factoryChecksRunwire = false, + ) {} /** * @template TFromKey of array-key @@ -30,6 +37,14 @@ private function __construct(private \Closure $factory) {} */ public static function from(iterable $source): self { + if (is_array($source) && !self::hasReferencedEntries($source)) { + return new self(static function (?RunwireLazyBinding $binding) use ($source): array { + unset($binding); + + return $source; + }); + } + return new self(self::replayableFactory($source)); } @@ -45,7 +60,13 @@ public static function from(iterable $source): self */ public static function fromFactory(\Closure $factory): self { - return new self($factory); + return new self( + static function (?RunwireLazyBinding $binding) use ($factory): iterable { + unset($binding); + + return $factory(); + }, + ); } /** @@ -85,25 +106,29 @@ public function chunkLazy(int $size, bool $preserveKeys = false): self throw new \InvalidArgumentException('Chunk size must be at least 1.'); } - return new self(function () use ($size, $preserveKeys): Generator { - $chunk = []; - foreach ($this->cursor() as $key => $value) { - if ($preserveKeys) { - $chunk[$key] = $value; - } else { - $chunk[] = $value; + return new self( + function (?RunwireLazyBinding $binding) use ($size, $preserveKeys): Generator { + $chunk = []; + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($preserveKeys) { + $chunk[$key] = $value; + } else { + $chunk[] = $value; + } + + if (count($chunk) === $size) { + yield $chunk; + $chunk = []; + } } - if (count($chunk) === $size) { + if ($chunk !== []) { yield $chunk; - $chunk = []; } - } - - if ($chunk !== []) { - yield $chunk; - } - }); + }, + $this->runwire, + true, + ); } /** @@ -111,10 +136,7 @@ public function chunkLazy(int $size, bool $preserveKeys = false): self */ public function cursor(): Generator { - $factory = $this->factory; - foreach ($factory() as $key => $value) { - yield $key => $value; - } + yield from $this->cursorWithBinding($this->runwire); } /** @@ -123,13 +145,17 @@ public function cursor(): Generator */ public function filterLazy(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - if ($callback($value, $key)) { - yield $key => $value; + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($callback($value, $key)) { + yield $key => $value; + } } - } - }); + }, + $this->runwire, + true, + ); } /** @@ -148,11 +174,15 @@ public function getIterator(): Traversable */ public function mapLazy(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - yield $key => $callback($value, $key); - } - }); + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + yield $key => $callback($value, $key); + } + }, + $this->runwire, + true, + ); } /** @@ -165,20 +195,32 @@ public function take(int $limit): self } if ($limit === 0) { - return self::from([]); + return new self( + static function (?RunwireLazyBinding $binding): array { + unset($binding); + + return []; + }, + $this->runwire, + true, + ); } - return new self(function () use ($limit): Generator { - $count = 0; - foreach ($this->cursor() as $key => $value) { - yield $key => $value; - $count++; + return new self( + function (?RunwireLazyBinding $binding) use ($limit): Generator { + $count = 0; + foreach ($this->cursorWithBinding($binding) as $key => $value) { + yield $key => $value; + $count++; - if ($count >= $limit) { - return; + if ($count >= $limit) { + return; + } } - } - }); + }, + $this->runwire, + true, + ); } /** @@ -187,15 +229,37 @@ public function take(int $limit): self */ public function takeUntil(callable $callback): self { - return new self(function () use ($callback): Generator { - foreach ($this->cursor() as $key => $value) { - if ($callback($value, $key)) { - break; + return new self( + function (?RunwireLazyBinding $binding) use ($callback): Generator { + foreach ($this->cursorWithBinding($binding) as $key => $value) { + if ($callback($value, $key)) { + break; + } + + yield $key => $value; } + }, + $this->runwire, + true, + ); + } - yield $key => $value; - } - }); + /** + * Bind explicit Runwire runtime/request/scope instances to this collection. + * + * @return self + */ + public function withRunwire( + RuntimeContext $runtime, + ?RequestContext $request = null, + ?CoroutineScope $scope = null, + int $checkpointEvery = 256, + ): self { + return new self( + $this->factory, + new RunwireLazyBinding($runtime, $request, $scope, $checkpointEvery), + $this->factoryChecksRunwire, + ); } /** @@ -209,6 +273,12 @@ private static function fromTraversable(Traversable $source): self return new self(self::replayableFactory($source)); } + /** @param array $source */ + private static function hasReferencedEntries(array $source): bool + { + return array_any(array_keys($source), fn($key) => \ReflectionReference::fromArrayElement($source, $key) !== null); + } + /** * Adapt any iterable into a repeatable lazy source without eagerly * materializing it. Values already consumed from a one-shot iterator are @@ -218,7 +288,7 @@ private static function fromTraversable(Traversable $source): self * @template TSourceValue * * @param iterable $source - * @return \Closure(): iterable + * @return \Closure(?RunwireLazyBinding): iterable */ private static function replayableFactory(iterable $source): \Closure { @@ -227,8 +297,20 @@ private static function replayableFactory(iterable $source): \Closure $sourceCursor = null; $sourceAdvancePending = false; $exhausted = false; + $state = new class { + public ?\Throwable $failure = null; + }; + + return static function (?RunwireLazyBinding $binding) use ( + $source, + &$cache, + &$sourceCursor, + &$sourceAdvancePending, + &$exhausted, + $state, + ): Generator { + unset($binding); - return static function () use ($source, &$cache, &$sourceCursor, &$sourceAdvancePending, &$exhausted): Generator { $position = 0; while (true) { @@ -240,6 +322,10 @@ private static function replayableFactory(iterable $source): \Closure continue; } + if ($state->failure !== null) { + throw $state->failure; + } + if ($exhausted) { return; } @@ -248,18 +334,28 @@ private static function replayableFactory(iterable $source): \Closure yield from $source; })(); - if ($sourceAdvancePending) { - $sourceCursor->next(); - $sourceAdvancePending = false; - } + try { + if ($sourceAdvancePending) { + $sourceCursor->next(); + $sourceAdvancePending = false; + } - if (!$sourceCursor->valid()) { - $exhausted = true; + if (!$sourceCursor->valid()) { + $exhausted = true; + $sourceCursor = null; - return; + return; + } + + $entry = [$sourceCursor->key(), $sourceCursor->current()]; + } catch (\Throwable $error) { + $state->failure = $error; + $sourceCursor = null; + $sourceAdvancePending = false; + + throw $error; } - $entry = [$sourceCursor->key(), $sourceCursor->current()]; $cache[] = $entry; $sourceAdvancePending = true; @@ -268,4 +364,48 @@ private static function replayableFactory(iterable $source): \Closure } }; } + + /** + * @return Generator + */ + private function cursorWithBinding(?RunwireLazyBinding $binding): Generator + { + if ($this->factoryChecksRunwire) { + $factory = $this->factory; + yield from $factory($binding); + + return; + } + + yield from $this->sourceCursorWithBinding($binding); + } + + /** + * @return Generator + */ + private function sourceCursorWithBinding(?RunwireLazyBinding $binding): Generator + { + $binding?->checkpoint(); + + $factory = $this->factory; + $iterable = $factory($binding); + $iterator = (static function () use ($iterable): Generator { + yield from $iterable; + })(); + + $processed = 0; + while ($iterator->valid()) { + yield $iterator->key() => $iterator->current(); + $processed++; + + if ( + $binding !== null + && ($processed % $binding->checkpointEvery) === 0 + ) { + $binding->checkpoint(); + } + + $iterator->next(); + } + } } diff --git a/src/Collection/RunwireLazyBinding.php b/src/Collection/RunwireLazyBinding.php new file mode 100644 index 0000000..19219ed --- /dev/null +++ b/src/Collection/RunwireLazyBinding.php @@ -0,0 +1,84 @@ + self::MAX_CHECKPOINT_INTERVAL) { + throw new InvalidArgumentException( + 'Runwire checkpoint interval must be between 1 and 1000000 items.', + ); + } + + if ($request !== null) { + if ($request->completed()) { + throw new LogicException('Completed Runwire request context cannot be bound to a lazy collection.'); + } + + if ($request->runtime() !== $runtime) { + throw new LogicException('Runwire request context belongs to a different runtime context.'); + } + } + + $this->requestCancellation = $request?->cancellation; + $this->scopeCancellation = $scope?->cancellation(); + $this->scopeCheckKey = $scope === null ? null : new TaskLocal(); + $this->yieldEnabled = $scope !== null + && $runtime->supports(RuntimeCapability::RUNWIRE_COROUTINES); + } + + public function checkpoint(): void + { + $this->assertActive(); + $this->requestCancellation?->throwIfCancelled(); + $this->scopeCancellation?->throwIfCancelled(); + + if ($this->yieldEnabled) { + $this->scope?->yieldNow(); + } + + $this->assertActive(); + $this->requestCancellation?->throwIfCancelled(); + $this->scopeCancellation?->throwIfCancelled(); + } + + private function assertActive(): void + { + if ($this->request?->completed()) { + throw new LogicException('Completed Runwire request context cannot be traversed.'); + } + + if ($this->scope !== null && $this->scopeCheckKey !== null) { + // Runwire 2.1.1 guards this read without yielding or changing task-local state. + $this->scope->hasLocal($this->scopeCheckKey); + } + } +} diff --git a/src/Config/Concerns/BaseConfigTrait.php b/src/Config/Concerns/BaseConfigTrait.php index bf0eeda..3a7f8a7 100644 --- a/src/Config/Concerns/BaseConfigTrait.php +++ b/src/Config/Concerns/BaseConfigTrait.php @@ -58,14 +58,7 @@ public function append(string $key, mixed $value): bool { $this->assertWritable(); - $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 = $this->arrayValueForMutation($key); $array[] = $value; return $this->set($key, $array); @@ -427,14 +420,7 @@ public function prepend(string $key, mixed $value): bool { $this->assertWritable(); - $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 = $this->arrayValueForMutation($key); array_unshift($array, $value); return $this->set($key, $array); @@ -580,23 +566,43 @@ protected function hasResolvedValue(int|string $key): bool return $this->resolveRawValue($key) !== $this->missingValueMarker(); } - protected function materializeCacheValue(mixed $value): mixed + protected function isReadCacheSafePath(string $path): bool { - if ($value instanceof EnvReference) { - return $this->materializeCacheValue($value->resolve()); + if ( + !str_contains($path, '.') + || str_contains($path, '\\') + || str_contains($path, '*') + || str_contains($path, '{') + ) { + return false; } - if ($value instanceof \Closure) { - return $this->materializeCacheValue($value()); - } + $cursor = $this->items; + foreach (explode('.', $path) as $segment) { + if (!is_array($cursor)) { + return false; + } - if (is_array($value)) { - foreach ($value as $key => $entry) { - $value[$key] = $this->materializeCacheValue($entry); + if (!array_key_exists($segment, $cursor)) { + return true; } + + if (\ReflectionReference::fromArrayElement($cursor, $segment) !== null) { + return false; + } + + $cursor = $cursor[$segment]; } - return $value; + return $cursor === null || is_scalar($cursor); + } + + protected function materializeCacheValue(mixed $value): mixed + { + $activeReferences = []; + $activeObjects = []; + + return $this->materializeCacheValueRecursive($value, $activeReferences, $activeObjects); } protected function missingValueMarker(): object @@ -626,7 +632,7 @@ protected function resolveRawValue(int|string $key): mixed : $this->missingValueMarker(); } - if (!$this->readCacheEnabled) { + if (!$this->readCacheEnabled || !$this->isReadCacheSafePath($key)) { return DotNotation::get($this->items, $key, $this->missingValueMarker()); } @@ -648,13 +654,22 @@ protected function valueCacheKey(int|string $key): string protected function writeCacheFile(string $path, string $contents): bool { + try { + if (token_get_all($contents, TOKEN_PARSE) === []) { + throw new UnexpectedValueException('Generated configuration cache is empty.'); + } + } catch (\ParseError $error) { + throw new UnexpectedValueException('Generated configuration cache contains invalid PHP syntax.', 0, $error); + } + $directory = dirname($path); $temporaryPath = tempnam($directory, '.arraykit-'); if ($temporaryPath === false) { return false; } - if (file_put_contents($temporaryPath, $contents, LOCK_EX) === false) { + $written = file_put_contents($temporaryPath, $contents, LOCK_EX); + if ($written !== strlen($contents)) { unlink($temporaryPath); return false; @@ -668,4 +683,92 @@ protected function writeCacheFile(string $path, string $contents): bool return false; } + + /** + * @return array + */ + private function arrayValueForMutation(string $key): array + { + $missing = $this->missingValueMarker(); + $array = $this->get($key, $missing); + if ($array === $missing) { + return []; + } + + if (!is_array($array)) { + throw new InvalidArgumentException("Config value [{$key}] must be an array."); + } + + return $array; + } + + /** + * @param array $activeReferences + * @param array $activeObjects + */ + private function materializeCacheValueRecursive( + mixed $value, + array &$activeReferences, + array &$activeObjects, + ): mixed { + if ($value instanceof EnvReference || $value instanceof \Closure) { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic deferred value.'); + } + + $activeObjects[$objectId] = true; + + try { + $resolved = $value instanceof EnvReference ? $value->resolve() : $value(); + + return $this->materializeCacheValueRecursive($resolved, $activeReferences, $activeObjects); + } finally { + unset($activeObjects[$objectId]); + } + } + + if ($value instanceof \UnitEnum || $value === null || is_scalar($value)) { + return $value; + } + + if (is_array($value)) { + $materialized = []; + foreach ($value as $key => $entry) { + $reference = \ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new UnexpectedValueException('Compiled configuration contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + $materialized[$key] = $this->materializeCacheValueRecursive( + $entry, + $activeReferences, + $activeObjects, + ); + } finally { + unset($activeReferences[$referenceId]); + } + } + + return $materialized; + } + + throw new UnexpectedValueException( + 'Compiled configuration contains unsupported value type [' . get_debug_type($value) . '].', + ); + } } diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php index 38e9374..cf0b54b 100644 --- a/src/Config/Concerns/LazyFileConfigCacheTrait.php +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -10,8 +10,16 @@ /** @internal */ trait LazyFileConfigCacheTrait { + private const string CACHE_FLAT_INDEX_FILE = '.arraykit-flat.php'; + + private const string CACHE_GENERATION_POINTER = '.arraykit-generation'; + + private const string CACHE_GENERATION_PREFIX = '.arraykit-gen-'; + private const string CACHE_LOCK_FILE = '.arraykit-cache.lock'; + private const string CACHE_STAGE_PREFIX = '.arraykit-stage-'; + /** * @var array */ @@ -21,47 +29,51 @@ trait LazyFileConfigCacheTrait protected ?string $namespaceCacheDirectory = null; + private bool $namespaceCachePinned = false; + + private ?string $pinnedNamespaceCacheDirectory = null; + /** * @param string|array|null $namespaces */ public function flushNamespaceCache(string|array|null $namespaces = null): static { - if ($this->namespaceCacheDirectory === null) { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { return $this; } - if (!is_dir($this->namespaceCacheDirectory)) { + if (!is_dir($directory)) { $this->flatLeafIndex = []; $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); return $this; } - return $this->withNamespaceCacheLock(function () use ($namespaces): void { - if ($namespaces === null) { - $this->flushAllNamespaceCacheFiles(); + $resolved = $namespaces === null ? null : $this->resolveWarmNamespaces($namespaces); - return; - } + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishFlushGeneration($resolved); + }); - foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { - $path = $this->cachedNamespacePath($namespace); - if ($path !== null && is_file($path)) { - unlink($path); - } - } + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); - $this->writeFlatLeafIndexFromCacheDirectory(); - }); + return $this; } public function namespaceCache(?string $directory): static { + $this->invalidateGeneratedNamespaceState(); + $this->namespaceCacheDirectory = $directory !== null ? rtrim($directory, DIRECTORY_SEPARATOR) : null; $this->flatLeafIndex = []; $this->flatLeafIndexLoaded = false; + $this->flushReadCache(); return $this; } @@ -85,33 +97,22 @@ public function warmNamespaceCache(string|array|null $namespaces = null): static throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); } - 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."); - } + $resolved = $this->resolveWarmNamespaces($namespaces); - $export = var_export($this->materializeCacheValue($this->items[$namespace]), true); - $path = $this->cachedNamespacePath($namespace); + $this->withNamespaceCacheLock(function () use ($resolved): void { + $this->publishWarmGeneration($resolved); + }); - if ($path === null || !$this->writeCacheFile($path, "flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + $this->invalidateGeneratedNamespaceState(); - $this->writeFlatLeafIndexFromCacheDirectory(); - }); + return $this; } protected function cachedNamespacePath(string $namespace): ?string { - if ($this->namespaceCacheDirectory === null) { - return null; - } - - return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + return $this->namespaceCachePath($namespace, $this->readerNamespaceCacheDirectory()); } /** @@ -135,8 +136,8 @@ protected function collectFlatLeafIndex(string $namespace, array $namespaceData, continue; } - if ($this->isCacheableLeafValue($value)) { - $this->addFlatLeafIndexValue($index, $path, $value); + if ($value === null || is_scalar($value)) { + $index[$path] = $value; } } } @@ -147,32 +148,20 @@ protected function discoverNamespaces(): array $namespaces = []; foreach ($this->items as $namespace => $_) { - if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { $namespaces[$namespace] = true; } } - if (!is_dir($this->directory)) { - return array_keys($namespaces); - } + $this->discoverNamespacesInDirectory($this->directory, $namespaces, false); - $entries = scandir($this->directory); - if ($entries === false) { - return array_keys($namespaces); - } - - $suffix = '.' . $this->extension; - foreach ($entries as $entry) { - if (!str_ends_with($entry, $suffix)) { - continue; - } - - $namespace = substr($entry, 0, -strlen($suffix)); - if ($namespace === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { - continue; - } - - $namespaces[$namespace] = true; + $cacheDirectory = $this->activeNamespaceCacheDirectory(); + if ($cacheDirectory !== null) { + $this->discoverNamespacesInDirectory( + $cacheDirectory, + $namespaces, + $cacheDirectory === $this->namespaceCacheDirectory, + ); } return array_keys($namespaces); @@ -180,11 +169,12 @@ protected function discoverNamespaces(): array protected function flatLeafIndexPath(): ?string { - if ($this->namespaceCacheDirectory === null) { + $directory = $this->readerNamespaceCacheDirectory(); + if ($directory === null) { return null; } - return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::FLAT_INDEX_FILE; + return $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; } protected function flatLeafValue(string $path): mixed @@ -196,6 +186,23 @@ protected function flatLeafValue(string $path): mixed : $this->missingValueMarker(); } + protected function invalidateGeneratedNamespaceState(): void + { + $this->namespaceCachePinned = false; + $this->pinnedNamespaceCacheDirectory = null; + foreach ($this->loadedNamespaceOrigins as $namespace => $origin) { + if ($origin === 'cache') { + unset($this->items[$namespace]); + } + + if ($origin === 'cache' || $origin === 'missing') { + unset($this->loadedNamespaces[$namespace], $this->loadedNamespaceOrigins[$namespace]); + } + } + + $this->flushReadCache(); + } + protected function isCacheableLeafValue(mixed $value): bool { return $value === null @@ -229,14 +236,57 @@ protected function loadFlatLeafIndex(): void $this->flatLeafIndex = $this->filterFlatLeafIndex($loaded); } } catch (\Throwable) { - // Generated flat indexes are disposable acceleration artifacts. - // A corrupt index is a cache miss; namespace/source loading remains authoritative. + // Generated indexes are disposable acceleration artifacts. } } $this->flatLeafIndexLoaded = true; } + /** + * @return array + */ + protected function namespaceCacheWarmValue(string $namespace): array + { + if ( + ($this->loadedNamespaceOrigins[$namespace] ?? null) === 'runtime' + && array_key_exists($namespace, $this->items) + ) { + $value = $this->items[$namespace]; + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + return $value; + } + + $sourceFile = $this->resolveNamespaceFile($namespace); + if ($sourceFile !== null) { + $value = include $sourceFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$sourceFile}] must return an array."); + } + + return $value; + } + + $cachedFile = $this->warmNamespaceCacheFile($namespace); + if ($cachedFile !== null) { + $value = include $cachedFile; + if (!is_array($value)) { + throw new UnexpectedValueException("Config file [{$cachedFile}] must return an array."); + } + + return $value; + } + + if (array_key_exists($namespace, $this->items) && is_array($this->items[$namespace])) { + return $this->items[$namespace]; + } + + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + /** * @param string|array|null $namespaces * @return string[] @@ -255,77 +305,122 @@ protected function resolveWarmNamespaces(string|array|null $namespaces): array return array_values(array_unique($resolved)); } - protected function writeFlatLeafIndexFromCacheDirectory(): void + private function activateGeneration(string $stage): void { - $indexPath = $this->flatLeafIndexPath(); - $directory = $this->namespaceCacheDirectory; - - if ($indexPath === null || $directory === null) { - return; + $root = $this->namespaceCacheDirectory; + if ($root === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); } - $index = $this->buildFlatLeafIndexFromDirectory($directory); - ksort($index); + $generation = self::CACHE_GENERATION_PREFIX . bin2hex(random_bytes(8)); + $destination = $root . DIRECTORY_SEPARATOR . $generation; - if (!$this->writeCacheFile($indexPath, "flatLeafIndex = $index; - $this->flatLeafIndexLoaded = true; + try { + $this->writeGenerationPointer($generation); + } catch (\Throwable $error) { + $this->removeGenerationDirectory($destination); + + throw $error; + } } - /** @param array $index */ - private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void + private function activeNamespaceCacheDirectory(): ?string { - if ( - $value === null - || is_bool($value) - || is_int($value) - || is_float($value) - || is_string($value) - ) { - $index[$path] = $value; + $root = $this->namespaceCacheDirectory; + if ($root === null || !is_dir($root)) { + return null; + } + + $pointer = $this->generationPointerPath(); + if ($pointer === null || !is_file($pointer) || !is_readable($pointer)) { + return $root; } + + $generation = trim((string) file_get_contents($pointer)); + if (preg_match('/^\\.arraykit-gen-[a-f0-9]+$/', $generation) !== 1) { + return $root; + } + + $directory = $root . DIRECTORY_SEPARATOR . $generation; + + return is_dir($directory) ? $directory : $root; } /** @return array */ private function buildFlatLeafIndexFromDirectory(string $directory): array { - /** @var array $index */ $index = []; - $entries = scandir($directory); - if ($entries === false) { - return $index; - } - $suffix = '.' . $this->extension; - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || $entry === self::FLAT_INDEX_FILE || !str_ends_with($entry, $suffix)) { + foreach ($this->namespaceCacheEntries($directory, false) as [$namespace, $path]) { + try { + $loaded = include $path; + } catch (\Throwable) { continue; } - $namespace = substr($entry, 0, -strlen($suffix)); - if ($namespace === '' || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1) { - continue; + if (is_array($loaded)) { + $this->collectFlatLeafIndex($namespace, $loaded, $index); } + } - $path = $directory . DIRECTORY_SEPARATOR . $entry; + return $index; + } - try { - $loaded = include $path; - } catch (\Throwable) { + /** + * @param array $excluded + */ + private function copyActiveNamespaceCacheFiles(string $stage, array $excluded): void + { + $active = $this->activeNamespaceCacheDirectory(); + if ($active === null) { + return; + } + + $legacy = $active === $this->namespaceCacheDirectory; + + foreach ($this->namespaceCacheEntries($active, $legacy) as [$namespace, $path]) { + if (isset($excluded[$namespace])) { continue; } - if (!is_array($loaded)) { - continue; + $destination = $stage . DIRECTORY_SEPARATOR . basename($path); + if (!copy($path, $destination)) { + throw new RuntimeException("Unable to copy namespace cache for [{$namespace}]."); } + } + } - $this->collectFlatLeafIndex($namespace, $loaded, $index); + private function createGenerationStage(): string + { + $root = $this->namespaceCacheDirectory; + if ($root === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); } - return $index; + $stage = $root . DIRECTORY_SEPARATOR . self::CACHE_STAGE_PREFIX . bin2hex(random_bytes(8)); + if (!mkdir($stage, 0755)) { + throw new RuntimeException('Unable to create lazy-config cache staging directory.'); + } + + return $stage; + } + + /** + * @param array $namespaces + */ + private function discoverNamespacesInDirectory(string $directory, array &$namespaces, bool $legacy): void + { + if (!is_dir($directory)) { + return; + } + + foreach ($this->namespaceCacheEntries($directory, $legacy) as [$namespace]) { + $namespaces[$namespace] = true; + } } /** @@ -334,7 +429,6 @@ private function buildFlatLeafIndexFromDirectory(string $directory): array */ private function filterFlatLeafIndex(array $loaded): array { - /** @var array $index */ $index = []; foreach ($loaded as $key => $value) { @@ -342,62 +436,166 @@ private function filterFlatLeafIndex(array $loaded): array continue; } - $this->addFlatLeafIndexValue($index, $key, $value); + if ($value === null || is_scalar($value)) { + $index[$key] = $value; + } } return $index; } - private function flushAllNamespaceCacheFiles(): void + private function generationPointerPath(): ?string { - $directory = $this->namespaceCacheDirectory; - if ($directory === null || !is_dir($directory)) { - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; + return $this->namespaceCacheDirectory === null + ? null + : $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::CACHE_GENERATION_POINTER; + } - return; - } + private function isFlatPathSafeSegment(string $segment): bool + { + return !str_contains($segment, '.') + && !str_contains($segment, '\\') + && !str_contains($segment, '*') + && !str_contains($segment, '{'); + } + /** + * @return array + */ + private function namespaceCacheEntries(string $directory, bool $legacy): array + { $entries = scandir($directory); - if ($entries !== false) { - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..' || !$this->isOwnedNamespaceCacheEntry($entry)) { - continue; - } + if ($entries === false) { + return []; + } - $path = $directory . DIRECTORY_SEPARATOR . $entry; - if (is_file($path)) { - unlink($path); - } + $resolved = []; + $suffix = '.' . $this->extension; + + foreach ($entries as $entry) { + if (!str_ends_with($entry, $suffix) || $entry === self::CACHE_FLAT_INDEX_FILE) { + continue; + } + + $namespace = substr($entry, 0, -strlen($suffix)); + if ( + $namespace === '' + || preg_match('/^[A-Za-z0-9_-]+$/', $namespace) !== 1 + || ($legacy && $namespace === '__flat') + ) { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_file($path) && is_readable($path)) { + $resolved[] = [$namespace, $path]; } } - $this->flatLeafIndex = []; - $this->flatLeafIndexLoaded = false; + return $resolved; } - private function isFlatPathSafeSegment(string $segment): bool + private function namespaceCachePath(string $namespace, ?string $directory): ?string { - return !str_contains($segment, '.') - && !str_contains($segment, '\\') - && !str_contains($segment, '*') - && !str_contains($segment, '{'); + if ($directory === null) { + return null; + } + + if ($directory === $this->namespaceCacheDirectory && $namespace === '__flat') { + return null; + } + + return $directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; } - private function isOwnedNamespaceCacheEntry(string $entry): bool + /** + * @param string[]|null $namespaces + */ + private function publishFlushGeneration(?array $namespaces): void { - if ($entry === self::FLAT_INDEX_FILE) { - return true; + $stage = $this->createGenerationStage(); + + try { + if ($namespaces !== null) { + $this->copyActiveNamespaceCacheFiles($stage, array_fill_keys($namespaces, true)); + } + + $this->writeGenerationFlatIndex($stage); + $this->activateGeneration($stage); + } catch (\Throwable $error) { + if (is_dir($stage)) { + $this->removeGenerationDirectory($stage); + } + + throw $error; } + } - $suffix = '.' . $this->extension; - if (!str_ends_with($entry, $suffix)) { - return false; + /** + * @param string[] $namespaces + */ + private function publishWarmGeneration(array $namespaces): void + { + $stage = $this->createGenerationStage(); + + try { + $this->copyActiveNamespaceCacheFiles($stage, array_fill_keys($namespaces, true)); + + foreach ($namespaces as $namespace) { + $value = $this->materializeCacheValue($this->namespaceCacheWarmValue($namespace)); + if (!is_array($value)) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + $path = $stage . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + $export = var_export($value, true); + if (!$this->writeCacheFile($path, "writeGenerationFlatIndex($stage); + $this->activateGeneration($stage); + } catch (\Throwable $error) { + if (is_dir($stage)) { + $this->removeGenerationDirectory($stage); + } + + throw $error; + } + } + + private function readerNamespaceCacheDirectory(): ?string + { + if (!$this->namespaceCachePinned) { + $this->pinnedNamespaceCacheDirectory = $this->activeNamespaceCacheDirectory(); + $this->namespaceCachePinned = true; + } + + return $this->pinnedNamespaceCacheDirectory; + } + + private function removeGenerationDirectory(string $directory): void + { + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_file($path)) { + unlink($path); + } } - $namespace = substr($entry, 0, -strlen($suffix)); + rmdir($directory); + } + + private function warmNamespaceCacheFile(string $namespace): ?string + { + $path = $this->namespaceCachePath($namespace, $this->activeNamespaceCacheDirectory()); - return $namespace !== '' && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1; + return $path !== null && is_file($path) && is_readable($path) ? $path : null; } private function withNamespaceCacheLock(\Closure $operation): static @@ -425,4 +623,42 @@ private function withNamespaceCacheLock(\Closure $operation): static return $this; } + + private function writeGenerationFlatIndex(string $directory): void + { + $index = $this->buildFlatLeafIndexFromDirectory($directory); + ksort($index); + + $path = $directory . DIRECTORY_SEPARATOR . self::CACHE_FLAT_INDEX_FILE; + if (!$this->writeCacheFile($path, "generationPointerPath(); + if ($path === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + $temporary = tempnam(dirname($path), '.arraykit-pointer-'); + if ($temporary === false) { + throw new RuntimeException('Unable to create lazy-config generation pointer.'); + } + + $contents = $generation . PHP_EOL; + $written = file_put_contents($temporary, $contents, LOCK_EX); + if ($written !== strlen($contents)) { + unlink($temporary); + + throw new RuntimeException('Unable to write lazy-config generation pointer.'); + } + + if (!rename($temporary, $path)) { + unlink($temporary); + + throw new RuntimeException('Unable to publish lazy-config generation pointer.'); + } + } } diff --git a/src/Config/LayeredLazyFileConfig.php b/src/Config/LayeredLazyFileConfig.php index 6cc294a..bbcbd07 100644 --- a/src/Config/LayeredLazyFileConfig.php +++ b/src/Config/LayeredLazyFileConfig.php @@ -6,7 +6,7 @@ /** * Layered lazy configuration with explicit precedence: - * fallback < lazy source < overrides. + * fallback < lazy source < overrides < runtime mutations. * * Namespace materialization is used deliberately so exact-path reads always * match reads against the fully merged configuration, including list/scalar @@ -52,9 +52,7 @@ public function __construct( #[\Override] public function all(): array { - foreach (array_keys($this->knownNamespaces) as $namespace) { - $this->materializeNamespace($namespace); - } + $this->materializeKnownNamespaces(); $items = []; foreach (parent::all() as $key => $value) { @@ -66,6 +64,14 @@ public function all(): array return $items; } + #[\Override] + public function changed(string $snapshot = 'default'): bool + { + $this->materializeKnownNamespaces(); + + return parent::changed($snapshot); + } + public function clearNamespaceCache(): static { $this->source->flushNamespaceCache(); @@ -73,11 +79,138 @@ public function clearNamespaceCache(): static return $this; } + #[\Override] + public function exportCache(string $path): bool + { + $this->materializeKnownNamespaces(); + + return parent::exportCache($path); + } + + #[\Override] + public function fill(string|array $key, mixed $value = null): bool + { + $this->materializeMutationTargets($key); + + return parent::fill($key, $value); + } + + #[\Override] + public function forget(string|int|array $key): bool + { + $this->materializeMutationTargets($key); + + return parent::forget($key); + } + + #[\Override] + public function get(string|int|array|null $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->all(); + } + + return parent::get($key, $default); + } + + #[\Override] + public function loadArray(array $resource): bool + { + $this->materializeKnownNamespaces(); + + return parent::loadArray($resource); + } + + #[\Override] + public function loadFile(string $path): bool + { + $this->materializeKnownNamespaces(); + + return parent::loadFile($path); + } + + #[\Override] + public function merge(array $items): bool + { + $this->materializeMutationTargets($items); + + return parent::merge($items); + } + public function namespaceCacheDirectory(): ?string { return $this->source->namespaceCacheDirectory(); } + #[\Override] + public function overlay(array $overlay): bool + { + return $this->merge($overlay); + } + + #[\Override] + public function reload(array|string $source): bool + { + $result = parent::reload($source); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } + + return $result; + } + + #[\Override] + public function replace(array $items): bool + { + $result = parent::replace($items); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($items); + } + + return $result; + } + + #[\Override] + public function restore(string $name = 'default'): bool + { + $restored = parent::restore($name); + if ($restored) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } + + return $restored; + } + + #[\Override] + public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool + { + if ($key === null) { + $this->materializeKnownNamespaces(); + $result = parent::set($key, $value, $overwrite); + if ($result) { + $this->markAllKnownNamespacesMaterialized(); + $this->registerNamespaces($this->items); + } + + return $result; + } + + $this->materializeMutationTargets($key); + + return parent::set($key, $value, $overwrite); + } + + #[\Override] + public function snapshot(string $name = 'default'): bool + { + $this->materializeKnownNamespaces(); + + return parent::snapshot($name); + } + /** * @param string|array|null $namespaces */ @@ -101,9 +234,42 @@ protected function resolveRawValue(int|string $key): mixed return parent::resolveRawValue($key); } + private function markAllKnownNamespacesMaterialized(): void + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializedNamespaces[$namespace] = true; + } + } + + private function materializeKnownNamespaces(): void + { + foreach (array_keys($this->knownNamespaces) as $namespace) { + $this->materializeNamespace($namespace); + } + } + + /** + * @param string|int|array $targets + */ + private function materializeMutationTargets(string|int|array $targets): void + { + if (is_array($targets)) { + foreach ($targets as $key => $value) { + $path = is_int($key) ? $value : $key; + if (is_int($path) || is_string($path)) { + $this->materializeNamespace($this->namespaceFromPath((string) $path)); + } + } + + return; + } + + $this->materializeNamespace($this->namespaceFromPath((string) $targets)); + } + private function materializeNamespace(string $namespace): void { - if (isset($this->materializedNamespaces[$namespace])) { + if ($namespace === '' || isset($this->materializedNamespaces[$namespace])) { return; } @@ -141,10 +307,19 @@ private function namespaceFromPath(string $path): string $namespace = $dot === false ? $path : substr($path, 0, $dot); $namespace = trim($namespace); - if ($namespace === '') { - return $path; - } + return $namespace === '' ? $path : $namespace; + } - return $namespace; + /** + * @param array $items + */ + private function registerNamespaces(array $items): void + { + foreach ($items as $namespace => $_value) { + if (is_string($namespace) && $namespace !== '') { + $this->knownNamespaces[$namespace] = true; + $this->materializedNamespaces[$namespace] = true; + } + } } } diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 8352258..c9115ae 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -14,7 +14,10 @@ class LazyFileConfig extends Config { use LazyFileConfigCacheTrait; - private const string FLAT_INDEX_FILE = '__flat.php'; + /** + * @var array + */ + protected array $loadedNamespaceOrigins = []; /** * @var array @@ -195,7 +198,16 @@ public function loadFile(string $path): bool */ public function merge(array $items): bool { - return $this->syncLoadedNamespacesAfter(parent::merge($items)); + $changed = parent::merge($items); + if ($changed) { + foreach (array_keys($items) as $namespace) { + if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace) === 1) { + $this->markNamespaceRuntime($namespace); + } + } + } + + return $changed; } /** @@ -274,11 +286,14 @@ protected function forgetPath(string $path): void $this->loadNamespace($namespace); if (!array_key_exists($namespace, $this->items)) { + $this->markNamespaceRuntime($namespace); + return; } if ($rest === null || $rest === '') { unset($this->items[$namespace]); + $this->markNamespaceRuntime($namespace); return; } @@ -288,6 +303,7 @@ protected function forgetPath(string $path): void } DotNotation::forget($this->items[$namespace], $rest); + $this->markNamespaceRuntime($namespace); } protected function getPath(string $path, mixed $default): mixed @@ -336,9 +352,13 @@ protected function loadNamespace(string $namespace): void return; } - $file = $this->resolveCachedNamespaceFile($namespace) ?? $this->resolveNamespaceFile($namespace); + $cachedFile = $this->resolveCachedNamespaceFile($namespace); + $sourceFile = $this->resolveNamespaceFile($namespace); + $file = $cachedFile ?? $sourceFile; + if ($file === null) { $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; return; } @@ -349,15 +369,18 @@ protected function loadNamespace(string $namespace): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $cachedFile !== null ? 'cache' : 'source'; if (!array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $loaded; + $this->flushReadCache(); return; } if (is_array($this->items[$namespace])) { $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); } } @@ -444,7 +467,7 @@ protected function resolveRawValue(int|string $key): mixed return parent::resolveRawValue($key); } - if (!$this->readCacheEnabled()) { + if (!$this->readCacheEnabled() || !$this->isReadCacheSafePath($key)) { return $this->resolveLazyRawValue($key); } @@ -464,6 +487,7 @@ protected function setPath(string $path, mixed $value, bool $overwrite): void if ($rest === null || $rest === '') { if ($overwrite || !array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $value; + $this->markNamespaceRuntime($namespace); } return; @@ -476,6 +500,7 @@ protected function setPath(string $path, mixed $value, bool $overwrite): void DotNotation::set($namespaceConfig, $rest, $value, $overwrite); $this->items[$namespace] = $namespaceConfig; + $this->markNamespaceRuntime($namespace); } /** @@ -505,6 +530,7 @@ protected function syncLoadedNamespacesFromItems(): void { parent::flushReadCache(); $this->loadedNamespaces = []; + $this->loadedNamespaceOrigins = []; foreach ($this->items as $namespace => $_) { if (!is_string($namespace) || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { @@ -512,9 +538,16 @@ protected function syncLoadedNamespacesFromItems(): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'runtime'; } } + private function markNamespaceRuntime(string $namespace): void + { + $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'runtime'; + } + private function syncLoadedNamespacesAfter(bool $changed): bool { if ($changed) { diff --git a/src/Config/ResilientLazyFileConfig.php b/src/Config/ResilientLazyFileConfig.php index 197114b..02945f5 100644 --- a/src/Config/ResilientLazyFileConfig.php +++ b/src/Config/ResilientLazyFileConfig.php @@ -19,11 +19,15 @@ protected function loadNamespace(string $namespace): void return; } - $loaded = $this->loadCachedNamespace($namespace); + $cache = $this->resolveCachedNamespaceFile($namespace); + $loaded = $cache === null ? null : $this->loadCachedNamespace($cache); + $origin = $loaded === null ? 'source' : 'cache'; + if ($loaded === null) { $source = $this->resolveNamespaceFile($namespace); if ($source === null) { $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = 'missing'; return; } @@ -35,28 +39,26 @@ protected function loadNamespace(string $namespace): void } $this->loadedNamespaces[$namespace] = true; + $this->loadedNamespaceOrigins[$namespace] = $origin; if (!array_key_exists($namespace, $this->items)) { $this->items[$namespace] = $loaded; + $this->flushReadCache(); return; } if (is_array($this->items[$namespace])) { $this->items[$namespace] = array_replace_recursive($loaded, $this->items[$namespace]); + $this->flushReadCache(); } } /** * @return array|null */ - private function loadCachedNamespace(string $namespace): ?array + private function loadCachedNamespace(string $cache): ?array { - $cache = $this->resolveCachedNamespaceFile($namespace); - if ($cache === null) { - return null; - } - try { $loaded = include $cache; } catch (\Throwable) { diff --git a/src/DTO/Concerns/DTOTrait.php b/src/DTO/Concerns/DTOTrait.php index 2f559da..dc9f415 100644 --- a/src/DTO/Concerns/DTOTrait.php +++ b/src/DTO/Concerns/DTOTrait.php @@ -4,6 +4,7 @@ namespace Infocyph\ArrayKit\DTO\Concerns; +use Infocyph\ArrayKit\DTO\DTOGraphGuard; use ReflectionNamedType; use ReflectionObject; use ReflectionProperty; @@ -79,6 +80,25 @@ public function hydrateNested(array $values, array $mapping = [], bool $coerce = return $this; } + /** + * Hydrate nested DTO data only after the complete input graph passes the + * configured cycle, depth and node limits. + * + * @param array $values + * @param array $mapping + */ + public function hydrateNestedGuarded( + array $values, + array $mapping = [], + bool $coerce = false, + int $maxDepth = 64, + int $maxNodes = 100000, + ): static { + DTOGraphGuard::assertWithinLimits($values, $maxDepth, $maxNodes); + + return $this->hydrateNested($values, $mapping, $coerce); + } + /** * @param array $values * @param array $mapping @@ -120,6 +140,17 @@ public function toArrayDeep(): array return $result; } + /** + * Export recursively after the complete public DTO/array graph passes the + * configured cycle, depth and node limits. + * + * @return array + */ + public function toArrayDeepGuarded(int $maxDepth = 64, int $maxNodes = 100000): array + { + return DTOGraphGuard::export($this, $maxDepth, $maxNodes); + } + private function assignProperty(string $property, mixed $value, bool $coerce): void { if (!$coerce) { diff --git a/src/DTO/DTOGraphGuard.php b/src/DTO/DTOGraphGuard.php new file mode 100644 index 0000000..ad33e87 --- /dev/null +++ b/src/DTO/DTOGraphGuard.php @@ -0,0 +1,255 @@ + */ + public static function export(object $value, int $maxDepth, int $maxNodes): array + { + $result = self::traverse($value, $maxDepth, $maxNodes, true); + if (!is_array($result)) { + throw new InvalidArgumentException('Guarded export requires the standard DTO exporter.'); + } + + return $result; + } + + private static function assertNodeWithinLimits( + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + ): void { + if ($depth > $maxDepth) { + throw new RuntimeException('DTO graph traversal exceeded max depth.'); + } + + $visitedNodes++; + if ($visitedNodes > $maxNodes) { + throw new RuntimeException('DTO graph traversal exceeded max node count.'); + } + } + + private static function isStandardExporter(object $value): bool + { + if (!is_callable([$value, 'toArrayDeep']) && !is_callable([$value, 'toArray'])) { + return false; + } + + foreach (['toArray', 'toArrayDeep'] as $method) { + if ( + !method_exists($value, $method) + || new ReflectionMethod($value, $method)->getFileName() + !== new ReflectionMethod(DTOTrait::class, $method)->getFileName() + ) { + throw new InvalidArgumentException('Guarded DTO export does not execute custom exporters.'); + } + } + + return true; + } + + private static function traverse(mixed $value, int $maxDepth, int $maxNodes, bool $export): mixed + { + if ($maxDepth < 1) { + throw new InvalidArgumentException('DTO graph max depth must be at least 1.'); + } + + if ($maxNodes < 1) { + throw new InvalidArgumentException('DTO graph max node count must be at least 1.'); + } + + $activeObjects = []; + $activeReferences = []; + $visitedNodes = 0; + + return self::walk( + $value, + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $export, + ); + } + + /** + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walk( + mixed $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + bool $export, + ): mixed { + self::assertNodeWithinLimits($depth, $visitedNodes, $maxDepth, $maxNodes); + + if (is_array($value)) { + return self::walkArray( + $value, + $depth, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $export, + ); + } + + if (is_object($value) && !$value instanceof UnitEnum) { + return self::walkObject( + $value, + $depth, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $export, + ); + } + + return $value; + } + + /** + * @param array $value + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walkArray( + array $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + bool $export, + ): mixed { + $result = []; + foreach ($value as $key => $entry) { + $reference = ReflectionReference::fromArrayElement($value, $key); + if ($reference === null) { + $resolved = self::walk( + $entry, + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $export, + ); + if ($export) { + $result[$key] = $resolved; + } + + continue; + } + + $referenceId = bin2hex($reference->getId()); + if (isset($activeReferences[$referenceId])) { + throw new RuntimeException('DTO graph contains a cyclic array reference.'); + } + + $activeReferences[$referenceId] = true; + + try { + $resolved = self::walk( + $entry, + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $export, + ); + if ($export) { + $result[$key] = $resolved; + } + } finally { + unset($activeReferences[$referenceId]); + } + } + + return $export ? $result : $value; + } + + /** + * @param array $activeObjects + * @param array $activeReferences + */ + private static function walkObject( + object $value, + int $depth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + array &$activeObjects, + array &$activeReferences, + bool $export, + ): mixed { + $objectId = spl_object_id($value); + if (isset($activeObjects[$objectId])) { + throw new RuntimeException('DTO graph contains a cyclic object reference.'); + } + + $exportObject = $export && self::isStandardExporter($value); + $result = []; + $activeObjects[$objectId] = true; + + try { + foreach (new ReflectionObject($value)->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic() || !$property->isInitialized($value)) { + continue; + } + + $resolved = self::walk( + $property->getValue($value), + $depth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $activeObjects, + $activeReferences, + $exportObject, + ); + if ($exportObject) { + $result[$property->getName()] = $resolved; + } + } + } finally { + unset($activeObjects[$objectId]); + } + + return $exportObject ? $result : $value; + } +} diff --git a/src/Facade/ModuleProxy.php b/src/Facade/ModuleProxy.php index f128824..01ffec4 100644 --- a/src/Facade/ModuleProxy.php +++ b/src/Facade/ModuleProxy.php @@ -5,6 +5,8 @@ namespace Infocyph\ArrayKit\Facade; use BadMethodCallException; +use Infocyph\ArrayKit\Array\DotNotation; +use UnexpectedValueException; final readonly class ModuleProxy { @@ -19,6 +21,87 @@ public function __construct( * @param array $arguments */ public function __call(string $method, array $arguments): mixed + { + return $this->invoke($method, $arguments); + } + + /** + * @param array $array + * @param array|string $keys + */ + public function fill(array &$array, array|string $keys, mixed $value = null): void + { + $this->invoke('fill', [&$array, $keys, $value]); + } + + /** + * @param array|null $array + * @param-out array $array + * @param array|int|string|null $keys + * @param array|null $target Native dot-module named argument + */ + public function forget(?array &$array = null, array|string|int|null $keys = null, ?array &$target = null): void + { + if ($target !== null) { + if ($this->targetClass !== DotNotation::class || $array !== null) { + throw new \InvalidArgumentException('The target argument requires the dot module and no array argument.'); + } + + $array = &$target; + } + + if ($array === null) { + throw new \ArgumentCountError('An array or dot target is required for forget().'); + } + + $this->invoke('forget', [&$array, $keys]); + } + + /** + * @param array $array + */ + public function move(array &$array, string $from, string $to, bool $overwrite = true): bool + { + return $this->invokeBool('move', [&$array, $from, $to, $overwrite]); + } + + /** + * @param array $array + */ + public function offsetSet(array &$array, string $key, mixed $value): void + { + $this->invoke('offsetSet', [&$array, $key, $value]); + } + + /** + * @param array $array + */ + public function offsetUnset(array &$array, string $key): void + { + $this->invoke('offsetUnset', [&$array, $key]); + } + + /** + * @param array $array + */ + public function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + { + return $this->invokeBool('rename', [&$array, $from, $to, $overwrite]); + } + + /** + * @param array $array + * @param array|string|null $keys + */ + public function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + { + return $this->invokeBool('set', [&$array, $keys, $value, $overwrite]); + } + + /** + * @param array $arguments + */ + private function invoke(string $method, array $arguments): mixed { if (!is_callable([$this->targetClass, $method])) { throw new BadMethodCallException("Method {$this->targetClass}::{$method} does not exist."); @@ -26,4 +109,17 @@ public function __call(string $method, array $arguments): mixed return $this->targetClass::$method(...$arguments); } + + /** + * @param array $arguments + */ + private function invokeBool(string $method, array $arguments): bool + { + $result = $this->invoke($method, $arguments); + if (!is_bool($result)) { + throw new UnexpectedValueException("Method {$this->targetClass}::{$method} must return bool."); + } + + return $result; + } } diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index 8f4d0b9..d8bc367 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -52,12 +52,27 @@ function lazyConfigItems(LazyFileConfig $config): array return (fn (): array => $this->items)->call($config); } +function lazyConfigActiveCacheDirectory(string $directory): string +{ + $pointer = $directory.DIRECTORY_SEPARATOR.'.arraykit-generation'; + if (! is_file($pointer)) { + return $directory; + } + + $generation = trim((string) file_get_contents($pointer)); + $active = $directory.DIRECTORY_SEPARATOR.$generation; + + return is_dir($active) ? $active : $directory; +} + /** * @return array */ function lazyConfigFlatIndex(string $directory): array { - $path = $directory.DIRECTORY_SEPARATOR.'__flat.php'; + $path = lazyConfigActiveCacheDirectory($directory) + .DIRECTORY_SEPARATOR + .'.arraykit-flat.php'; if (! is_file($path)) { return []; @@ -362,9 +377,11 @@ function lazyConfigFlatIndex(string $directory): array $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); $config->warmNamespaceCache('db')->flushNamespaceCache(); + $active = lazyConfigActiveCacheDirectory($this->cachePath); + expect(is_file($this->cachePath.DIRECTORY_SEPARATOR.'keep.txt'))->toBeTrue() - ->and(is_file($this->cachePath.DIRECTORY_SEPARATOR.'db.php'))->toBeFalse() - ->and(is_file($this->cachePath.DIRECTORY_SEPARATOR.'__flat.php'))->toBeFalse(); + ->and(is_file($active.DIRECTORY_SEPARATOR.'db.php'))->toBeFalse() + ->and(lazyConfigFlatIndex($this->cachePath))->toBe([]); }); it('materializes environment references and closures when warming namespace cache', function () { @@ -498,7 +515,7 @@ function lazyConfigFlatIndex(string $directory): array $config->warmNamespaceCache('db'); unlink($this->configPath.DIRECTORY_SEPARATOR.'db.php'); - unlink($this->cachePath.DIRECTORY_SEPARATOR.'db.php'); + unlink(lazyConfigActiveCacheDirectory($this->cachePath).DIRECTORY_SEPARATOR.'db.php'); $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); diff --git a/tests/Feature/Release530BatchATest.php b/tests/Feature/Release530BatchATest.php new file mode 100644 index 0000000..d2d7ed3 --- /dev/null +++ b/tests/Feature/Release530BatchATest.php @@ -0,0 +1,314 @@ + ArrayMulti::flattenGuarded( + range(1, 10000), + maxNodes: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); + + expect(fn () => DotNotation::getSafe( + ['rows' => range(1, 10000)], + 'rows.*', + maxNodes: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('shares wildcard node budgets and stops default resolution after exhaustion', function () { + $defaults = 0; + + $result = DotNotation::getSafe( + ['rows' => array_fill(0, 10000, [])], + 'rows.*.id', + function () use (&$defaults): string { + $defaults++; + + return 'missing'; + }, + maxNodes: 3, + ); + + expect(count($result))->toBeLessThanOrEqual(3) + ->and($defaults)->toBeLessThanOrEqual(3); +}); + +it('advances guarded depth across wildcard traversal', function () { + $value = 'leaf'; + for ($i = 0; $i < 12; $i++) { + $value = [$value]; + } + + expect(fn () => DotNotation::getSafe( + ['rows' => [$value]], + 'rows.*.*.*.*.*.*.*.*.*.*.*.*', + maxDepth: 2, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('preserves layered runtime writes made before first materialization', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source', 'debug' => false];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + $config->set('app.name', 'caller'); + + expect($config->get('app.name'))->toBe('caller') + ->and($config->get())->toBe($config->all()); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('restores layered materialization state with snapshots', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source'];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + $config->snapshot(); + expect($config->get('app.name'))->toBe('source'); + + $config->restore(); + + expect($config->get('app.name'))->toBe('source'); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('materializes known layered namespaces before exporting compiled config', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-a-cache-' . bin2hex(random_bytes(5)) . '.php'; + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source'];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->exportCache($cache))->toBeTrue() + ->and(include $cache)->toBe(['app' => ['name' => 'source']]); + } finally { + batchARemoveDirectory($directory); + if (is_file($cache)) { + unlink($cache); + } + } +}); + +it('preserves by-reference mutations through facade proxies', function () { + $data = ['app' => ['name' => 'old'], 'remove' => true]; + + expect(ArrayKit::dot()->set($data, 'app.name', 'new'))->toBeTrue() + ->and($data['app']['name'])->toBe('new'); + + ArrayKit::helper()->forget($data, 'remove'); + + expect($data)->not->toHaveKey('remove'); + + ArrayKit::dot()->fill($data, 'app.debug', true); + expect($data['app']['debug'])->toBeTrue(); + + expect(ArrayKit::dot()->rename($data, 'app.debug', 'app.enabled'))->toBeTrue() + ->and($data['app']['enabled'])->toBeTrue(); +}); + +it('replays terminal lazy source failures instead of presenting truncated success', function () { + $lazy = LazyCollection::from((function () { + yield 1; + throw new RuntimeException('source failure'); + })()); + + foreach ([1, 2] as $attempt) { + try { + $lazy->all(); + test()->fail("Traversal {$attempt} unexpectedly succeeded."); + } catch (RuntimeException $error) { + expect($error->getMessage())->toBe('source failure'); + } + } +}); + +it('replays failures before the first lazy yield', function () { + $lazy = LazyCollection::from((function () { + if (true) { + throw new RuntimeException('initial failure'); + } + + yield 1; + })()); + + expect(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure') + ->and(fn () => $lazy->all())->toThrow(RuntimeException::class, 'initial failure'); +}); + +it('enforces guarded array budgets at exact flat and cyclic boundaries', function () { + expect(ArrayMulti::flattenGuarded([1, 2], maxNodes: 2, throwOnTooDeep: true))->toBe([1, 2]) + ->and(ArrayMulti::flattenGuarded(range(1, 100), maxNodes: 2))->toBe([1, 2]) + ->and(fn () => ArrayMulti::depthGuarded(range(1, 100), maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(fn () => ArrayMulti::sortRecursiveGuarded([3, 2, 1], maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(ArrayMulti::sortRecursiveGuarded([3, 2, 1], maxNodes: 2))->toBe([3, 2, 1]); + + $cycle = []; + $cycle['self'] = &$cycle; + + expect(fn () => ArrayMulti::depthGuarded($cycle, maxNodes: 3, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class); +}); + +it('shares safe dot budgets across multiple requested paths', function () { + $data = [ + 'a' => ['value' => 1], + 'b' => ['value' => 2], + ]; + + expect(DotNotation::getSafe( + $data, + ['a.value', 'b.value'], + 'missing', + maxNodes: 3, + ))->toBe([ + 'a.value' => 1, + 'b.value' => 'missing', + ])->and(fn () => DotNotation::getSafe( + $data, + ['a.value', 'b.value'], + 'missing', + maxNodes: 3, + throwOnTooDeep: true, + ))->toThrow(RuntimeException::class); +}); + +it('treats non-positive safe traversal limits as unbounded', function () { + $data = ['one' => ['two' => ['three' => 'value']]]; + + expect(DotNotation::getSafe($data, 'one.two.three', maxDepth: 0, maxNodes: 0))->toBe('value') + ->and(DotNotation::getSafe($data, 'one.two.three', maxDepth: -1, maxNodes: -1))->toBe('value'); +}); + +it('keeps full layered replacements authoritative over previously unknown source namespaces', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents($directory . '/app.php', " 'source-app'];\n"); + file_put_contents($directory . '/extra.php', " 'source-extra'];\n"); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->set(null, ['extra' => ['name' => 'caller-extra']]))->toBeTrue() + ->and($config->get('extra.name'))->toBe('caller-extra') + ->and($config->get('app.name', 'missing'))->toBe('missing'); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('keeps inherited layered mutations coherent before first read', function () { + $directory = sys_get_temp_dir() . '/arraykit-batch-a-' . bin2hex(random_bytes(5)); + mkdir($directory, 0777, true); + file_put_contents( + $directory . '/app.php', + " 'source', 'items' => ['middle'], 'remove' => true];\n", + ); + + try { + $config = new LayeredLazyFileConfig($directory, namespaces: ['app']); + + expect($config->fill('app.debug', true))->toBeTrue() + ->and($config->append('app.items', 'last'))->toBeTrue() + ->and($config->prepend('app.items', 'first'))->toBeTrue() + ->and($config->forget('app.remove'))->toBeTrue() + ->and($config->get('app'))->toBe([ + 'name' => 'source', + 'items' => ['first', 'middle', 'last'], + 'debug' => true, + ]); + } finally { + batchARemoveDirectory($directory); + } +}); + +it('preserves named and offset facade mutations', function () { + $data = ['literal.key' => 'old', 'remove' => true]; + + expect(ArrayKit::dot()->set(array: $data, keys: 'literal\\.key', value: 'new'))->toBeTrue() + ->and($data['literal.key'])->toBe('new'); + + ArrayKit::dot()->offsetSet($data, 'added', 1); + ArrayKit::dot()->offsetUnset($data, 'remove'); + ArrayKit::helper()->forget(array: $data, keys: 'added'); + + expect($data)->toBe(['literal.key' => 'new']); +}); + +it('allows successful lazy prefix replay while preserving a later terminal failure', function () { + $lazy = LazyCollection::from((function () { + yield 'first' => 1; + yield 'second' => 2; + throw new RuntimeException('later failure'); + })()); + + expect(fn () => $lazy->all())->toThrow(RuntimeException::class, 'later failure') + ->and($lazy->take(2)->all())->toBe(['first' => 1, 'second' => 2]) + ->and(fn () => $lazy->all())->toThrow(RuntimeException::class, 'later failure'); +}); + +it('does not confuse lazy consumer callback failures with source failures', function () { + $lazy = LazyCollection::from((function () { + yield 1; + yield 2; + })()); + + expect(fn () => $lazy->mapLazy( + static fn (int $value): int => $value === 1 + ? throw new RuntimeException('callback failure') + : $value, + )->all())->toThrow(RuntimeException::class, 'callback failure') + ->and($lazy->all())->toBe([1, 2]); +}); diff --git a/tests/Feature/Release530BatchBTest.php b/tests/Feature/Release530BatchBTest.php new file mode 100644 index 0000000..2731889 --- /dev/null +++ b/tests/Feature/Release530BatchBTest.php @@ -0,0 +1,493 @@ + 'before']; + $referenced = 'before'; + + $config = new Config(); + $config->loadArray([ + 'object' => $object, + 'reference' => ['name' => &$referenced], + ]); + + expect($config->get('object.name'))->toBe('before') + ->and($config->get('reference.name'))->toBe('before'); + + $object->name = 'after'; + $referenced = 'after'; + + expect($config->get('object.name'))->toBe('after') + ->and($config->get('reference.name'))->toBe('after'); +}); + +it('invalidates flat and resolved reads when the namespace cache source changes', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + $warmer = new LazyFileConfig( + $source, + items: ['db' => ['host' => 'cache']], + namespaceCacheDirectory: $cache, + ); + $warmer->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($config->get('db.host'))->toBe('cache') + ->and($config->loaded('db'))->toBeFalse(); + + $config->namespaceCache(null); + + expect($config->get('db.host'))->toBe('source') + ->and($config->loaded('db'))->toBeTrue(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('keeps the valid __flat namespace distinct from internal flat-index metadata', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, '__flat', ['name' => 'namespace']); + + try { + $warmer = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $warmer->warmNamespaceCache('__flat'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('__flat.name'))->toBe('namespace'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rebuilds generated namespace caches from authoritative source values', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents( + $source . '/db.php', + <<<'PHP' + Environment::ref('ARRAYKIT_BATCH_B_HOST', 'fallback')]; +PHP, + ); + + try { + $_ENV['ARRAYKIT_BATCH_B_HOST'] = 'first'; + + $first = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $first->warmNamespaceCache('db'); + + $_ENV['ARRAYKIT_BATCH_B_HOST'] = 'second'; + + $second = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $second->warmNamespaceCache('db'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('db.host'))->toBe('second'); + } finally { + unset($_ENV['ARRAYKIT_BATCH_B_HOST']); + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('publishes namespace cache rebuilds as an immutable generation', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'app', ['name' => 'ArrayKit']); + batchBWriteConfig($source, 'db', ['host' => 'localhost']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['app', 'db']); + + $pointer = $cache . '/.arraykit-generation'; + + expect(is_file($pointer))->toBeTrue(); + + $generation = trim((string) file_get_contents($pointer)); + $generationDirectory = $cache . DIRECTORY_SEPARATOR . $generation; + + expect(str_starts_with($generation, '.arraykit-gen-'))->toBeTrue() + ->and(is_dir($generationDirectory))->toBeTrue() + ->and(is_file($generationDirectory . '/app.php'))->toBeTrue() + ->and(is_file($generationDirectory . '/db.php'))->toBeTrue() + ->and(is_file($generationDirectory . '/.arraykit-flat.php'))->toBeTrue() + ->and(is_file($cache . '/app.php'))->toBeFalse() + ->and(is_file($cache . '/db.php'))->toBeFalse(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rejects unsupported compiled-cache values without replacing the last valid artifact', function () { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + + try { + $valid = new Config(); + $valid->loadArray(['app' => ['name' => 'stable']]); + expect($valid->exportCache($cache))->toBeTrue(); + + $invalid = new Config(); + $invalid->loadArray([ + 'app' => [ + 'value' => new class { + public string $name = 'unsupported'; + }, + ], + ]); + + expect(fn () => $invalid->exportCache($cache)) + ->toThrow(UnexpectedValueException::class); + + expect(include $cache)->toBe(['app' => ['name' => 'stable']]); + } finally { + if (is_file($cache)) { + unlink($cache); + } + } +}); + +it('switches generated cache roots without retaining cache-derived state', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cacheA = sys_get_temp_dir() . '/arraykit-batch-b-cache-a-' . bin2hex(random_bytes(5)); + $cacheB = sys_get_temp_dir() . '/arraykit-batch-b-cache-b-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cacheA, 0777, true); + mkdir($cacheB, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-a']], namespaceCacheDirectory: $cacheA)) + ->warmNamespaceCache('db'); + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-b']], namespaceCacheDirectory: $cacheB)) + ->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cacheA); + + expect($config->get('db.host'))->toBe('cache-a'); + + $config->namespaceCache($cacheB); + expect($config->get('db.host'))->toBe('cache-b'); + + $config->namespaceCache(null); + expect($config->get('db.host'))->toBe('source'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cacheA); + batchBRemoveDirectory($cacheB); + } +}); + +it('preserves runtime namespace mutations when generated cache roots change', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cacheA = sys_get_temp_dir() . '/arraykit-batch-b-cache-a-' . bin2hex(random_bytes(5)); + $cacheB = sys_get_temp_dir() . '/arraykit-batch-b-cache-b-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cacheA, 0777, true); + mkdir($cacheB, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'source']); + + try { + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-a']], namespaceCacheDirectory: $cacheA)) + ->warmNamespaceCache('db'); + (new LazyFileConfig($source, items: ['db' => ['host' => 'cache-b']], namespaceCacheDirectory: $cacheB)) + ->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cacheA); + $config->set('db.host', 'runtime'); + + $config->namespaceCache($cacheB); + + expect($config->get('db.host'))->toBe('runtime'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cacheA); + batchBRemoveDirectory($cacheB); + } +}); + +it('loads namespace structure coherently after an exact flat-index hit', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'localhost', 'options' => ['timeout' => 5]]); + + try { + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($config->get('db.host'))->toBe('localhost') + ->and($config->loaded('db'))->toBeFalse() + ->and($config->get('db'))->toBe([ + 'host' => 'localhost', + 'options' => ['timeout' => 5], + ]) + ->and($config->loaded('db'))->toBeTrue(); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('supports selective generated-cache flushes without disturbing other namespaces', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'app', ['name' => 'ArrayKit']); + batchBWriteConfig($source, 'db', ['host' => 'localhost']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['app', 'db'])->flushNamespaceCache('db'); + + unlink($source . '/app.php'); + unlink($source . '/db.php'); + + $fresh = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + expect($fresh->get('app.name'))->toBe('ArrayKit') + ->and($fresh->get('db.host', 'missing'))->toBe('missing'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('supports valid cache namespace names and alternate source extensions', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents($source . '/foo-bar.inc', " 'dash'];\n"); + file_put_contents($source . '/foo_bar.inc', " 'underscore'];\n"); + file_put_contents($source . '/__flat.inc', " 'flat-namespace'];\n"); + + try { + $config = new LazyFileConfig($source, 'inc', namespaceCacheDirectory: $cache); + $config->warmNamespaceCache(['foo-bar', 'foo_bar', '__flat']); + + $fresh = new LazyFileConfig($source, 'inc', namespaceCacheDirectory: $cache); + + expect($fresh->get('foo-bar.value'))->toBe('dash') + ->and($fresh->get('foo_bar.value'))->toBe('underscore') + ->and($fresh->get('__flat.value'))->toBe('flat-namespace'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('rebuilds environment-backed caches on the same warmer instance', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + + file_put_contents( + $source . '/db.php', + <<<'PHP' + Environment::ref('ARRAYKIT_BATCH_B_SAME_HOST', 'fallback')]; +PHP, + ); + + try { + $warmer = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + + $_ENV['ARRAYKIT_BATCH_B_SAME_HOST'] = 'first'; + $warmer->warmNamespaceCache('db'); + + $_ENV['ARRAYKIT_BATCH_B_SAME_HOST'] = 'second'; + $warmer->warmNamespaceCache('db'); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('second'); + } finally { + unset($_ENV['ARRAYKIT_BATCH_B_SAME_HOST']); + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('can republish a generated namespace in a cache-only deployment', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'cached']); + + try { + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + unlink($source . '/db.php'); + + (new LazyFileConfig($source, namespaceCacheDirectory: $cache))->warmNamespaceCache('db'); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('cached'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); + +it('round-trips supported compiled-cache values including nulls and enums', function () { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + + try { + $config = new Config(); + $config->loadArray([ + 'app' => [ + 'enabled' => true, + 'mode' => ConfigMode::Prod, + 'nullable' => null, + 'ports' => [80, 443], + ], + ]); + + expect($config->exportCache($cache))->toBeTrue() + ->and(include $cache)->toBe([ + 'app' => [ + 'enabled' => true, + 'mode' => ConfigMode::Prod, + 'nullable' => null, + 'ports' => [80, 443], + ], + ]); + } finally { + if (is_file($cache)) { + unlink($cache); + } + } +}); + +it('rejects resources named objects and cyclic compiled-cache graphs', function (string $case) { + $cache = sys_get_temp_dir() . '/arraykit-batch-b-export-' . bin2hex(random_bytes(5)) . '.php'; + $resource = null; + + try { + $value = match ($case) { + 'resource' => $resource = fopen('php://memory', 'r'), + 'object' => new stdClass(), + 'array-cycle' => (static function (): array { + $cycle = []; + $cycle['self'] = &$cycle; + + return $cycle; + })(), + 'closure-cycle' => (static function (): Closure { + $closure = null; + $closure = static function () use (&$closure): Closure { + return $closure; + }; + + return $closure; + })(), + }; + + $config = new Config(); + $config->loadArray(['value' => $value]); + + expect(fn () => $config->exportCache($cache)) + ->toThrow(UnexpectedValueException::class); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + if (is_file($cache)) { + unlink($cache); + } + } +})->with(['resource', 'object', 'array-cycle', 'closure-cycle']); + +it('keeps the previous generation active when namespace publication rejects a value', function () { + $source = sys_get_temp_dir() . '/arraykit-batch-b-source-' . bin2hex(random_bytes(5)); + $cache = sys_get_temp_dir() . '/arraykit-batch-b-cache-' . bin2hex(random_bytes(5)); + mkdir($source, 0777, true); + mkdir($cache, 0777, true); + batchBWriteConfig($source, 'db', ['host' => 'stable']); + + try { + $config = new LazyFileConfig($source, namespaceCacheDirectory: $cache); + $config->warmNamespaceCache('db'); + $config->set('db.invalid', new stdClass()); + + expect(fn () => $config->warmNamespaceCache('db')) + ->toThrow(UnexpectedValueException::class); + + expect((new LazyFileConfig($source, namespaceCacheDirectory: $cache))->get('db.host')) + ->toBe('stable'); + } finally { + batchBRemoveDirectory($source); + batchBRemoveDirectory($cache); + } +}); diff --git a/tests/Feature/Release530BatchCTest.php b/tests/Feature/Release530BatchCTest.php new file mode 100644 index 0000000..e6102a7 --- /dev/null +++ b/tests/Feature/Release530BatchCTest.php @@ -0,0 +1,189 @@ + ['value' => $candidate], + 'stored' => ['value' => $stored], + ]; + + expect(ArrayMulti::whereIn($rows, 'value', $values, true))->toBe([ + 'stored' => ['value' => $stored], + ])->and(ArrayMulti::whereNotIn($rows, 'value', $values, true))->toBe([ + 'candidate' => ['value' => $candidate], + ])->and(ArrayMulti::firstWhereIn($rows, 'value', $values, true))->toBe([ + 'value' => $stored, + ]); +})->with([255, 256, 257]); + +it('preserves strict membership for nested resources nan objects and scalar edge cases', function () { + $left = batchCClosedResource(); + $right = batchCClosedResource(); + $object = new stdClass(); + $otherObject = new stdClass(); + + $cases = [ + [['resource' => $right], ['resource' => $left], null], + [NAN, NAN, 0], + [$otherObject, $object, null], + [null, null, 'x'], + [false, 0, 'x'], + [0, false, 'x'], + ]; + + foreach ($cases as [$candidate, $storedValue, $filler]) { + $values = array_fill(0, 255, $filler); + $values[] = $storedValue; + $expected = in_array($candidate, $values, true); + $actual = ArrayMulti::whereIn([['value' => $candidate]], 'value', $values, true); + + expect($actual !== [])->toBe($expected); + } +}); + +it('keeps strict membership semantics through collection pipelines', function () { + $stored = batchCClosedResource(); + $candidate = batchCClosedResource(); + $values = range(1, 255); + $values[] = $stored; + + $collection = new Collection([ + ['value' => $candidate], + ['value' => $stored], + ]); + + expect($collection->process()->whereIn('value', $values, true)->all())->toBe([ + 1 => ['value' => $stored], + ]); +}); + +it('treats existing wildcard leaves as present regardless of leaf truthiness', function () { + foreach ([[], null, false, 0, '', 'value'] as $leaf) { + expect(DotNotation::matches( + ['rows' => [['value' => $leaf]]], + 'rows.*.value', + ))->toBeTrue(); + } + + expect(DotNotation::matches( + ['rows' => [['missing' => true]]], + 'rows.*.value', + ))->toBeFalse() + ->and(DotNotation::matches(['rows' => []], 'rows.*.value'))->toBeFalse(); +}); + +it('supports multiple wildcard presence and escaped literal wildcard keys', function () { + $data = [ + 'groups' => [ + [ + 'rows' => [ + ['value' => []], + ], + ], + ], + 'literal' => [ + '*' => ['value' => null], + ], + ]; + + expect(DotNotation::matches($data, 'groups.*.rows.*.value'))->toBeTrue() + ->and(DotNotation::matches($data, 'literal.\\*.value'))->toBeTrue(); +}); + +it('anchors SQL-like patterns at the true end of the value', function () { + $rows = [ + 'exact' => ['name' => 'admin'], + 'newline' => ['name' => "admin\n"], + 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], + 'case' => ['name' => 'ADMIN'], + 'empty' => ['name' => ''], + ]; + + expect(ArrayMulti::whereLike($rows, 'name', 'admin'))->toBe([ + 'exact' => ['name' => 'admin'], + 'case' => ['name' => 'ADMIN'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'admin', true))->toBe([ + 'exact' => ['name' => 'admin'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a%b', true))->toBe([ + 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a_b', true))->toBe([ + 'internal-newline' => ['name' => "a\nb"], + 'meta' => ['name' => 'a.b'], + ])->and(ArrayMulti::whereLike($rows, 'name', 'a.b', true))->toBe([ + 'meta' => ['name' => 'a.b'], + ])->and(ArrayMulti::whereLike($rows, 'name', '', true))->toBe([ + 'empty' => ['name' => ''], + ]); +}); + +it('surfaces PCRE execution failures instead of treating them as no match', function () { + $previousBacktrackLimit = ini_get('pcre.backtrack_limit'); + $previousJit = ini_get('pcre.jit'); + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '10'); + + try { + expect(fn () => ArrayMulti::whereLike( + [['name' => str_repeat('a', 100)]], + 'name', + '%a%a%a%aa%', + true, + ))->toThrow(RuntimeException::class); + } finally { + ini_set('pcre.backtrack_limit', (string) $previousBacktrackLimit); + ini_set('pcre.jit', (string) $previousJit); + } +}); + +it('keeps SQL-like matching equivalent through the collection pipeline', function () { + $collection = new Collection([ + ['name' => 'admin'], + ['name' => "admin\n"], + ]); + + expect($collection->process()->whereLike('name', 'admin')->all())->toBe([ + 0 => ['name' => 'admin'], + ]); +}); + +it('paginates valid huge inputs without integer overflow', function () { + $values = ['first' => 1, 'second' => 2, 'third' => 3]; + + expect(ArraySingle::paginate([], 1, 10))->toBe([]) + ->and(ArraySingle::paginate($values, 1, 2))->toBe([ + 'first' => 1, + 'second' => 2, + ])->and(ArraySingle::paginate($values, 2, 2))->toBe([ + 'third' => 3, + ])->and(ArraySingle::paginate($values, 3, 2))->toBe([]) + ->and(ArraySingle::paginate($values, PHP_INT_MAX, 2))->toBe([]) + ->and(ArraySingle::paginate($values, 1, PHP_INT_MAX))->toBe($values); +}); + +it('keeps overflow-safe pagination through the collection pipeline', function () { + $collection = new Collection(['first' => 1, 'second' => 2]); + + expect($collection->process()->paginate(PHP_INT_MAX, 2)->all())->toBe([]); +}); diff --git a/tests/Feature/Release530BatchDTest.php b/tests/Feature/Release530BatchDTest.php new file mode 100644 index 0000000..ca594ed --- /dev/null +++ b/tests/Feature/Release530BatchDTest.php @@ -0,0 +1,131 @@ + 1, 'second' => 2]; + $lazy = LazyCollection::from($source); + + expect($lazy->all())->toBe($source) + ->and($lazy->all())->toBe($source) + ->and($lazy->mapLazy(static fn(int $value): int => $value * 2)->all())->toBe([ + 'first' => 2, + 'second' => 4, + ]); +}); + +it('exports bounded DTO graphs while allowing shared acyclic objects', function () { + $child = new Release530BatchDAddress(); + $child->city = 'Dhaka'; + + $dto = new Release530BatchDUser(); + $dto->address = $child; + $dto->payload = [ + 'primary' => $child, + 'secondary' => $child, + ]; + + expect($dto->toArrayDeepGuarded())->toBe([ + 'address' => ['city' => 'Dhaka'], + 'payload' => [ + 'primary' => ['city' => 'Dhaka'], + 'secondary' => ['city' => 'Dhaka'], + ], + ]); +}); + +it('rejects self and mutual DTO cycles before deep export', function () { + $self = new Release530BatchDUser(); + $self->address = new Release530BatchDAddress(); + $self->payload = $self; + + expect(fn () => $self->toArrayDeepGuarded()) + ->toThrow(RuntimeException::class, 'cyclic object reference'); + + $left = new Release530BatchDUser(); + $left->address = new Release530BatchDAddress(); + $right = new Release530BatchDUser(); + $right->address = new Release530BatchDAddress(); + $left->payload = $right; + $right->payload = $left; + + expect(fn () => $left->toArrayDeepGuarded()) + ->toThrow(RuntimeException::class, 'cyclic object reference'); +}); + +it('enforces DTO graph depth and node budgets', function () { + $dto = new Release530BatchDUser(); + $dto->address = new Release530BatchDAddress(); + $dto->payload = [ + 'deep' => [ + 'deeper' => [ + 'value' => true, + ], + ], + ]; + + expect(fn () => $dto->toArrayDeepGuarded(maxDepth: 3)) + ->toThrow(RuntimeException::class, 'max depth') + ->and(fn () => $dto->toArrayDeepGuarded(maxNodes: 3)) + ->toThrow(RuntimeException::class, 'max node count'); +}); + +it('rejects cyclic hydration input before mutating the DTO', function () { + $cycle = []; + $cycle['self'] = &$cycle; + + $dto = new Release530BatchDUser(); + $dto->address = new Release530BatchDAddress(); + $dto->payload = 'before'; + + expect(fn () => $dto->hydrateNestedGuarded(['payload' => $cycle])) + ->toThrow(RuntimeException::class, 'cyclic array reference') + ->and($dto->payload)->toBe('before'); +}); + +it('hydrates typed nested DTOs through the guarded entry point', function () { + $dto = new Release530BatchDUser(); + + $dto->hydrateNestedGuarded([ + 'address' => ['city' => 'Dhaka'], + 'payload' => ['roles' => ['admin', 'editor']], + ]); + + expect($dto->address)->toBeInstanceOf(Release530BatchDAddress::class) + ->and($dto->address->city)->toBe('Dhaka') + ->and($dto->payload)->toBe(['roles' => ['admin', 'editor']]); +}); + +it('includes inherited public state in guarded deep export', function () { + $dto = new Release530BatchDInherited(); + + expect($dto->toArrayDeepGuarded())->toBe([ + 'name' => 'child', + 'base' => 'base', + ]); +}); + +it('preserves readonly hydration behavior when the trait owns the property scope', function () { + $dto = new Release530BatchDReadonly(); + $dto->hydrateNestedGuarded(['name' => 'fixed']); + + expect($dto->name)->toBe('fixed') + ->and($dto->toArrayDeepGuarded())->toBe(['name' => 'fixed']); +}); + +it('rejects invalid DTO graph limits explicitly', function () { + $dto = new Release530BatchDInherited(); + + expect(fn () => $dto->toArrayDeepGuarded(maxDepth: 0)) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => $dto->toArrayDeepGuarded(maxNodes: 0)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/Release530BatchETest.php b/tests/Feature/Release530BatchETest.php new file mode 100644 index 0000000..bbab168 --- /dev/null +++ b/tests/Feature/Release530BatchETest.php @@ -0,0 +1,345 @@ +withRunwire($runtime, $request, $scope, $checkpointEvery); +} + +it('keeps ordinary lazy collection usage independent from Runwire installation', function () { + $root = dirname(__DIR__, 2); + $source = addslashes($root . DIRECTORY_SEPARATOR . 'src'); + $code = <<<'PHP' +$source = '__SOURCE__'; +spl_autoload_register(static function (string $class) use ($source): void { + $prefix = 'Infocyph\\ArrayKit\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + + $relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix))); + $path = $source . DIRECTORY_SEPARATOR . $relative . '.php'; + if (is_file($path)) { + require $path; + } +}); + +$result = \Infocyph\ArrayKit\Collection\LazyCollection::from(['a' => 1, 'b' => 2])->all(); +exit($result === ['a' => 1, 'b' => 2] ? 0 : 1); +PHP; + $code = str_replace('__SOURCE__', $source, $code); + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + + expect(is_resource($process))->toBeTrue(); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + expect(proc_close($process))->toBe(0, $stdout . $stderr); +}); + +it('keeps installed but unbound lazy collections on the ordinary path', function () { + expect(LazyCollection::from(['a' => 1, 'b' => 2])->all())->toBe([ + 'a' => 1, + 'b' => 2, + ]); +}); + +it('allows metadata-only runtime binding without starting runtime work', function () { + $runtime = RuntimeContext::standalone(); + + expect(LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, checkpointEvery: 1) + ->all())->toBe([1, 2, 3]); +}); + +it('rejects completed or mismatched request contexts at the binding boundary', function () { + $runtime = batchERuntime(); + $otherRuntime = batchERuntime(); + $request = RequestContext::create($runtime); + + expect(fn () => LazyCollection::from([1])->withRunwire($otherRuntime, $request)) + ->toThrow(LogicException::class, 'different runtime context'); + + $request->complete(); + + expect(fn () => LazyCollection::from([1])->withRunwire($runtime, $request)) + ->toThrow(LogicException::class, 'Completed Runwire request context'); +}); + +it('validates the Runwire checkpoint interval once at binding', function () { + $runtime = batchERuntime(); + + expect(fn () => LazyCollection::from([1])->withRunwire($runtime, checkpointEvery: 0)) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => LazyCollection::from([1])->withRunwire($runtime, checkpointEvery: 1_000_001)) + ->toThrow(InvalidArgumentException::class); +}); + +it('raises pre-cancelled request state before source factory consumption', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $request->cancel(CancellationReason::HOST_CANCELLED); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1, 2, 3]; + }, + )->withRunwire($runtime, $request, checkpointEvery: 1); + + expect(fn () => $collection->all()) + ->toThrow(CancelledException::class) + ->and($factoryCalls)->toBe(0); +}); + +it('checks cancellation before advancing the source after a checkpoint boundary', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $consumed = 0; + $seen = []; + + $collection = LazyCollection::from((function () use (&$consumed) { + foreach ([1, 2, 3, 4] as $value) { + $consumed++; + yield $value; + } + })())->withRunwire($runtime, $request, checkpointEvery: 2); + + $cursor = $collection->cursor(); + + try { + foreach ($cursor as $value) { + $seen[] = $value; + if ($value === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + } + + test()->fail('Traversal unexpectedly completed after request cancellation.'); + } catch (CancelledException) { + expect($seen)->toBe([1, 2]) + ->and($consumed)->toBe(2); + } +}); + +it('checks filtered-out upstream rows and stops before the next callback', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $consumed = 0; + $callbacks = 0; + + $collection = LazyCollection::from((function () use (&$consumed) { + foreach ([1, 2, 3, 4] as $value) { + $consumed++; + yield $value; + } + })()) + ->withRunwire($runtime, $request, checkpointEvery: 2) + ->filterLazy(function (int $value) use (&$callbacks, $request): bool { + $callbacks++; + if ($value === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return false; + }); + + expect(fn () => $collection->all()) + ->toThrow(CancelledException::class) + ->and($callbacks)->toBe(2) + ->and($consumed)->toBe(2); +}); + +it('preserves and can explicitly rebind Runwire context through derived operations', function () { + $runtime = batchERuntime(); + $first = RequestContext::create($runtime); + $second = RequestContext::create($runtime); + + $derived = LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, $first, checkpointEvery: 1) + ->filterLazy(static fn(int $value): bool => $value > 1) + ->mapLazy(static fn(int $value): int => $value * 10); + + $first->cancel(CancellationReason::HOST_CANCELLED); + + expect(fn () => $derived->all())->toThrow(CancelledException::class) + ->and($derived->withRunwire($runtime, $second, checkpointEvery: 1)->all())->toBe([ + 1 => 20, + 2 => 30, + ]); +}); + +it('keeps take zero lazy even when a cancelled request is bound', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + $request->cancel(CancellationReason::HOST_CANCELLED); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1]; + }, + )->withRunwire($runtime, $request, checkpointEvery: 1); + + expect($collection->take(0)->all())->toBe([]) + ->and($factoryCalls)->toBe(0); +}); + +it('uses scope cancellation without requiring a request context', function () { + $runtime = batchERuntime(true); + $coroutines = new CoroutineRuntime(); + $turns = []; + + $result = $coroutines->run(function (CoroutineScope $scope) use ($runtime, &$turns): array { + $scope->spawn(function () use (&$turns): void { + $turns[] = 'sibling'; + }); + + return LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, scope: $scope, checkpointEvery: 1) + ->mapLazy(function (int $value) use (&$turns): int { + $turns[] = 'map-' . $value; + + return $value; + }) + ->all(); + }); + + expect($result)->toBe([1, 2, 3]) + ->and($turns[0] ?? null)->toBe('sibling'); +}); + +it('falls back without cooperative yielding when the runtime lacks the capability', function () { + $runtime = batchERuntime(false); + $coroutines = new CoroutineRuntime(); + + $result = $coroutines->run( + fn(CoroutineScope $scope): array => LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, scope: $scope, checkpointEvery: 1) + ->all(), + ); + + expect($result)->toBe([1, 2, 3]); +}); + +it('treats a closed active-scope binding as terminal before source consumption', function () { + $runtime = batchERuntime(true); + $coroutines = new CoroutineRuntime(); + $closedScope = $coroutines->run(static fn(CoroutineScope $scope): CoroutineScope => $scope); + $factoryCalls = 0; + + $collection = LazyCollection::fromFactory( + function () use (&$factoryCalls): array { + $factoryCalls++; + + return [1]; + }, + )->withRunwire($runtime, scope: $closedScope, checkpointEvery: 1); + + expect(fn () => $collection->all()) + ->toThrow(LogicException::class, 'Coroutine scope is already closed') + ->and($factoryCalls)->toBe(0); +}); + +it('honors request cancellation while an active coroutine scope is bound without completing host context', function () { + $runtime = batchERuntime(true); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + $state = new class { + public int $factoryCalls = 0; + }; + + expect(fn () => $coroutines->run( + function (CoroutineScope $scope) use ($runtime, $request, $state): array { + $scope->spawn(function () use ($request): void { + $request->cancel(CancellationReason::HOST_CANCELLED); + }); + + return LazyCollection::fromFactory( + function () use ($state): array { + $state->factoryCalls++; + + return [1, 2, 3]; + }, + ) + ->withRunwire($runtime, $request, $scope, checkpointEvery: 1) + ->all(); + }, + ))->toThrow(CancelledException::class); + + expect($state->factoryCalls)->toBe(0) + ->and($request->cancelled())->toBeTrue() + ->and($request->completed())->toBeFalse(); +}); +it('preserves source and callback exception identity under Runwire binding', function () { + $runtime = batchERuntime(); + + $source = LazyCollection::from((function () { + yield 1; + throw new DomainException('source'); + })())->withRunwire($runtime, checkpointEvery: 1); + + expect(fn () => $source->all())->toThrow(DomainException::class, 'source'); + + $callback = LazyCollection::from([1]) + ->withRunwire($runtime, checkpointEvery: 1) + ->mapLazy(static function (): never { + throw new DomainException('callback'); + }); + + expect(fn () => $callback->all())->toThrow(DomainException::class, 'callback'); +}); + +it('supports explicit intermediary forwarding of the same Runwire instances', function () { + $runtime = batchERuntime(); + $request = RequestContext::create($runtime); + + $bound = batchEForwardBinding( + LazyCollection::from(['a' => 1, 'b' => 2]), + $runtime, + $request, + checkpointEvery: 1, + ); + + expect($bound->all())->toBe(['a' => 1, 'b' => 2]) + ->and($request->completed())->toBeFalse(); +}); diff --git a/tests/Feature/Release530BatchFTest.php b/tests/Feature/Release530BatchFTest.php new file mode 100644 index 0000000..d3b9333 --- /dev/null +++ b/tests/Feature/Release530BatchFTest.php @@ -0,0 +1,133 @@ +resolve( + $driver, + $environment, + new RuntimeOptions(driver: $driver), + ); + + return RuntimeContext::fromCapabilities( + $capabilities, + mode: 'acceptance', + workerSlot: 0, + generation: 1, + ); +} + +it('keeps lazy traversal semantics across supported Runwire host capability sets', function (RuntimeDriver $driver) { + $runtime = batchFRuntime($driver); + $request = RequestContext::create($runtime); + + $result = LazyCollection::from([1, 2, 3]) + ->withRunwire($runtime, $request, checkpointEvery: 1) + ->mapLazy(static fn(int $value): int => $value * 2) + ->all(); + + expect($result)->toBe([2, 4, 6]) + ->and($request->completed())->toBeFalse(); + + $request->cancel(CancellationReason::HOST_CANCELLED); + + expect(fn () => LazyCollection::from([1]) + ->withRunwire($runtime, $request, checkpointEvery: 1) + ->all())->toThrow(CancelledException::class) + ->and($request->completed())->toBeFalse(); +})->with([ + RuntimeDriver::FPM, + RuntimeDriver::FRANKENPHP, + RuntimeDriver::ROADRUNNER, + RuntimeDriver::SWOOLE, +]); + +it('does not retain request-scoped config or cancellation state across repeated worker cycles', function () { + $runtime = batchFRuntime(RuntimeDriver::ROADRUNNER); + + for ($cycle = 0; $cycle < 250; $cycle++) { + $tenant = 'tenant-' . $cycle; + $config = new Config(); + $config->set('request.tenant', $tenant); + $config->snapshot(); + + expect($config->get('request.tenant'))->toBe($tenant); + + $config->set('request.tenant', 'mutated-' . $cycle); + expect($config->restore())->toBeTrue() + ->and($config->get('request.tenant'))->toBe($tenant); + + $request = RequestContext::create($runtime); + $collection = LazyCollection::fromFactory( + static fn(): array => [ + ['tenant' => $tenant, 'value' => 1], + ['tenant' => $tenant, 'value' => 2], + ['tenant' => $tenant, 'value' => 3], + ], + )->withRunwire($runtime, $request, checkpointEvery: 1); + + if (($cycle % 11) === 0) { + $seen = []; + + try { + $collection + ->mapLazy(function (array $row) use (&$seen, $request): array { + $seen[] = $row['tenant']; + if (count($seen) === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return $row; + }) + ->all(); + + test()->fail('Cancelled worker cycle unexpectedly completed.'); + } catch (CancelledException) { + expect($seen)->toBe([$tenant, $tenant]); + } + } else { + $rows = $collection->all(); + + expect($rows)->toHaveCount(3) + ->and(array_unique(array_column($rows, 'tenant')))->toBe([$tenant]); + } + + expect($request->completed())->toBeFalse(); + + unset($collection, $config, $request); + if (($cycle % 25) === 0) { + gc_collect_cycles(); + } + } + + $fresh = new Config(); + expect($fresh->get('request.tenant', 'missing'))->toBe('missing'); + + $freshRequest = RequestContext::create($runtime); + expect(LazyCollection::from(['fresh']) + ->withRunwire($runtime, $freshRequest, checkpointEvery: 1) + ->all())->toBe(['fresh']) + ->and($freshRequest->cancelled())->toBeFalse(); +}); diff --git a/tests/Feature/Release530ReviewRegressionTest.php b/tests/Feature/Release530ReviewRegressionTest.php new file mode 100644 index 0000000..17f2ac5 --- /dev/null +++ b/tests/Feature/Release530ReviewRegressionTest.php @@ -0,0 +1,261 @@ +reviewDirectory = sys_get_temp_dir() . '/arraykit-review-fix-' . bin2hex(random_bytes(6)); + mkdir($this->reviewDirectory); + mkdir($this->reviewDirectory . '/source'); + mkdir($this->reviewDirectory . '/cache'); +}); + +afterEach(function () { + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->reviewDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($entries as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($this->reviewDirectory); +}); + +function review530Source(string $directory, int $version): void +{ + foreach (['app', 'db'] as $namespace) { + file_put_contents($directory . '/source/' . $namespace . '.php', " {$version}];\n"); + } +} + +function review530Config(string $directory): LazyFileConfig +{ + return new LazyFileConfig($directory . '/source', namespaceCacheDirectory: $directory . '/cache'); +} + +it('bounds missing direct lookups and default callbacks under the shared safe budget', function () { + $keys = array_map(static fn(int $index): string => 'missing-' . $index, range(1, 10000)); + $calls = 0; + $default = function () use (&$calls): string { + $calls++; + + return 'default'; + }; + expect(DotNotation::getSafe([], $keys, $default, maxNodes: 2))->toHaveCount(2) + ->and($calls)->toBe(2); + $calls = 0; + expect(fn () => DotNotation::getSafe([], $keys, $default, maxNodes: 2, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and($calls)->toBe(2); +}); + +it('rejects unfinished throwing lookups and accepts an exactly complete budget', function () { + expect(fn () => DotNotation::getSafe(['a' => 1, 'b' => 2], ['a', 'b'], maxNodes: 1, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class) + ->and(DotNotation::getSafe(['a' => 1, 'b' => 2], ['a', 'b'], maxNodes: 2, throwOnTooDeep: true)) + ->toBe(['a' => 1, 'b' => 2]); +}); + +it('does not compare cyclic children after guarded sort traversal is cut short', function (int $maxDepth, int $maxNodes) { + $left = []; + $left['self'] = &$left; + $left['tail'] = 2; + $right = []; + $right['self'] = &$right; + $right['tail'] = 1; + $result = ArrayMulti::sortRecursiveGuarded([$left, $right], maxDepth: $maxDepth, maxNodes: $maxNodes); + expect($result)->toHaveCount(2) + ->and($result[0]['tail'])->toBe(2) + ->and($result[1]['tail'])->toBe(1); +})->with([[1, 100], [256, 2]]); + +it('leaves wide unvisited children in order rather than comparing them outside the sort budget', function () { + $left = array_fill(0, 100000, 1); + $right = $left; + $right[99999] = 2; + $result = ArrayMulti::sortRecursiveGuarded([$right, $left], maxDepth: 1, maxNodes: 2); + expect($result[0][99999])->toBe(2) + ->and($result[1][99999])->toBe(1) + ->and(ArrayMulti::sortRecursiveGuarded([[3, 1], [2, 0]], maxNodes: 6)) + ->toBe(ArrayMulti::sortRecursive([[3, 1], [2, 0]])); +}); + +it('rejects host request completion after binding before calling the source', function () { + $runtime = RuntimeContext::standalone(); + $request = RequestContext::create($runtime); + $calls = 0; + $collection = LazyCollection::fromFactory(function () use (&$calls): array { + $calls++; + + return [1, 2]; + })->withRunwire($runtime, $request, checkpointEvery: 1); + $request->complete(); + expect(fn () => $collection->all())->toThrow(LogicException::class) + ->and($collection->take(0)->all())->toBe([]) + ->and($calls)->toBe(0); +}); + +it('stops at a checkpoint when the host completes a request during iteration', function () { + $runtime = RuntimeContext::standalone(); + $request = RequestContext::create($runtime); + $seen = []; + $collection = LazyCollection::from([1, 2, 3])->withRunwire($runtime, $request, checkpointEvery: 1) + ->mapLazy(function (int $value) use ($request, &$seen): int { + $seen[] = $value; + $request->complete(); + + return $value; + }); + expect(fn () => $collection->all())->toThrow(LogicException::class) + ->and($seen)->toBe([1]); +}); + +it('rejects a closed scope even when coroutine capability is unavailable', function () { + $scope = new CoroutineRuntime()->run(static fn(CoroutineScope $scope): CoroutineScope => $scope); + expect(fn () => LazyCollection::from([1, 2])->withRunwire(RuntimeContext::standalone(), scope: $scope)->all()) + ->toThrow(LogicException::class, 'Coroutine scope is already closed'); +}); + +it('rechecks request completion after a cooperative yield before consuming the source', function () { + $runtime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities(RuntimeDriver::NATIVE, supportsRunwireCoroutines: true), + 'yield-check', + ); + $request = RequestContext::create($runtime); + $calls = 0; + new CoroutineRuntime()->run(function (CoroutineScope $scope) use ($runtime, $request, &$calls): void { + $scope->spawn(static fn() => $request->complete()); + $collection = LazyCollection::fromFactory(function () use (&$calls): array { + $calls++; + + return [1]; + })->withRunwire($runtime, $request, $scope, checkpointEvery: 1); + expect(fn () => $collection->all())->toThrow(LogicException::class, 'Completed Runwire request'); + }); + expect($calls)->toBe(0)->and($request->completed())->toBeTrue(); +}); + +it('keeps untouched generated namespaces refreshable after partial config merges', function (string $operation) { + review530Source($this->reviewDirectory, 1); + review530Config($this->reviewDirectory)->warmNamespaceCache(['app', 'db']); + $config = review530Config($this->reviewDirectory); + expect($config->get('app'))->toBe(['version' => 1]); + if ($operation === 'mergeEnvFile') { + $envFile = $this->reviewDirectory . '/partial.env'; + file_put_contents($envFile, "UNRELATED_REVIEW_VALUE=yes\n"); + $config->mergeEnvFile($envFile); + } else { + $config->$operation(['other' => ['value' => true]]); + } + review530Source($this->reviewDirectory, 2); + $config->warmNamespaceCache('app'); + expect(review530Config($this->reviewDirectory)->get('app.version'))->toBe(2); +})->with(['merge', 'overlay', 'mergeEnvFile']); + +it('preserves a deliberate namespace override while refreshing untouched cached namespaces', function () { + review530Source($this->reviewDirectory, 1); + review530Config($this->reviewDirectory)->warmNamespaceCache(['app', 'db']); + $config = review530Config($this->reviewDirectory); + $config->get('db'); + $config->merge(['app' => ['version' => 99]]); + review530Source($this->reviewDirectory, 2); + $config->warmNamespaceCache(['app', 'db']); + $fresh = review530Config($this->reviewDirectory); + expect($fresh->get('app.version'))->toBe(99)->and($fresh->get('db.version'))->toBe(2); +}); + +it('pins exact and structural reads to one generation until explicit cache refresh', function () { + review530Source($this->reviewDirectory, 1); + $writer = review530Config($this->reviewDirectory); + $writer->warmNamespaceCache(['app', 'db']); + $reader = review530Config($this->reviewDirectory); + expect($reader->get('app.version'))->toBe(1); + review530Source($this->reviewDirectory, 2); + $writer->warmNamespaceCache(['app', 'db']); + expect($reader->get('db'))->toBe(['version' => 1]) + ->and($reader->get('app.version'))->toBe(1) + ->and(review530Config($this->reviewDirectory)->get('db'))->toBe(['version' => 2]); + $reader->namespaceCache($this->reviewDirectory . '/cache'); + expect($reader->get('app.version'))->toBe(2) + ->and($reader->get('db'))->toBe(['version' => 2]); +}); + +it('copies the current published generation when a pinned reader later becomes a writer', function () { + review530Source($this->reviewDirectory, 1); + $writer = review530Config($this->reviewDirectory); + $writer->warmNamespaceCache(['app', 'db']); + $writer->get('app'); + review530Source($this->reviewDirectory, 2); + review530Config($this->reviewDirectory)->warmNamespaceCache('db'); + $writer->warmNamespaceCache('app'); + $fresh = review530Config($this->reviewDirectory); + expect($fresh->get('app.version'))->toBe(2)->and($fresh->get('db.version'))->toBe(2); +}); + +it('rejects custom DTO exporters before executing their unbounded callbacks', function () { + $dto = new class extends DTO { + public mixed $payload; + }; + $exporter = new class { + private int $calls = 0; + + public function calls(): int { return $this->calls; } + + public function toArray(): array { + $this->calls++; + + return range(1, 10000); + } + }; + $dto->payload = $exporter; + expect(fn () => $dto->toArrayDeepGuarded(maxNodes: 2)) + ->toThrow(InvalidArgumentException::class, 'custom exporters') + ->and($exporter->calls())->toBe(0) + ->and($dto->toArrayDeep()['payload'])->toHaveCount(10000); +}); + +it('exports DTO properties only once under the actual output budget', function () { + $dto = new class extends DTO { + public int $reads = 0; + + public array $payload { + get => ++$this->reads === 1 ? [1] : range(1, 10000); + } + }; + expect($dto->toArrayDeepGuarded(maxNodes: 4)['payload'])->toBe([1]) + ->and($dto->reads)->toBe(1); +}); + +it('preserves native named arguments for both facade reference mutations', function () { + $dot = ['remove' => true, 'keep' => true]; + $helper = $dot; + ArrayKit::dot()->forget(target: $dot, keys: 'remove'); + ArrayKit::helper()->forget(array: $helper, keys: 'remove'); + expect($dot)->toBe(['keep' => true])->and($helper)->toBe($dot) + ->and(fn () => ArrayKit::dot()->forget())->toThrow(ArgumentCountError::class); +}); + +it('memoizes consumed referenced scalar entries while keeping unread entries lazy', function () { + $first = 1; + $second = 2; + $lazy = LazyCollection::from(['first' => &$first, 'second' => &$second]); + $first = 3; + expect($lazy->take(1)->all())->toBe(['first' => 3]); + $first = 4; + $second = 5; + expect($lazy->all())->toBe(['first' => 3, 'second' => 5]); + $second = 6; + expect($lazy->all())->toBe(['first' => 3, 'second' => 5]); +}); diff --git a/tests/Fixtures/Release530BatchDAddress.php b/tests/Fixtures/Release530BatchDAddress.php new file mode 100644 index 0000000..fc49e46 --- /dev/null +++ b/tests/Fixtures/Release530BatchDAddress.php @@ -0,0 +1,14 @@ +sourceDirectory = sys_get_temp_dir() . '/arraykit-530-soak-source-' . $suffix; + $this->cacheDirectory = sys_get_temp_dir() . '/arraykit-530-soak-cache-' . $suffix; + + if (!mkdir($this->sourceDirectory, 0777, true) || !mkdir($this->cacheDirectory, 0777, true)) { + throw new RuntimeException('Unable to create ArrayKit worker-soak directories.'); + } + + $this->writeSource(1); + $this->runtime = $this->runtime(); + } + + public function run(): void + { + $this->installSignalHandlers(); + + try { + $cycle = 0; + + while ($this->running) { + $cycle++; + $this->runCycle($cycle); + + if (($cycle % 100) === 0) { + gc_collect_cycles(); + usleep(1_000); + } + } + } finally { + $this->removeDirectory($this->sourceDirectory); + $this->removeDirectory($this->cacheDirectory); + } + } + + private function installSignalHandlers(): void + { + if (!function_exists('pcntl_async_signals') || !function_exists('pcntl_signal')) { + return; + } + + pcntl_async_signals(true); + pcntl_signal(SIGTERM, function (): void { + $this->running = false; + }); + pcntl_signal(SIGINT, function (): void { + $this->running = false; + }); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + } elseif (is_file($path) || is_link($path)) { + if (function_exists('opcache_invalidate')) { + opcache_invalidate($path, true); + } + unlink($path); + clearstatcache(true, $path); + } + } + + rmdir($directory); + } + + private function runCycle(int $cycle): void + { + $tenant = 'tenant-' . $cycle; + + $config = new LayeredLazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + namespaces: ['app'], + ); + $config->set('app.tenant', $tenant); + $config->snapshot(); + $config->set('app.tenant', 'mutated-' . $cycle); + + if (!$config->restore() || $config->get('app.tenant') !== $tenant) { + throw new RuntimeException('Layered config state leaked during worker soak.'); + } + + // Cache publication belongs to deployment, not every few requests. + if (hrtime(true) >= $this->nextCacheRefreshNanoseconds) { + $this->refreshGeneratedCache($cycle); + $this->nextCacheRefreshNanoseconds = hrtime(true) + 20_000_000_000; + } + + $request = RequestContext::create($this->runtime); + $collection = LazyCollection::fromFactory( + static fn(): array => [ + ['tenant' => $tenant, 'value' => 1], + ['tenant' => $tenant, 'value' => 2], + ['tenant' => $tenant, 'value' => 3], + ], + )->withRunwire($this->runtime, $request, checkpointEvery: 1); + + if (($cycle % 13) === 0) { + try { + $collection + ->mapLazy(function (array $row) use ($request): array { + if ($row['value'] === 2) { + $request->cancel(CancellationReason::HOST_CANCELLED); + } + + return $row; + }) + ->all(); + + throw new RuntimeException('Cancelled worker cycle unexpectedly completed.'); + } catch (CancelledException) { + } + } else { + $rows = $collection->all(); + if (count($rows) !== 3 || array_unique(array_column($rows, 'tenant')) !== [$tenant]) { + throw new RuntimeException('Lazy collection state leaked across worker cycles.'); + } + } + + if ($request->completed()) { + throw new RuntimeException('ArrayKit completed a host-owned Runwire request.'); + } + + $request->complete(); + + unset($collection, $config, $request); + + if (($cycle % 100) === 0) { + $this->retireInactiveGenerations(); + } + } + + private function retireInactiveGenerations(): void + { + $pointer = $this->cacheDirectory . '/.arraykit-generation'; + if (!is_file($pointer)) { + return; + } + + $active = trim((string) file_get_contents($pointer)); + foreach (glob($this->cacheDirectory . '/.arraykit-gen-*', GLOB_ONLYDIR) ?: [] as $directory) { + if (basename($directory) !== $active) { + $this->removeDirectory($directory); + } + } + } + + private function refreshGeneratedCache(int $cycle): void + { + $version = intdiv($cycle, 50) + 1; + $this->writeSource($version); + + $config = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + $config->warmNamespaceCache('app'); + + $fresh = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + + if ($fresh->get('app.version') !== $version) { + throw new RuntimeException('Generated cache refresh returned stale data during worker soak.'); + } + + $this->cacheRefreshes++; + if (($this->cacheRefreshes % 2) !== 0) { + return; + } + + $config->set('app.unsupported', new class {}); + try { + $config->warmNamespaceCache('app'); + throw new RuntimeException('Unsupported generated cache payload was unexpectedly published.'); + } catch (Throwable $error) { + if ($error instanceof RuntimeException && $error->getMessage() === 'Unsupported generated cache payload was unexpectedly published.') { + throw $error; + } + } + + $afterFailure = new LazyFileConfig( + $this->sourceDirectory, + namespaceCacheDirectory: $this->cacheDirectory, + ); + + if ($afterFailure->get('app.version') !== $version) { + throw new RuntimeException('Failed cache rebuild replaced the last valid generation.'); + } + } + + private function runtime(): RuntimeContext + { + $environment = new RuntimeEnvironment( + sapi: 'cli', + hostedDrivers: [RuntimeDriver::ROADRUNNER], + availableDrivers: [RuntimeDriver::ROADRUNNER], + opcacheAvailable: true, + opcacheEnabled: true, + opcacheCliEnabled: true, + ); + + $capabilities = (new RuntimeCapabilityResolver())->resolve( + RuntimeDriver::ROADRUNNER, + $environment, + new RuntimeOptions(driver: RuntimeDriver::ROADRUNNER), + ); + + return RuntimeContext::fromCapabilities( + $capabilities, + mode: 'worker-soak', + workerSlot: 0, + generation: 1, + ); + } + + private function writeSource(int $version): void + { + $source = sprintf( + " 'source', 'version' => %d];\n", + $version, + ); + + if (file_put_contents($this->sourceDirectory . '/app.php', $source) === false) { + throw new RuntimeException('Unable to update worker-soak source config.'); + } + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($this->sourceDirectory . '/app.php', true); + } + } +} diff --git a/tests/Support/release530-persistent-worker.php b/tests/Support/release530-persistent-worker.php new file mode 100644 index 0000000..d406519 --- /dev/null +++ b/tests/Support/release530-persistent-worker.php @@ -0,0 +1,9 @@ +run();