├── src ├── HunterServiceProvider.php ├── helpers.php ├── Concerns │ └── EvaluatesClosures.php ├── HunterResult.php └── Hunter.php ├── rector.php ├── LICENSE.md ├── CHANGELOG.md ├── composer.json ├── README.md └── pint.json /src/HunterServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('hunter'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__ . '/src', 10 | ]) 11 | ->withPreparedSets( 12 | deadCode: true, 13 | codeQuality: true, 14 | typeDeclarations: true, 15 | privatization: true, 16 | earlyReturn: true, 17 | strictBooleans: true 18 | ) 19 | ->withSkip([ 20 | Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector::class, 21 | \Rector\Php55\Rector\String_\StringClassNameToClassConstantRector::class, 22 | ]) 23 | ->withPhpSets(); 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) e2tmk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `hunter` will be documented in this file. 4 | 5 | ## v1.2.3 - 2025-08-04 6 | 7 | ### What's Changed 8 | 9 | * feat(docs): add local search configuration by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/7 10 | * Update examples.md by @kayedspace in https://github.com/e2tmk/hunter/pull/8 11 | 12 | ### New Contributors 13 | 14 | * @kayedspace made their first contribution in https://github.com/e2tmk/hunter/pull/8 15 | 16 | **Full Changelog**: https://github.com/e2tmk/hunter/compare/v1.1.2...v1.2.3 17 | 18 | ## v1.1.2 - 2025-06-26 19 | 20 | ### What's Changed 21 | 22 | * docs: update docs with logo and style changes by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/4 23 | * docs: Update styling and restructure documentation by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/5 24 | * docs: add link to E2TMK in footer text by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/6 25 | 26 | **Full Changelog**: https://github.com/e2tmk/hunter/compare/v1.1.1...v1.1.2 27 | 28 | ## v1.1.1 - 2025-06-25 29 | 30 | ### What's Changed 31 | 32 | * Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot in https://github.com/e2tmk/hunter/pull/1 33 | * feat: Add console table display with Laravel Prompts by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/3 34 | 35 | ### New Contributors 36 | 37 | * @dependabot made their first contribution in https://github.com/e2tmk/hunter/pull/1 38 | 39 | **Full Changelog**: https://github.com/e2tmk/hunter/compare/v1.1.0...v1.1.1 40 | 41 | ## v1.1.0 - 2025-06-25 42 | 43 | ### What's Changed 44 | 45 | * feat: add helper function for Hunter instances by @andrefelipe18 in https://github.com/e2tmk/hunter/pull/2 46 | 47 | ### New Contributors 48 | 49 | * @andrefelipe18 made their first contribution in https://github.com/e2tmk/hunter/pull/2 50 | 51 | **Full Changelog**: https://github.com/e2tmk/hunter/compare/v1.0.0...v1.1.0 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2tmk/hunter", 3 | "description": "Powerful utility for finding and processing Eloquent model records with a fluent, chainable API.", 4 | "keywords": [ 5 | "e2tmk", 6 | "laravel", 7 | "hunter" 8 | ], 9 | "homepage": "https://github.com/e2tmk/hunter", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "André Domingues", 14 | "email": "dominguesstroppa@gmail.com", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "Elias Olivtradet", 19 | "email": "deoliveira.elias.eus@gmail.com", 20 | "role": "Owner" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.2", 25 | "illuminate/contracts": "^10.0||^11.0||^12.0", 26 | "laravel/prompts": "^0.3.5", 27 | "spatie/laravel-package-tools": "^1.16" 28 | }, 29 | "require-dev": { 30 | "laravel/pint": "^1.14", 31 | "nunomaduro/collision": "^8.1.1||^7.10.0", 32 | "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", 33 | "pestphp/pest": "^3.0", 34 | "pestphp/pest-plugin-arch": "^3.0", 35 | "pestphp/pest-plugin-laravel": "^3.0", 36 | "rector/rector": "*" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Hunter\\": "src/" 41 | }, 42 | "files": [ 43 | "src/helpers.php" 44 | ] 45 | }, 46 | "scripts": { 47 | "format": "vendor/bin/pint" 48 | }, 49 | "config": { 50 | "sort-packages": true, 51 | "allow-plugins": { 52 | "pestphp/pest-plugin": true, 53 | "phpstan/extension-installer": true 54 | } 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Hunter\\HunterServiceProvider" 60 | ] 61 | } 62 | }, 63 | "minimum-stability": "dev", 64 | "prefer-stable": true 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Hunter Logo 3 |

4 | 5 | # Hunter 6 | 7 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/e2tmk/hunter.svg?style=flat-square)](https://packagist.org/packages/e2tmk/hunter) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/e2tmk/hunter.svg?style=flat-square)](https://packagist.org/packages/e2tmk/hunter) 9 | [![License](https://img.shields.io/packagist/l/e2tmk/skeleton-module.svg?style=flat-square)](https://packagist.org/packages/e2tmk/skeleton-module) 10 | 11 | Powerful utility for finding and processing Eloquent model records with a fluent, chainable API. 12 | 13 | Hunter provides a clean way to search for records based on specific criteria and execute multiple actions on them with comprehensive error handling, logging, and advanced flow control. 14 | 15 | ## Installation 16 | 17 | You can install the package via Composer: 18 | 19 | ```bash 20 | composer require e2tmk/hunter 21 | ``` 22 | 23 | ## Quick Example 24 | 25 | ```php 26 | use Hunter\Hunter; 27 | 28 | // Process all pending orders 29 | $result = hunter(Order::class) 30 | ->find('status', 'pending') 31 | ->then(function (Order $order) { 32 | $order->process(); 33 | }) 34 | ->hunt(); 35 | 36 | echo $result->summary(); // "Total: 15, Successful: 14, Failed: 1, Skipped: 0" 37 | ``` 38 | 39 | ## Features 40 | 41 | - 🔍 **Powerful Search**: Find records with flexible criteria 42 | - 🔗 **Fluent API**: Chainable methods for clean code 43 | - 🎯 **Multiple Actions**: Execute several actions per record 44 | - ⚡ **Flow Control**: Skip, fail, or stop processing gracefully 45 | - 📊 **Comprehensive Reporting**: Detailed statistics and error tracking 46 | 47 | ## Documentation 48 | 49 | 📖 **[Complete Documentation](https://hunter.e2tmk.com)** 50 | 51 | - [Getting Started](https://hunter.e2tmk.com) 52 | - [Usage Examples](https://hunter.e2tmk.com/examples) 53 | - [API Reference](https://hunter.e2tmk.com/api) 54 | 55 | ## License 56 | 57 | The project is licensed under the [MIT License](LICENSE.md). 58 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12", 3 | "exclude": [ 4 | "src/helpers.php" 5 | ], 6 | "rules": { 7 | "not_operator_with_successor_space": true, 8 | "no_blank_lines_after_phpdoc": true, 9 | "group_import": false, 10 | "declare_strict_types": true, 11 | "fully_qualified_strict_types": true, 12 | "single_import_per_statement": true, 13 | "types_spaces": { 14 | "space": "single", 15 | "space_multiple_catch": "single" 16 | }, 17 | "no_unused_imports": true, 18 | "array_indentation": true, 19 | "statement_indentation": true, 20 | "method_chaining_indentation": true, 21 | "array_syntax": { 22 | "syntax": "short" 23 | }, 24 | "binary_operator_spaces": { 25 | "default": "single_space", 26 | "operators": { 27 | "=": "align_single_space_minimal", 28 | "=>": "align_single_space_minimal" 29 | } 30 | }, 31 | "braces": { 32 | "allow_single_line_anonymous_class_with_empty_body": false, 33 | "allow_single_line_closure": true, 34 | "position_after_functions_and_oop_constructs": "next", 35 | "position_after_control_structures": "same" 36 | }, 37 | "curly_braces_position": { 38 | "classes_opening_brace": "next_line_unless_newline_at_signature_end", 39 | "anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end", 40 | "anonymous_functions_opening_brace": "same_line" 41 | }, 42 | "blank_line_after_namespace": true, 43 | "blank_line_after_opening_tag": true, 44 | "class_attributes_separation": { 45 | "elements": { 46 | "property": "one", 47 | "method": "one" 48 | } 49 | }, 50 | "concat_space": { 51 | "spacing": "one" 52 | }, 53 | "declare_equal_normalize": { 54 | "space": "single" 55 | }, 56 | "elseif": false, 57 | "encoding": true, 58 | "indentation_type": true, 59 | "no_useless_else": false, 60 | "no_useless_return": true, 61 | "ordered_imports": true, 62 | "ternary_operator_spaces": true, 63 | "no_extra_blank_lines": true, 64 | "no_multiline_whitespace_around_double_arrow": true, 65 | "multiline_whitespace_before_semicolons": true, 66 | "no_singleline_whitespace_before_semicolons": true, 67 | "no_spaces_around_offset": true, 68 | "ternary_to_null_coalescing": true, 69 | "whitespace_after_comma_in_array": true, 70 | "trim_array_spaces": true, 71 | "trailing_comma_in_multiline": true, 72 | "unary_operator_spaces": true, 73 | "blank_line_before_statement": { 74 | "statements": [ 75 | "break", 76 | "continue", 77 | "declare", 78 | "return", 79 | "throw", 80 | "try", 81 | "continue", 82 | "do", 83 | "exit", 84 | "for", 85 | "foreach", 86 | "if", 87 | "include", 88 | "include_once", 89 | "require", 90 | "require_once" 91 | ] 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Concerns/EvaluatesClosures.php: -------------------------------------------------------------------------------- 1 | $namedInjections 25 | * @param array $typedInjections 26 | * @return T 27 | */ 28 | public function evaluate(mixed $value, array $namedInjections = [], array $typedInjections = []): mixed 29 | { 30 | if (! $value instanceof Closure) { 31 | return $value; 32 | } 33 | 34 | $dependencies = []; 35 | 36 | foreach ((new ReflectionFunction($value))->getParameters() as $parameter) { 37 | $dependencies[] = $this->resolveClosureDependencyForEvaluation($parameter, $namedInjections, $typedInjections); 38 | } 39 | 40 | return $value(...$dependencies); 41 | } 42 | 43 | /** 44 | * @param array $namedInjections 45 | * @param array $typedInjections 46 | */ 47 | protected function resolveClosureDependencyForEvaluation(ReflectionParameter $parameter, array $namedInjections, array $typedInjections): mixed 48 | { 49 | $parameterName = $parameter->getName(); 50 | 51 | if (array_key_exists($parameterName, $namedInjections)) { 52 | return value($namedInjections[$parameterName]); 53 | } 54 | 55 | $typedParameterClassName = $this->getTypedReflectionParameterClassName($parameter); 56 | 57 | if (filled($typedParameterClassName) && array_key_exists($typedParameterClassName, $typedInjections)) { 58 | return value($typedInjections[$typedParameterClassName]); 59 | } 60 | 61 | // Dependencies are wrapped in an array to differentiate between null and no value. 62 | $defaultWrappedDependencyByName = $this->resolveDefaultClosureDependencyForEvaluationByName($parameterName); 63 | 64 | if (count($defaultWrappedDependencyByName) > 0) { 65 | // Unwrap the dependency if it was resolved. 66 | return $defaultWrappedDependencyByName[0]; 67 | } 68 | 69 | if (filled($typedParameterClassName)) { 70 | // Dependencies are wrapped in an array to differentiate between null and no value. 71 | $defaultWrappedDependencyByType = $this->resolveDefaultClosureDependencyForEvaluationByType($typedParameterClassName); 72 | 73 | if (count($defaultWrappedDependencyByType) > 0) { 74 | // Unwrap the dependency if it was resolved. 75 | return $defaultWrappedDependencyByType[0]; 76 | } 77 | } 78 | 79 | if ( 80 | ( 81 | isset($this->evaluationIdentifier) && 82 | ($parameterName === $this->evaluationIdentifier) 83 | ) || 84 | ($typedParameterClassName === static::class) 85 | ) { 86 | return $this; 87 | } 88 | 89 | if (filled($typedParameterClassName)) { 90 | return app()->make($typedParameterClassName); 91 | } 92 | 93 | if ($parameter->isDefaultValueAvailable()) { 94 | return $parameter->getDefaultValue(); 95 | } 96 | 97 | if ($parameter->isOptional()) { 98 | return null; 99 | } 100 | 101 | $staticClass = static::class; 102 | 103 | throw new BindingResolutionException("An attempt was made to evaluate a closure for [{$staticClass}], but [\${$parameterName}] was unresolvable."); 104 | } 105 | 106 | /** 107 | * @return array 108 | */ 109 | protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array 110 | { 111 | return []; 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | protected function resolveDefaultClosureDependencyForEvaluationByType(string $parameterType): array 118 | { 119 | return []; 120 | } 121 | 122 | protected function getTypedReflectionParameterClassName(ReflectionParameter $parameter): ?string 123 | { 124 | $type = $parameter->getType(); 125 | 126 | if (! $type instanceof ReflectionNamedType) { 127 | return null; 128 | } 129 | 130 | if ($type->isBuiltin()) { 131 | return null; 132 | } 133 | 134 | $name = $type->getName(); 135 | 136 | $class = $parameter->getDeclaringClass(); 137 | 138 | if (blank($class)) { 139 | return $name; 140 | } 141 | 142 | if ($name === 'self') { 143 | return $class->getName(); 144 | } 145 | 146 | if ($name === 'parent' && ($parent = $class->getParentClass())) { 147 | return $parent->getName(); 148 | } 149 | 150 | return $name; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/HunterResult.php: -------------------------------------------------------------------------------- 1 | failed > 0; 32 | } 33 | 34 | public function hasSkipped(): bool 35 | { 36 | return $this->skipped > 0; 37 | } 38 | 39 | public function wasStopped(): bool 40 | { 41 | return $this->stopReason !== null; 42 | } 43 | 44 | public function getStopReason(): ?string 45 | { 46 | return $this->stopReason; 47 | } 48 | 49 | public function getSkipReason(string $recordId): ?string 50 | { 51 | return $this->skipReasons[$recordId] ?? null; 52 | } 53 | 54 | public function getSkipReasons(): array 55 | { 56 | return $this->skipReasons; 57 | } 58 | 59 | public function hasSkipReasons(): bool 60 | { 61 | return $this->skipReasons !== []; 62 | } 63 | 64 | public function getSuccessRate(): float 65 | { 66 | if ($this->total === 0) { 67 | return 0.0; 68 | } 69 | 70 | return ($this->successful / $this->total) * 100; 71 | } 72 | 73 | public function getProcessedCount(): int 74 | { 75 | return $this->successful + $this->failed; 76 | } 77 | 78 | public function summary(): string 79 | { 80 | $summary = "Total: {$this->total}, Successful: {$this->successful}, Failed: {$this->failed}, Skipped: {$this->skipped}"; 81 | 82 | if ($this->wasStopped()) { 83 | $summary .= " (Stopped: {$this->stopReason})"; 84 | } 85 | 86 | return $summary; 87 | } 88 | 89 | public function getDetailedSummary(): array 90 | { 91 | return [ 92 | 'total' => $this->total, 93 | 'successful' => $this->successful, 94 | 'failed' => $this->failed, 95 | 'skipped' => $this->skipped, 96 | 'errors' => $this->errors, 97 | 'skipped_records' => $this->skippedRecords, 98 | 'skip_reasons' => $this->skipReasons, 99 | 'stop_reason' => $this->stopReason, 100 | 'success_rate' => $this->getSuccessRate(), 101 | 'processed_count' => $this->getProcessedCount(), 102 | 'execution_time' => $this->executionTime, 103 | 'memory_usage' => $this->memoryUsage, 104 | ]; 105 | } 106 | 107 | public function getExecutionTime(): float 108 | { 109 | return $this->executionTime; 110 | } 111 | 112 | public function getMemoryUsage(): int 113 | { 114 | return $this->memoryUsage; 115 | } 116 | 117 | public function getFormattedMemoryUsage(): string 118 | { 119 | $bytes = $this->memoryUsage; 120 | $units = ['B', 'KB', 'MB', 'GB']; 121 | 122 | for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { 123 | $bytes /= 1024; 124 | } 125 | 126 | return round($bytes, 2) . ' ' . $units[$i]; 127 | } 128 | 129 | public function consoleTable(): void 130 | { 131 | if (! function_exists('\Laravel\Prompts\table')) { 132 | echo $this->summary() . "\n"; 133 | 134 | return; 135 | } 136 | 137 | $headers = ['Metric', 'Value', 'Details']; 138 | $rows = [ 139 | ['Total Records', number_format($this->total), ''], 140 | ['Successful', number_format($this->successful), $this->total > 0 ? sprintf('%.1f%%', $this->getSuccessRate()) : ''], 141 | ['Failed', number_format($this->failed), $this->failed > 0 ? sprintf('%.1f%%', ($this->failed / $this->total) * 100) : ''], 142 | ['Skipped', number_format($this->skipped), $this->skipped > 0 ? sprintf('%.1f%%', ($this->skipped / $this->total) * 100) : ''], 143 | ]; 144 | 145 | // Add execution metrics if available 146 | if ($this->executionTime > 0) { 147 | $rows[] = ['Execution Time', sprintf('%.2fs', $this->executionTime), '']; 148 | } 149 | 150 | if ($this->memoryUsage > 0) { 151 | $rows[] = ['Memory Usage', $this->getFormattedMemoryUsage(), '']; 152 | } 153 | 154 | $rows[] = $this->wasStopped() ? ['Status', 'Stopped', $this->stopReason] : ['Status', 'Completed', '']; 155 | 156 | if ($this->executionTime > 0 && $this->total > 0) { 157 | $rate = $this->total / $this->executionTime; 158 | $rows[] = ['Processing Rate', sprintf('%.1f records/sec', $rate), '']; 159 | } 160 | 161 | \Laravel\Prompts\table( 162 | headers: $headers, 163 | rows: $rows 164 | ); 165 | 166 | if ($this->hasErrors()) { 167 | echo "\n"; 168 | \Laravel\Prompts\warning("⚠️ {$this->failed} record(s) failed to process. Check logs for details."); 169 | } 170 | 171 | if ($this->hasSkipped()) { 172 | echo "\n"; 173 | \Laravel\Prompts\info("ℹ️ {$this->skipped} record(s) were skipped."); 174 | 175 | if ($this->hasSkipReasons()) { 176 | echo "\nSkip reasons:\n"; 177 | 178 | foreach (array_count_values($this->skipReasons) as $reason => $count) { 179 | echo " • {$reason}: {$count} record(s)\n"; 180 | } 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Hunter.php: -------------------------------------------------------------------------------- 1 | queryModifiers = collect(); 64 | $this->individualActions = collect(); 65 | $this->beforeThenCallbacks = collect(); 66 | $this->afterThenCallbacks = collect(); 67 | $this->successCallbacks = collect(); 68 | $this->errorCallbacks = collect(); 69 | } 70 | 71 | public static function for(string $modelClass): self 72 | { 73 | if (! class_exists($modelClass)) { 74 | throw new \InvalidArgumentException("Model class '{$modelClass}' does not exist"); 75 | } 76 | 77 | if (! is_subclass_of($modelClass, Model::class)) { 78 | throw new \InvalidArgumentException("Class '{$modelClass}' must extend Illuminate\Database\Eloquent\Model"); 79 | } 80 | 81 | $instance = new self(); 82 | $instance->modelClass = $modelClass; 83 | 84 | return $instance; 85 | } 86 | 87 | public function find(string $column, mixed $operator = null, mixed $value = null): self 88 | { 89 | $this->column = $column; 90 | 91 | if (blank($value) && filled($operator)) { 92 | $this->value = $operator; 93 | 94 | return $this; 95 | } 96 | 97 | $this->operator = $operator ?? '='; 98 | $this->value = $value; 99 | 100 | return $this; 101 | } 102 | 103 | public function modifyQueryUsing(Closure $callback): self 104 | { 105 | $this->queryModifiers->push($callback); 106 | 107 | return $this; 108 | } 109 | 110 | public function then(Closure $callback): self 111 | { 112 | $this->individualActions->push($callback); 113 | 114 | return $this; 115 | } 116 | 117 | public function thenIf(Closure | bool $condition, Closure $callback, array $context = []): self 118 | { 119 | if ($condition instanceof Closure) { 120 | $condition = $this->evaluate($condition, array_merge([ 121 | 'hunter' => $this, 122 | 'model' => $this->currentRecord, 123 | 'record' => $this->currentRecord, 124 | ], $context)); 125 | } 126 | 127 | if (! $condition) { 128 | return $this; 129 | } 130 | 131 | $this->individualActions->push($callback); 132 | 133 | return $this; 134 | } 135 | 136 | public function onBeforeThen(Closure $callback): self 137 | { 138 | $this->beforeThenCallbacks->push($callback); 139 | 140 | return $this; 141 | } 142 | 143 | public function onAfterThen(Closure $callback): self 144 | { 145 | $this->afterThenCallbacks->push($callback); 146 | 147 | return $this; 148 | } 149 | 150 | public function onSuccess(Closure $callback): self 151 | { 152 | $this->successCallbacks->push($callback); 153 | 154 | return $this; 155 | } 156 | 157 | public function onError(Closure $callback): self 158 | { 159 | $this->errorCallbacks->push($callback); 160 | 161 | return $this; 162 | } 163 | 164 | public function onProgress(Closure $callback): self 165 | { 166 | $this->progressCallback = $callback; 167 | 168 | return $this; 169 | } 170 | 171 | public function withLogging(string $context = 'hunter'): self 172 | { 173 | $this->logErrors = true; 174 | $this->logContext = $context; 175 | 176 | return $this; 177 | } 178 | 179 | public function withoutLogging(): self 180 | { 181 | $this->logErrors = false; 182 | 183 | return $this; 184 | } 185 | 186 | public function chunk(int $size): self 187 | { 188 | if ($size <= 0) { 189 | throw new \InvalidArgumentException('Chunk size must be greater than zero'); 190 | } 191 | 192 | $this->chunk = $size; 193 | 194 | return $this; 195 | } 196 | 197 | public function skip(?Model $record = null, ?string $reason = null): self 198 | { 199 | $this->shouldSkipCurrentRecord = true; 200 | $this->skipReason = $reason; 201 | 202 | if ($record && $this->currentResult) { 203 | $this->currentResult->skipped++; 204 | $recordId = $this->getRecordIdentifier($record); 205 | $this->currentResult->skippedRecords[] = $recordId; 206 | 207 | if (filled($reason)) { 208 | $this->currentResult->skipReasons[$recordId] = $reason; 209 | } 210 | } 211 | 212 | return $this; 213 | } 214 | 215 | public function stop(?string $reason = null): self 216 | { 217 | $this->shouldStopProcessing = true; 218 | $this->stopReason = $reason; 219 | 220 | if ($this->currentResult && $reason) { 221 | $this->currentResult->stopReason = $reason; 222 | } 223 | 224 | return $this; 225 | } 226 | 227 | public function fail(Model $record, string $reason): self 228 | { 229 | if (filled($this->currentResult) && $this->currentResult instanceof HunterResult) { 230 | $this->currentResult->failed++; 231 | $this->currentResult->errors[$this->getRecordIdentifier($record)] = $reason; 232 | } 233 | 234 | $this->shouldSkipCurrentRecord = true; 235 | 236 | return $this; 237 | } 238 | 239 | public function getCurrentRecord(): ?Model 240 | { 241 | return $this->currentRecord; 242 | } 243 | 244 | public function getResult(): ?HunterResult 245 | { 246 | return $this->currentResult; 247 | } 248 | 249 | public function getSkipReason(): ?string 250 | { 251 | return $this->skipReason; 252 | } 253 | 254 | public function getStopReason(): ?string 255 | { 256 | return $this->stopReason; 257 | } 258 | 259 | public function dryRun(bool $enabled = true): self 260 | { 261 | $this->dryRun = $enabled; 262 | 263 | return $this; 264 | } 265 | 266 | public function hunt(): HunterResult 267 | { 268 | $startTime = microtime(true); 269 | $startMemory = memory_get_usage(true); 270 | 271 | $query = $this->buildQuery(); 272 | $result = new HunterResult(); 273 | $result->total = $query->count(); 274 | $result->skipped = 0; 275 | $result->skippedRecords = []; 276 | $result->skipReasons = []; 277 | 278 | $this->currentResult = $result; 279 | 280 | if ($result->total === 0) { 281 | $result->executionTime = microtime(true) - $startTime; 282 | $result->memoryUsage = memory_get_usage(true) - $startMemory; 283 | 284 | return $result; 285 | } 286 | 287 | $query->chunk($this->chunk, function ($records) use ($result): void { 288 | $records->each(function ($record) use ($result): void { 289 | // Reset flow control for each record 290 | $this->shouldSkipCurrentRecord = false; 291 | $this->skipReason = null; 292 | $this->currentRecord = $record; 293 | 294 | // Check if we should stop processing entirely 295 | if ($this->shouldStopProcessing) { 296 | $result->skipped++; 297 | $recordId = $this->getRecordIdentifier($record); 298 | $result->skippedRecords[] = $recordId; 299 | 300 | if (filled($this->stopReason)) { 301 | $result->skipReasons[$recordId] = "Stopped: {$this->stopReason}"; 302 | } 303 | 304 | return; 305 | } 306 | 307 | try { 308 | // Hook: Before Then 309 | $this->executeCallbacks($this->beforeThenCallbacks, $record); 310 | 311 | // Check if the record was skipped in onBeforeThen 312 | if ($this->shouldSkipCurrentRecord) { 313 | $this->handleSkipReason($record, $result); 314 | 315 | return; 316 | } 317 | 318 | // Check if we're in dry run mode 319 | if ($this->dryRun) { 320 | $result->successful++; 321 | $this->reportProgress($result); 322 | 323 | return; 324 | } 325 | 326 | // Main actions 327 | $this->executeCallbacks($this->individualActions, $record); 328 | 329 | // Check if the record was skipped in then 330 | if ($this->shouldSkipCurrentRecord) { 331 | $this->handleSkipReason($record, $result); 332 | 333 | return; 334 | } 335 | 336 | // Hook: After Then 337 | $this->executeCallbacks($this->afterThenCallbacks, $record); 338 | 339 | // Check if the record was skipped in onAfterThen 340 | if ($this->shouldSkipCurrentRecord) { 341 | $this->handleSkipReason($record, $result); 342 | 343 | return; 344 | } 345 | 346 | $result->successful++; 347 | 348 | // Success callbacks 349 | $this->executeCallbacks($this->successCallbacks, $record); 350 | 351 | // Report progress 352 | $this->reportProgress($result); 353 | } catch (Throwable $e) { 354 | // Skip if the record was already marked as failed via fail() method 355 | if ($this->shouldSkipCurrentRecord) { 356 | return; 357 | } 358 | 359 | $result->failed++; 360 | $result->errors[$this->getRecordIdentifier($record)] = $e->getMessage(); 361 | 362 | if ($this->logErrors) { 363 | Log::error("Hunter error in {$this->logContext}", [ 364 | 'model' => $record::class, 365 | 'id' => $record->getKey(), 366 | 'error' => $e->getMessage(), 367 | 'trace' => $e->getTraceAsString(), 368 | ]); 369 | } 370 | 371 | // Error callbacks 372 | $this->executeErrorCallbacks($this->errorCallbacks, $record, $e); 373 | } 374 | }); 375 | }); 376 | 377 | $this->currentRecord = null; 378 | $this->currentResult = null; 379 | 380 | $result->executionTime = microtime(true) - $startTime; 381 | $result->memoryUsage = memory_get_usage(true) - $startMemory; 382 | 383 | return $result; 384 | } 385 | 386 | protected function handleSkipReason(Model $record, HunterResult $result): void 387 | { 388 | if (blank($this->skipReason)) { 389 | return; 390 | } 391 | 392 | $recordId = $this->getRecordIdentifier($record); 393 | $result->skipReasons[$recordId] = $this->skipReason; 394 | } 395 | 396 | protected function executeCallbacks(Collection $callbacks, Model $record): void 397 | { 398 | $callbacks->each(function ($callback) use ($record): void { 399 | if ($this->shouldSkipCurrentRecord || $this->shouldStopProcessing) { 400 | return; 401 | } 402 | 403 | $this->evaluate($callback, [ 404 | 'record' => $record, 405 | 'hunter' => $this, 406 | 'model' => $record, 407 | $this->getModelParameterName() => $record, 408 | ]); 409 | }); 410 | } 411 | 412 | protected function executeErrorCallbacks(Collection $callbacks, Model $record, Throwable $exception): void 413 | { 414 | $callbacks->each(function ($callback) use ($record, $exception): void { 415 | if ($this->shouldSkipCurrentRecord || $this->shouldStopProcessing) { 416 | return; 417 | } 418 | 419 | $this->evaluate($callback, [ 420 | 'record' => $record, 421 | 'hunter' => $this, 422 | 'model' => $record, 423 | 'exception' => $exception, 424 | 'error' => $exception, 425 | 'e' => $exception, 426 | $this->getModelParameterName() => $record, 427 | ]); 428 | }); 429 | } 430 | 431 | protected function buildQuery(): Builder 432 | { 433 | /** @var Model $model */ 434 | $model = $this->modelClass; 435 | 436 | $query = $model::query() 437 | ->where($this->column, $this->operator, $this->value); 438 | 439 | $this->queryModifiers->each(function ($callback) use (&$query): void { 440 | $result = $this->evaluate($callback, [ 441 | 'query' => $query, 442 | 'builder' => $query, 443 | ]); 444 | 445 | if (! $result instanceof Builder) { 446 | throw new \InvalidArgumentException('modifyQueryUsing callback must return an instance of Illuminate\Database\Eloquent\Builder'); 447 | } 448 | 449 | $query = $result; 450 | }); 451 | 452 | return $query; 453 | } 454 | 455 | protected function getRecordIdentifier(Model $record): string 456 | { 457 | $class = class_basename($record); 458 | 459 | return strtolower($class) . '_' . $record->getKey(); 460 | } 461 | 462 | protected function getModelParameterName(): string 463 | { 464 | $class = class_basename($this->modelClass); 465 | 466 | return lcfirst($class); 467 | } 468 | 469 | protected function reportProgress(HunterResult $result): void 470 | { 471 | if ($this->progressCallback && $result->total > 0) { 472 | $processed = $result->successful + $result->failed + $result->skipped; 473 | $percentage = ($processed / $result->total) * 100; 474 | 475 | $this->evaluate($this->progressCallback, [ 476 | 'processed' => $processed, 477 | 'total' => $result->total, 478 | 'percentage' => round($percentage, 2), 479 | 'successful' => $result->successful, 480 | 'failed' => $result->failed, 481 | 'skipped' => $result->skipped, 482 | 'result' => $result, 483 | 'hunter' => $this, 484 | ]); 485 | } 486 | } 487 | 488 | public function whereIn(string $column, array $values): self 489 | { 490 | return $this->modifyQueryUsing(fn ($query) => $query->whereIn($column, $values)); 491 | } 492 | 493 | public function whereNull(string $column): self 494 | { 495 | return $this->modifyQueryUsing(fn ($query) => $query->whereNull($column)); 496 | } 497 | 498 | public function whereNotNull(string $column): self 499 | { 500 | return $this->modifyQueryUsing(fn ($query) => $query->whereNotNull($column)); 501 | } 502 | 503 | public function whereBetween(string $column, array $values): self 504 | { 505 | return $this->modifyQueryUsing(fn ($query) => $query->whereBetween($column, $values)); 506 | } 507 | 508 | public function orderBy(string $column, string $direction = 'asc'): self 509 | { 510 | return $this->modifyQueryUsing(fn ($query) => $query->orderBy($column, $direction)); 511 | } 512 | 513 | public function latest(string $column = 'created_at'): self 514 | { 515 | return $this->modifyQueryUsing(fn ($query) => $query->latest($column)); 516 | } 517 | 518 | public function oldest(string $column = 'created_at'): self 519 | { 520 | return $this->modifyQueryUsing(fn ($query) => $query->oldest($column)); 521 | } 522 | 523 | public function limit(int $limit): self 524 | { 525 | return $this->modifyQueryUsing(fn ($query) => $query->limit($limit)); 526 | } 527 | 528 | public function offset(int $offset): self 529 | { 530 | return $this->modifyQueryUsing(fn ($query) => $query->offset($offset)); 531 | } 532 | 533 | public function with(array | string $relations): self 534 | { 535 | return $this->modifyQueryUsing(fn ($query) => $query->with($relations)); 536 | } 537 | } 538 | --------------------------------------------------------------------------------