├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── config └── one-time-operations.php ├── database ├── factories │ └── OperationFactory.php └── migrations │ └── 2023_02_28_000000_create_one_time_operations_table.php ├── phpunit.xml ├── src ├── Commands │ ├── OneTimeOperationShowCommand.php │ ├── OneTimeOperationsCommand.php │ ├── OneTimeOperationsMakeCommand.php │ ├── OneTimeOperationsProcessCommand.php │ └── Utils │ │ ├── ColoredOutput.php │ │ └── OperationsLineElement.php ├── Jobs │ └── OneTimeOperationProcessJob.php ├── Models │ └── Operation.php ├── OneTimeOperation.php ├── OneTimeOperationCreator.php ├── OneTimeOperationFile.php ├── OneTimeOperationManager.php └── Providers │ └── OneTimeOperationsServiceProvider.php ├── stubs ├── one-time-operation-essential.stub └── one-time-operation.stub └── tests ├── Feature ├── OneTimeOperationCase.php ├── OneTimeOperationCommandTest.php ├── OneTimeOperationCreatorTest.php ├── OneTimeOperationManagerTest.php └── OperationModelTest.php └── resources ├── xxxx_xx_xx_xxxxxx_foo_bar.php └── xxxx_xx_xx_xxxxxx_narf_puit.php /.gitignore: -------------------------------------------------------------------------------- 1 | .phpunit.result.cache 2 | vendor 3 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Timo Körber 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![One-Time Operations for Laravel](https://user-images.githubusercontent.com/65356688/225704995-ec7f54fb-a5b8-4d73-898f-2ebeed9ee733.jpg) 2 | # One-Time Operations for Laravel 3 | 4 | Run operations once after deployment - just like you do it with migrations! 5 | 6 | ----- 7 | 8 | **Take your CI/CD to the next Level with One-Time Operations for Laravel**! 🚀 9 | 10 | Create specific classes for a one-time usage, that can be executed automatically after each deployment. 11 | Same as migrations they get processed once and then never again. Perfect for seeding or updating some data instantly after 12 | some database changes or feature updates. 13 | 14 | This package is for you if... 15 | 16 | - you regularly need to **update specific data** after you deployed new code 17 | - you often execute jobs just **only one single time** after a deployment 18 | - you sometimes **forget to execute** that one specific job and stuff gets crazy 19 | - your code gets **cluttered with jobs**, that are not being used anymore 20 | - your co-workers always need to be reminded to **execute that one job** after some database changes 21 | - you often seed or process data **in a migration file** (which is a big no-no!) 22 | 23 | 24 | ## Installation 25 | 26 | Require this package with composer: 27 | 28 | ```shell 29 | composer require timokoerber/laravel-one-time-operations 30 | ``` 31 | 32 | Create the required table in your database: 33 | 34 | ```shell 35 | php artisan migrate 36 | ``` 37 | 38 | Now you're all set! 39 | 40 | ## Commands 41 | 42 | ### Create operation files 43 | Create new operation file: 44 | ```shell 45 | php artisan operations:make 46 | ``` 47 | Create file without any attributes: 48 | ```shell 49 | php artisan operations:make -e|--essential 50 | ``` 51 | 52 | Use command alias to create operation file: 53 | ```shell 54 | php artisan make:operation 55 | ``` 56 | 57 | ### Process operations 58 | 59 | Process all new operation files: 60 | ```shell 61 | php artisan operations:process 62 | ``` 63 | 64 | Force synchronous execution: 65 | ```shell 66 | php artisan operations:process --sync 67 | ``` 68 | 69 | Force asynchronous execution: 70 | ```shell 71 | php artisan operations:process --async 72 | ``` 73 | 74 | Test mode (don't flag operations as processed): 75 | ```shell 76 | php artisan operations:process --test 77 | ``` 78 | 79 | Run command isolated: 80 | ```shell 81 | php artisan operations:process --isolated 82 | ``` 83 | 84 | Force a specific queue for the job: 85 | ```shell 86 | php artisan operations:process --queue= 87 | ``` 88 | 89 | Only process operations with a specific tag: 90 | ```shell 91 | php artisan operations:process --tag= 92 | ``` 93 | 94 | Re-run one specific operation: 95 | ```shell 96 | php artisan operations:process 97 | ``` 98 | 99 | ### Show operations 100 | 101 | Show all operations: 102 | ```shell 103 | php artisan operations:show 104 | ``` 105 | 106 | Show pending operations: 107 | ```shell 108 | php artisan operations:show pending 109 | ``` 110 | 111 | Show processed operations: 112 | ```shell 113 | php artisan operations:show processed 114 | ``` 115 | 116 | Show disposed operations: 117 | ```shell 118 | php artisan operations:show disposed 119 | ``` 120 | 121 | Use multiple filters to show operations: 122 | ```shell 123 | php artisan operations:show pending processed disposed 124 | ``` 125 | 126 | ## Tutorials 127 | 128 | ### CI/CD & Deployment-Process 129 | 130 | The *One-Time Operations* work exactly like [Laravel Migrations](https://laravel.com/docs/9.x/migrations). 131 | Just process the operations *after your code was deployed and the migrations were migrated*. 132 | You can make it part of your deployment script like this: 133 | 134 | ```shell 135 | ... 136 | - php artisan migrate 137 | - php artisan operations:process 138 | ... 139 | ``` 140 | 141 | ### Edit config 142 | 143 | By default, the following elements will be created in your project: 144 | 145 | - the table `operations` in your database 146 | - the directory `operations` in your project root directory 147 | 148 | If you want to use a different settings just publish and edit the config file: 149 | 150 | ```shell 151 | php artisan vendor:publish --provider="TimoKoerber\LaravelOneTimeOperations\Providers\OneTimeOperationsServiceProvider" 152 | ``` 153 | 154 | This will create the file `config/one-time-operations.php` with the following content. 155 | 156 | ```php 157 | // config/one-time-operation.php 158 | 159 | return [ 160 | 'directory' => 'operations', 161 | 'table' => 'operations', 162 | ]; 163 | ``` 164 | 165 | Make changes as you like. 166 | 167 | ### Create One-Time Operation files 168 | 169 | ![One-Time Operations for Laravel - Create One-Time Operation files](https://user-images.githubusercontent.com/65356688/224433928-721b1261-b7ad-40c6-a512-d0f5b5fa0cbf.png) 170 | 171 | ![One-Time Operations for Laravel - Create One-Time Operation files](https://user-images.githubusercontent.com/65356688/224433323-96b23e84-e22e-4333-8749-ae61cc866cd1.png) 172 | 173 | To create a new operation file execute the following command: 174 | 175 | ```shell 176 | php artisan operations:make AwesomeOperation 177 | ``` 178 | 179 | This will create a file like `operations/XXXX_XX_XX_XXXXXX_awesome_operation.php` with the following content. 180 | 181 | ```php 182 | update(['status' => 'awesome']) // make active users awesome 223 | } 224 | ``` 225 | 226 | By default, the operation is being processed ***asynchronously*** (based on your configuration) by dispatching the job `OneTimeOperationProcessJob`. 227 | By default, the operation is being dispatched to the `default` queue of your project. Change the `$queue` as you wish. 228 | 229 | You can also execute the code synchronously by setting the `$async` flag to `false`. 230 | _(this is only recommended for small operations, since the processing of these operations should be part of the deployment process)_ 231 | 232 | **Hint:** If you use synchronous processing, the `$queue` attribute will be ignored (duh!). 233 | 234 | ### Create a cleaner operation file 235 | 236 | If you don't need all the available attributes for your operation, you can create a *cleaner* operation file with the `--essential` or `-e` option: 237 | 238 | ```shell 239 | php artisan operations:make AwesomeOperation --essential 240 | php artisan operations:make AwesomeOperation -e 241 | ``` 242 | 243 | ### Custom operation file 244 | 245 | You can provide a custom class layout in `/stubs/one-time-operation.stub`, which will be used to create a new operation file. 246 | 247 | ### Processing the operations 248 | 249 | ![One-Time Operations for Laravel - Processing the operations](https://user-images.githubusercontent.com/65356688/224434129-43082402-6077-4043-8e97-c44786e60a59.png) 250 | 251 | Use the following call to process all new operation files. 252 | 253 | ```shell 254 | php artisan operations:process 255 | ``` 256 | 257 | Your code will be executed, and you will find all the processed operations in the `operations` table: 258 | 259 | | id | name | dispatched | processed_at | 260 | |-----|-------------------------------------|------------|---------------------| 261 | | 1 | XXXX_XX_XX_XXXXXX_awesome_operation | async | 2015-10-21 07:28:00 | 262 | 263 | After that, this operation will not be processed anymore. 264 | 265 | ### Dispatching Jobs synchronously or asynchronously 266 | 267 | For each operation a `OneTimeOperationProcessJob` is being dispatched, 268 | either with `dispatch()` oder `dispatchSync()` based on the `$async` attribute in the operation file. 269 | 270 | By providing the `--sync` or `--async` option with the `operations:process` command, you can force a synchronously/asynchronously execution and ignore the attribute: 271 | 272 | ```shell 273 | php artisan operations:process --async // force dispatch() 274 | php artisan operations:process --sync // force dispatchSync() 275 | ``` 276 | 277 | **Hint!** If `operation:process` is part of your deployment process, it is **not recommended** to process the operations synchronously, 278 | since an error in your operation could make your whole deployment fail. 279 | 280 | ### Force different queue for all operations 281 | 282 | You can provide the `--queue` option in the artisan call. The given queue will be used for all operations, ignoring the `$queue` attribute in the class. 283 | 284 | ```shell 285 | php artisan operations:process --queue=redis // force redis queue 286 | ``` 287 | 288 | ### Run commands isolated on Multi-Server Architecture 289 | 290 | If you work with a Multi-Server Architecture you can use `--isolated` option to make sure to only run one instance of the command ([Laravel Isolatable Commands](https://laravel.com/docs/10.x/artisan#isolatable-commands)). 291 | 292 | ```shell 293 | php artisan operations:process --isolated 294 | ``` 295 | 296 | ### Run only operations with a given tag 297 | 298 | You can provide the `$tag` attribute in your operation file: 299 | 300 | ```php 301 | 'operations', 7 | 8 | // Table name - name of the table that stores your operation entries 9 | 'table' => 'operations', 10 | 11 | // Database Connection Name - Change the model connection, support for Multitenancy 12 | // Only change when you want to deviate from your system default repository 13 | 'connection' => null, 14 | ]; 15 | -------------------------------------------------------------------------------- /database/factories/OperationFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->date('Y_m_d_His').'_'.Str::snake($this->faker->words(3, true)), 18 | 'dispatched' => Arr::random([Operation::DISPATCHED_ASYNC, Operation::DISPATCHED_ASYNC]), 19 | 'processed_at' => $this->faker->date('Y-m-d H:i:s'), 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /database/migrations/2023_02_28_000000_create_one_time_operations_table.php: -------------------------------------------------------------------------------- 1 | name = OneTimeOperationManager::getTableName(); 16 | } 17 | 18 | public function up() 19 | { 20 | Schema::create($this->name, function (Blueprint $table) { 21 | $table->bigIncrements('id'); 22 | $table->string('name'); 23 | $table->enum('dispatched', [Operation::DISPATCHED_SYNC, Operation::DISPATCHED_ASYNC]); 24 | $table->timestamp('processed_at')->nullable(); 25 | }); 26 | } 27 | 28 | public function down() 29 | { 30 | Schema::dropIfExists($this->name); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Feature 10 | 11 | 12 | 13 | 14 | ./app 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Commands/OneTimeOperationShowCommand.php: -------------------------------------------------------------------------------- 1 | validateFilters(); 28 | $this->newLine(); 29 | 30 | $operationOutputLines = $this->getOperationLinesForOutput(); 31 | $operationOutputLines = $this->filterOperationLinesByStatus($operationOutputLines); 32 | 33 | if ($operationOutputLines->isEmpty()) { 34 | $this->components->info('No operations found.'); 35 | } 36 | 37 | /** @var OperationsLineElement $lineElement */ 38 | foreach ($operationOutputLines as $lineElement) { 39 | $lineElement->output($this->components); 40 | } 41 | 42 | $this->newLine(); 43 | 44 | return self::SUCCESS; 45 | } catch (Throwable $e) { 46 | $this->components->error($e->getMessage()); 47 | 48 | return self::FAILURE; 49 | } 50 | } 51 | 52 | /** 53 | * @throws Throwable 54 | */ 55 | protected function validateFilters(): void 56 | { 57 | $filters = array_map(fn ($filter) => strtolower($filter), $this->argument('filter')); 58 | $validFilters = array_map(fn ($filter) => strtolower($filter), $this->validFilters); 59 | 60 | throw_if(array_diff($filters, $validFilters), \Exception::class, 'Given filter is not valid. Allowed filters: '.implode('|', array_map('strtolower', $this->validFilters))); 61 | } 62 | 63 | protected function shouldDisplayByFilter(string $filterName): bool 64 | { 65 | $givenFilters = $this->argument('filter'); 66 | 67 | if (empty($givenFilters)) { 68 | return true; 69 | } 70 | 71 | $givenFilters = array_map(fn ($filter) => strtolower($filter), $givenFilters); 72 | 73 | return in_array(strtolower($filterName), $givenFilters); 74 | } 75 | 76 | protected function getOperationLinesForOutput(): Collection 77 | { 78 | $operationModels = Operation::all(); 79 | $operationFiles = OneTimeOperationManager::getAllOperationFiles(); 80 | $operationOutputLines = collect(); 81 | 82 | // add disposed operations 83 | foreach ($operationModels as $operation) { 84 | if (OneTimeOperationManager::fileExistsByName($operation->name)) { 85 | continue; 86 | } 87 | 88 | $operationOutputLines->add(OperationsLineElement::make($operation->name, self::LABEL_DISPOSED, $operation->processed_at)); 89 | } 90 | 91 | // add processed and pending operations 92 | foreach ($operationFiles->toArray() as $file) { 93 | /** @var OneTimeOperationFile $file */ 94 | if ($model = $file->getModel()) { 95 | $operationOutputLines->add(OperationsLineElement::make($model->name, self::LABEL_PROCESSED, $model->processed_at, $file->getClassObject()->getTag())); 96 | } else { 97 | $operationOutputLines->add(OperationsLineElement::make($file->getOperationName(), self::LABEL_PENDING, null, $file->getClassObject()->getTag())); 98 | } 99 | } 100 | 101 | return $operationOutputLines; 102 | } 103 | 104 | protected function filterOperationLinesByStatus(Collection $operationOutputLines): Collection 105 | { 106 | return $operationOutputLines->filter(function (OperationsLineElement $lineElement) { 107 | return $this->shouldDisplayByFilter($lineElement->getStatus()); 108 | })->collect(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Commands/OneTimeOperationsCommand.php: -------------------------------------------------------------------------------- 1 | operationsDirectory = OneTimeOperationManager::getDirectoryPath(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Commands/OneTimeOperationsMakeCommand.php: -------------------------------------------------------------------------------- 1 | setAliases(['make:operation']); 19 | 20 | parent::configure(); 21 | } 22 | 23 | public function handle(): int 24 | { 25 | try { 26 | $file = OneTimeOperationCreator::createOperationFile($this->argument('name'), $this->option('essential')); 27 | $this->components->info(sprintf('One-time operation [%s] created successfully.', $file->getOperationName())); 28 | 29 | return self::SUCCESS; 30 | } catch (Throwable $e) { 31 | $this->components->warn($e->getMessage()); 32 | 33 | return self::FAILURE; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Commands/OneTimeOperationsProcessCommand.php: -------------------------------------------------------------------------------- 1 | displayTestmodeWarning(); 36 | 37 | $this->forceAsync = (bool) $this->option('async'); 38 | $this->forceSync = (bool) $this->option('sync'); 39 | $this->queue = $this->option('queue'); 40 | $this->tags = $this->option('tag'); 41 | 42 | if (! $this->tagOptionsAreValid()) { 43 | $this->components->error('Abort! Do not provide empty tags!'); 44 | 45 | return self::FAILURE; 46 | } 47 | 48 | if (! $this->syncOptionsAreValid()) { 49 | $this->components->error('Abort! Process either with --sync or --async.'); 50 | 51 | return self::FAILURE; 52 | } 53 | 54 | if ($operationName = $this->argument('name')) { 55 | return $this->proccessSingleOperation($operationName); 56 | } 57 | 58 | return $this->processNextOperations(); 59 | } 60 | 61 | protected function proccessSingleOperation(string $providedOperationName): int 62 | { 63 | $providedOperationName = str($providedOperationName)->rtrim('.php')->toString(); 64 | 65 | try { 66 | if ($operationModel = OneTimeOperationManager::getModelByName($providedOperationName)) { 67 | return $this->processOperationModel($operationModel); 68 | } 69 | 70 | $operationsFile = OneTimeOperationManager::getOperationFileByName($providedOperationName); 71 | 72 | return $this->processOperationFile($operationsFile); 73 | } catch (\Throwable $e) { 74 | $this->components->error($e->getMessage()); 75 | 76 | return self::FAILURE; 77 | } 78 | } 79 | 80 | protected function processOperationFile(OneTimeOperationFile $operationFile): int 81 | { 82 | $this->components->task($operationFile->getOperationName(), function () use ($operationFile) { 83 | $this->dispatchOperationJob($operationFile); 84 | $this->storeOperation($operationFile); 85 | }); 86 | 87 | $this->newLine(); 88 | $this->components->info('Processing finished.'); 89 | 90 | return self::SUCCESS; 91 | } 92 | 93 | protected function processOperationModel(Operation $operationModel): int 94 | { 95 | if (! $this->components->confirm('Operation was processed before. Process it again?')) { 96 | $this->components->info('Operation aborted'); 97 | 98 | return self::SUCCESS; 99 | } 100 | 101 | $this->components->info(sprintf('Processing operation %s.', $operationModel->name)); 102 | 103 | $this->components->task($operationModel->name, function () use ($operationModel) { 104 | $operationFile = OneTimeOperationManager::getOperationFileByModel($operationModel); 105 | 106 | $this->dispatchOperationJob($operationFile); 107 | $this->storeOperation($operationFile); 108 | }); 109 | 110 | $this->newLine(); 111 | $this->components->info('Processing finished.'); 112 | 113 | return self::SUCCESS; 114 | } 115 | 116 | protected function processNextOperations(): int 117 | { 118 | $processingOutput = 'Processing operations.'; 119 | $unprocessedOperationFiles = OneTimeOperationManager::getUnprocessedOperationFiles(); 120 | 121 | if ($this->tags) { 122 | $processingOutput = sprintf('Processing operations with tags (%s)', Arr::join($this->tags, ',')); 123 | $unprocessedOperationFiles = $this->filterOperationsByTags($unprocessedOperationFiles); 124 | } 125 | 126 | if ($unprocessedOperationFiles->isEmpty()) { 127 | $this->components->info('No operations to process.'); 128 | 129 | return self::SUCCESS; 130 | } 131 | 132 | $this->components->info($processingOutput); 133 | 134 | foreach ($unprocessedOperationFiles as $operationFile) { 135 | $this->components->task($operationFile->getOperationName(), function () use ($operationFile) { 136 | $this->dispatchOperationJob($operationFile); 137 | $this->storeOperation($operationFile); 138 | }); 139 | } 140 | 141 | $this->newLine(); 142 | $this->components->info('Processing finished.'); 143 | 144 | return self::SUCCESS; 145 | } 146 | 147 | protected function tagMatched(OneTimeOperationFile $operationFile): bool 148 | { 149 | return in_array($operationFile->getClassObject()->getTag(), $this->tags); 150 | } 151 | 152 | protected function storeOperation(OneTimeOperationFile $operationFile): void 153 | { 154 | if ($this->testModeEnabled()) { 155 | return; 156 | } 157 | 158 | Operation::storeOperation($operationFile->getOperationName(), $this->isAsyncMode($operationFile)); 159 | } 160 | 161 | protected function dispatchOperationJob(OneTimeOperationFile $operationFile) 162 | { 163 | if ($this->isAsyncMode($operationFile)) { 164 | OneTimeOperationProcessJob::dispatch($operationFile->getOperationName())->onQueue($this->getQueue($operationFile)); 165 | 166 | return; 167 | } 168 | 169 | OneTimeOperationProcessJob::dispatchSync($operationFile->getOperationName()); 170 | } 171 | 172 | protected function testModeEnabled(): bool 173 | { 174 | return $this->option('test'); 175 | } 176 | 177 | protected function displayTestmodeWarning(): void 178 | { 179 | if ($this->testModeEnabled()) { 180 | $this->components->warn('Testmode! Operation won\'t be tagged as `processed`'); 181 | } 182 | } 183 | 184 | protected function isAsyncMode(OneTimeOperationFile $operationFile): bool 185 | { 186 | if ($this->forceAsync) { 187 | return true; 188 | } 189 | 190 | if ($this->forceSync) { 191 | return false; 192 | } 193 | 194 | return $operationFile->getClassObject()->isAsync(); 195 | } 196 | 197 | protected function getQueue(OneTimeOperationFile $operationFile): ?string 198 | { 199 | if ($this->queue) { 200 | return $this->queue; 201 | } 202 | 203 | return $operationFile->getClassObject()->getQueue() ?: null; 204 | } 205 | 206 | protected function filterOperationsByTags(Collection $unprocessedOperationFiles): Collection 207 | { 208 | return $unprocessedOperationFiles->filter(function (OneTimeOperationFile $operationFile) { 209 | return $this->tagMatched($operationFile); 210 | })->collect(); 211 | } 212 | 213 | protected function tagOptionsAreValid(): bool 214 | { 215 | // no tags provided 216 | if (empty($this->tags)) { 217 | return true; 218 | } 219 | 220 | // all tags are not empty 221 | if (count($this->tags) === count(array_filter($this->tags))) { 222 | return true; 223 | } 224 | 225 | return false; 226 | } 227 | 228 | protected function syncOptionsAreValid(): bool 229 | { 230 | // do not use both options at the same time 231 | return ! ($this->forceAsync && $this->forceSync); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Commands/Utils/ColoredOutput.php: -------------------------------------------------------------------------------- 1 | %s', $message); 10 | } 11 | 12 | protected function lightgray(string $message): string 13 | { 14 | return sprintf('%s', $message); 15 | } 16 | 17 | protected function gray(string $message): string 18 | { 19 | return sprintf('%s', $message); 20 | } 21 | 22 | protected function brightgreen(string $message): string 23 | { 24 | return sprintf('%s', $message); 25 | } 26 | 27 | protected function green(string $message): string 28 | { 29 | return sprintf('%s', $message); 30 | } 31 | 32 | protected function white(string $message): string 33 | { 34 | return sprintf('%s', $message); 35 | } 36 | 37 | protected function grayBadge(string $message): string 38 | { 39 | return sprintf('%s', $message); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Commands/Utils/OperationsLineElement.php: -------------------------------------------------------------------------------- 1 | twoColumnDetail($this->firstColumn(), $this->secondColumn()); 29 | } 30 | 31 | protected function firstColumn(): string 32 | { 33 | $label = $this->name; 34 | 35 | if ($this->tag) { 36 | $label .= ' '.$this->gray('('.$this->tag.')'); 37 | } 38 | 39 | return $label; 40 | } 41 | 42 | protected function secondColumn(): string 43 | { 44 | $label = $this->coloredStatus($this->status); 45 | 46 | if ($this->processedAt) { 47 | $label = $this->gray($this->processedAt).' '.$label; 48 | } 49 | 50 | return $label; 51 | } 52 | 53 | protected function coloredStatus(string $status): string 54 | { 55 | return match ($status) { 56 | OneTimeOperationsCommand::LABEL_DISPOSED => $this->green($status), 57 | OneTimeOperationsCommand::LABEL_PROCESSED => $this->brightgreen($status), 58 | default => $this->white($status), 59 | }; 60 | } 61 | 62 | public function getStatus(): string 63 | { 64 | return $this->status; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Jobs/OneTimeOperationProcessJob.php: -------------------------------------------------------------------------------- 1 | operationName = $operationName; 21 | } 22 | 23 | public function handle(): void 24 | { 25 | OneTimeOperationManager::getClassObjectByName($this->operationName)->process(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Models/Operation.php: -------------------------------------------------------------------------------- 1 | 'datetime', 28 | ]; 29 | 30 | public function __construct(array $attributes = []) 31 | { 32 | parent::__construct($attributes); 33 | 34 | $this->table = OneTimeOperationManager::getTableName(); 35 | } 36 | 37 | public function getConnectionName() 38 | { 39 | return config('one-time-operations.connection', $this->connection); 40 | } 41 | 42 | protected static function newFactory(): OperationFactory 43 | { 44 | return new OperationFactory(); 45 | } 46 | 47 | public static function storeOperation(string $operation, bool $async): self 48 | { 49 | return self::firstOrCreate([ 50 | 'name' => $operation, 51 | 'dispatched' => $async ? self::DISPATCHED_ASYNC : self::DISPATCHED_SYNC, 52 | 'processed_at' => now(), 53 | ]); 54 | } 55 | 56 | public function getFilePathAttribute(): string 57 | { 58 | return OneTimeOperationManager::pathToFileByName($this->name); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/OneTimeOperation.php: -------------------------------------------------------------------------------- 1 | async; 30 | } 31 | 32 | public function getQueue(): string 33 | { 34 | return $this->queue; 35 | } 36 | 37 | public function getTag(): ?string 38 | { 39 | return $this->tag; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/OneTimeOperationCreator.php: -------------------------------------------------------------------------------- 1 | operationsDirectory = OneTimeOperationManager::getDirectoryPath(); 23 | } 24 | 25 | /** 26 | * @throws \Throwable 27 | */ 28 | public static function createOperationFile(string $name, bool $essential = false): OneTimeOperationFile 29 | { 30 | $instance = new self(); 31 | $instance->setProvidedName($name); 32 | $instance->setEssential($essential); 33 | 34 | return OneTimeOperationFile::make($instance->createFile()); 35 | } 36 | 37 | /** 38 | * @throws \Throwable 39 | */ 40 | public function createFile(): \SplFileInfo 41 | { 42 | $path = $this->getPath(); 43 | $stub = $this->getStubFilepath(); 44 | $this->ensureDirectoryExists(); 45 | 46 | throw_if(File::exists($path), ErrorException::class, sprintf('File already exists: %s', $path)); 47 | 48 | File::put($path, $stub); 49 | 50 | return new \SplFileInfo($path); 51 | } 52 | 53 | protected function getPath(): string 54 | { 55 | return $this->operationsDirectory.DIRECTORY_SEPARATOR.$this->getOperationName(); 56 | } 57 | 58 | protected function getStubFilepath(): string 59 | { 60 | // check for custom stub file 61 | if (File::exists(base_path('stubs/one-time-operation.stub'))) { 62 | return File::get(base_path('stubs/one-time-operation.stub')); 63 | } 64 | 65 | if ($this->essential) { 66 | return File::get(__DIR__.'/../stubs/one-time-operation-essential.stub'); 67 | } 68 | 69 | return File::get(__DIR__.'/../stubs/one-time-operation.stub'); 70 | } 71 | 72 | public function getOperationName(): string 73 | { 74 | if (! $this->operationName) { 75 | $this->initOperationName(); 76 | } 77 | 78 | return $this->operationName; 79 | } 80 | 81 | protected function getDatePrefix(): string 82 | { 83 | return Carbon::now()->format('Y_m_d_His'); 84 | } 85 | 86 | protected function initOperationName(): void 87 | { 88 | $this->operationName = $this->getDatePrefix().'_'.Str::snake($this->providedName).'.php'; 89 | } 90 | 91 | protected function ensureDirectoryExists(): void 92 | { 93 | File::ensureDirectoryExists($this->operationsDirectory); 94 | } 95 | 96 | public function setProvidedName(string $providedName): void 97 | { 98 | $this->providedName = $providedName; 99 | } 100 | 101 | public function setEssential(bool $essential): void 102 | { 103 | $this->essential = $essential; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/OneTimeOperationFile.php: -------------------------------------------------------------------------------- 1 | file = $file; 24 | } 25 | 26 | public function getOperationName(): string 27 | { 28 | $pathElements = explode(DIRECTORY_SEPARATOR, $this->file->getRealPath()); 29 | $filename = end($pathElements); 30 | 31 | return Str::remove('.php', $filename); 32 | } 33 | 34 | public function getClassObject(): OneTimeOperation 35 | { 36 | if (! $this->classObject) { 37 | $this->classObject = File::getRequire($this->file); 38 | } 39 | 40 | return $this->classObject; 41 | } 42 | 43 | public function getModel(): ?Operation 44 | { 45 | return Operation::whereName($this->getOperationName())->first(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/OneTimeOperationManager.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public static function getUnprocessedOperationFiles(): Collection 21 | { 22 | $operationFiles = self::getUnprocessedFiles(); 23 | 24 | return $operationFiles->map(fn (SplFileInfo $file) => OneTimeOperationFile::make($file)); 25 | } 26 | 27 | /** 28 | * @return Collection 29 | */ 30 | public static function getAllOperationFiles(): Collection 31 | { 32 | $operationFiles = self::getAllFiles(); 33 | 34 | return $operationFiles->map(fn (SplFileInfo $file) => OneTimeOperationFile::make($file)); 35 | } 36 | 37 | /** 38 | * @return Collection 39 | */ 40 | public static function getUnprocessedFiles(): Collection 41 | { 42 | $allOperationFiles = self::getAllFiles(); 43 | 44 | return $allOperationFiles->filter(function (SplFileInfo $operationFilepath) { 45 | $operation = self::getOperationNameFromFilename($operationFilepath->getBasename()); 46 | 47 | return Operation::whereName($operation)->doesntExist(); 48 | }); 49 | } 50 | 51 | /** 52 | * @return Collection 53 | */ 54 | public static function getAllFiles(): Collection 55 | { 56 | try { 57 | return collect(File::files(self::getDirectoryPath())); 58 | } catch (DirectoryNotFoundException $e) { 59 | return collect(); 60 | } 61 | } 62 | 63 | public static function getClassObjectByName(string $operationName): OneTimeOperation 64 | { 65 | $filepath = self::pathToFileByName($operationName); 66 | 67 | return File::getRequire($filepath); 68 | } 69 | 70 | public static function getModelByName(string $operationName): ?Operation 71 | { 72 | return Operation::whereName($operationName)->first(); 73 | } 74 | 75 | public static function getOperationFileByModel(Operation $operationModel): OneTimeOperationFile 76 | { 77 | $filepath = self::pathToFileByName($operationModel->name); 78 | 79 | throw_unless(File::exists($filepath), FileNotFoundException::class); 80 | 81 | return OneTimeOperationFile::make(new SplFileInfo($filepath)); 82 | } 83 | 84 | /** 85 | * @throws \Throwable 86 | */ 87 | public static function getOperationFileByName(string $operationName): OneTimeOperationFile 88 | { 89 | $filepath = self::pathToFileByName($operationName); 90 | 91 | throw_unless(File::exists($filepath), FileNotFoundException::class, sprintf('File %s does not exist', self::buildFilename($operationName))); 92 | 93 | return OneTimeOperationFile::make(new SplFileInfo($filepath)); 94 | } 95 | 96 | public static function pathToFileByName(string $operationName): string 97 | { 98 | return self::getDirectoryPath().self::buildFilename($operationName); 99 | } 100 | 101 | public static function fileExistsByName(string $operationName): bool 102 | { 103 | return File::exists(self::pathToFileByName($operationName)); 104 | } 105 | 106 | public static function getDirectoryName(): string 107 | { 108 | return Config::get('one-time-operations.directory'); 109 | } 110 | 111 | public static function getDirectoryPath(): string 112 | { 113 | return App::basePath(Str::of(self::getDirectoryName())->rtrim('/')).DIRECTORY_SEPARATOR; 114 | } 115 | 116 | public static function getOperationNameFromFilename(string $filename): string 117 | { 118 | return str($filename)->remove('.php'); 119 | } 120 | 121 | public static function getTableName(): string 122 | { 123 | return Config::get('one-time-operations.table', 'operations'); // @TODO 124 | } 125 | 126 | public static function buildFilename($operationName): string 127 | { 128 | return $operationName.'.php'; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Providers/OneTimeOperationsServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom([__DIR__.'/../../database/migrations']); 15 | 16 | $this->publishes([ 17 | __DIR__.'/../../config/one-time-operations.php' => config_path('one-time-operations.php'), 18 | ]); 19 | 20 | if ($this->app->runningInConsole()) { 21 | $this->commands(OneTimeOperationsMakeCommand::class); 22 | $this->commands(OneTimeOperationsProcessCommand::class); 23 | $this->commands(OneTimeOperationShowCommand::class); 24 | } 25 | } 26 | 27 | public function register() 28 | { 29 | $this->mergeConfigFrom( 30 | __DIR__.'/../../config/one-time-operations.php', 'one-time-operations' 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /stubs/one-time-operation-essential.stub: -------------------------------------------------------------------------------- 1 | set('one-time-operations.directory', '../../../../'.self::TEST_FILE_DIRECTORY); 40 | $app['config']->set('one-time-operations.table', self::TEST_TABLE_NAME); 41 | $app['config']->set('queue.default', 'database'); 42 | } 43 | 44 | protected function deleteFileDirectory() 45 | { 46 | File::deleteDirectory(self::TEST_FILE_DIRECTORY); 47 | } 48 | 49 | protected function mockFileDirectory() 50 | { 51 | File::copyDirectory('tests/resources', self::TEST_FILE_DIRECTORY); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/OneTimeOperationCommandTest.php: -------------------------------------------------------------------------------- 1 | filepath('2015_10_21_072800_awesome_operation.php'); 19 | File::delete($filepath); 20 | 21 | // create operation file 22 | $this->artisan('operations:make AwesomeOperation') 23 | ->assertSuccessful() 24 | ->expectsOutputToContain('One-time operation [2015_10_21_072800_awesome_operation] created successfully.'); 25 | 26 | // file was created, but no operation entry yet 27 | $this->assertFileExists($filepath); 28 | 29 | $fileContent = File::get($filepath); 30 | // file should contain attributes and method 31 | $this->assertStringContainsString('protected bool $async = true;', $fileContent); 32 | $this->assertStringContainsString('protected string $queue = \'default\';', $fileContent); 33 | $this->assertStringContainsString('protected ?string $tag = null;', $fileContent); 34 | $this->assertStringContainsString('public function process(): void', $fileContent); 35 | } 36 | 37 | public function test_make_command_alias() 38 | { 39 | $filepath = $this->filepath('2015_10_21_072800_awesome_operation.php'); 40 | File::delete($filepath); 41 | 42 | // create operation file 43 | $this->artisan('make:operation AwesomeOperation') 44 | ->assertSuccessful() 45 | ->expectsOutputToContain('One-time operation [2015_10_21_072800_awesome_operation] created successfully.'); 46 | } 47 | 48 | public function test_make_command_without_attributes() 49 | { 50 | $filepath = $this->filepath('2015_10_21_072800_awesome_operation.php'); 51 | File::delete($filepath); 52 | 53 | // create operation file with essential flag 54 | $this->artisan('operations:make AwesomeOperation --essential')->assertSuccessful(); 55 | 56 | $fileContent = File::get($filepath); 57 | 58 | // file should contain method 59 | $this->assertStringContainsString('public function process(): void', $fileContent); 60 | 61 | // file should not contain attributes 62 | $this->assertStringNotContainsString('protected bool $async = true;', $fileContent); 63 | $this->assertStringNotContainsString('protected string $queue = \'default\';', $fileContent); 64 | $this->assertStringNotContainsString('protected ?string $tag = null;', $fileContent); 65 | } 66 | 67 | public function test_make_command_without_attributes_shortcut() 68 | { 69 | $filepath = $this->filepath('2015_10_21_072800_awesome_operation.php'); 70 | File::delete($filepath); 71 | 72 | // create operation file with shortcut for essential flag 73 | $this->artisan('operations:make AwesomeOperation -e')->assertSuccessful(); 74 | 75 | $fileContent = File::get($filepath); 76 | 77 | // file should contain method 78 | $this->assertStringContainsString('public function process(): void', $fileContent); 79 | 80 | // file should not contain attributes 81 | $this->assertStringNotContainsString('protected bool $async = true;', $fileContent); 82 | $this->assertStringNotContainsString('protected string $queue = \'default\';', $fileContent); 83 | $this->assertStringNotContainsString('protected ?string $tag = null;', $fileContent); 84 | } 85 | 86 | /** @test */ 87 | public function test_the_whole_command_process() 88 | { 89 | $filepath = $this->filepath('2015_10_21_072800_awesome_operation.php'); 90 | File::delete($filepath); 91 | 92 | // no files, database entries or jobs 93 | $this->assertFileDoesNotExist($filepath); 94 | $this->assertEquals(0, Operation::count()); 95 | Queue::assertNothingPushed(); 96 | 97 | // create operation file 98 | $this->artisan('operations:make AwesomeOperation') 99 | ->assertSuccessful() 100 | ->expectsOutputToContain('One-time operation [2015_10_21_072800_awesome_operation] created successfully.'); 101 | 102 | // file was created, but no operation entry yet 103 | $this->assertFileExists($filepath); 104 | $this->assertEquals(0, Operation::count()); 105 | 106 | // process available operations succesfully 107 | $this->artisan('operations:process') 108 | ->assertSuccessful() 109 | ->expectsOutputToContain('Processing operations.') 110 | ->expectsOutputToContain('2015_10_21_072800_awesome_operation') 111 | ->expectsOutputToContain('Processing finished.'); 112 | 113 | // operation was exectued - database entry and job was created 114 | $this->assertEquals(1, Operation::count()); 115 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 116 | return $job->connection === null; // async 117 | }); 118 | 119 | // entry was created successfully 120 | $operation = Operation::first(); 121 | $this->assertEquals('2015_10_21_072800_awesome_operation', $operation->name); 122 | $this->assertEquals('2015-10-21 07:28:00', $operation->processed_at); 123 | $this->assertEquals('async', $operation->dispatched); 124 | 125 | // process once more - nothing to do 126 | $this->artisan('operations:process') 127 | ->assertSuccessful() 128 | ->expectsOutputToContain('No operations to process.') 129 | ->doesntExpectOutputToContain('2015_10_21_072800_awesome_operation'); 130 | 131 | // no more jobs were created 132 | Queue::assertPushed(OneTimeOperationProcessJob::class, 1); 133 | 134 | // re-run job explicitly, but cancel process 135 | $this->artisan('operations:process 2015_10_21_072800_awesome_operation') 136 | ->expectsConfirmation('Operation was processed before. Process it again?', 'no') 137 | ->expectsOutputToContain('Operation aborted.'); 138 | 139 | // test different processed_at timestamp later 140 | $this->travel(1)->hour(); // 2015-10-21 08:28:00 141 | 142 | // re-run job explicitly and confirm 143 | $this->artisan('operations:process 2015_10_21_072800_awesome_operation') 144 | ->expectsConfirmation('Operation was processed before. Process it again?', 'yes') //confirm 145 | ->expectsOutputToContain('Processing operation 2015_10_21_072800_awesome_operation') 146 | ->expectsOutputToContain('Processing finished.'); 147 | 148 | // another job was pushed to the queue 149 | Queue::assertPushed(OneTimeOperationProcessJob::class, 2); 150 | 151 | // another database entry was created 152 | $this->assertEquals(2, Operation::count()); 153 | 154 | // newest entry has updated timestamp 155 | $operation = Operation::all()->last(); 156 | $this->assertEquals('2015_10_21_072800_awesome_operation', $operation->name); 157 | $this->assertEquals('2015-10-21 08:28:00', $operation->processed_at); 158 | $this->assertEquals('async', $operation->dispatched); 159 | } 160 | 161 | public function test_sync_processing() 162 | { 163 | $filepath = $this->filepath('2015_10_21_072800_foo_bar_operation.php'); 164 | File::delete($filepath); 165 | 166 | // no jobs yet 167 | Queue::assertNothingPushed(); 168 | 169 | // create operation 170 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 171 | 172 | // Process - error is thrown because both flags are used 173 | $this->artisan('operations:process --sync --async') 174 | ->assertFailed() 175 | ->expectsOutputToContain('Abort! Process either with --sync or --async.'); 176 | 177 | // process operation without jobs 178 | $this->artisan('operations:process --sync') 179 | ->assertSuccessful() 180 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation'); 181 | 182 | // Job was executed synchronously 183 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 184 | return $job->connection === 'sync'; 185 | }); 186 | 187 | $operation = Operation::first(); 188 | $this->assertEquals('2015_10_21_072800_foo_bar_operation', $operation->name); 189 | $this->assertEquals('sync', $operation->dispatched); 190 | } 191 | 192 | public function test_sync_processing_with_file_attribute() 193 | { 194 | Queue::assertNothingPushed(); 195 | 196 | // create file 197 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 198 | 199 | // edit file so it will be executed synchronously 200 | $this->editFile('2015_10_21_072800_foo_bar_operation.php', '$async = true;', '$async = false;'); 201 | 202 | // process 203 | $this->artisan('operations:process')->assertSuccessful(); 204 | 205 | // Job was executed synchronously 206 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 207 | return $job->operationName === '2015_10_21_072800_foo_bar_operation' 208 | && $job->connection === 'sync' // sync 209 | && $job->queue === null; // no queue 210 | }); 211 | 212 | $operation = Operation::first(); 213 | $this->assertEquals('2015_10_21_072800_foo_bar_operation', $operation->name); 214 | $this->assertEquals('sync', $operation->dispatched); 215 | 216 | // process again - now asynchronously 217 | $this->artisan('operations:process 2015_10_21_072800_foo_bar_operation --async') 218 | ->expectsConfirmation('Operation was processed before. Process it again?', 'yes') 219 | ->assertSuccessful(); 220 | 221 | // Job was executed asynchronously 222 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 223 | return $job->operationName === '2015_10_21_072800_foo_bar_operation' 224 | && $job->connection === null // async 225 | && $job->queue === 'default'; // default queue 226 | }); 227 | 228 | $operation = Operation::all()->last(); 229 | $this->assertEquals('2015_10_21_072800_foo_bar_operation', $operation->name); 230 | $this->assertEquals('async', $operation->dispatched); 231 | 232 | // process again - now on queue "foobar" 233 | $this->artisan('operations:process 2015_10_21_072800_foo_bar_operation --async --queue=foobar') 234 | ->expectsConfirmation('Operation was processed before. Process it again?', 'yes') 235 | ->assertSuccessful(); 236 | 237 | // Job was executed asynchronously on queue "foobar" 238 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 239 | return $job->operationName === '2015_10_21_072800_foo_bar_operation' 240 | && $job->connection === null // async 241 | && $job->queue === 'foobar'; // default queue 242 | }); 243 | } 244 | 245 | public function test_processing_with_queue() 246 | { 247 | Queue::assertNothingPushed(); 248 | 249 | // create file 250 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 251 | 252 | // edit file so it will use different queue 253 | $this->editFile('2015_10_21_072800_foo_bar_operation.php', '$queue = \'default\';', '$queue = \'narfpuit\';'); 254 | 255 | // process 256 | $this->artisan('operations:process')->assertSuccessful(); 257 | 258 | // Job was executed synchronously 259 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 260 | return $job->operationName === '2015_10_21_072800_foo_bar_operation' 261 | && $job->connection === null // async 262 | && $job->queue === 'narfpuit'; // queue narfpuit 263 | }); 264 | 265 | // process again - overwrite queue with "foobar" 266 | $this->artisan('operations:process 2015_10_21_072800_foo_bar_operation --queue=foobar') 267 | ->expectsConfirmation('Operation was processed before. Process it again?', 'yes') 268 | ->assertSuccessful(); 269 | 270 | // Job was executed asynchronously on queue "foobar" 271 | Queue::assertPushed(OneTimeOperationProcessJob::class, function (OneTimeOperationProcessJob $job) { 272 | return $job->operationName === '2015_10_21_072800_foo_bar_operation' 273 | && $job->connection === null // async 274 | && $job->queue === 'foobar'; // queue foobar 275 | }); 276 | } 277 | 278 | public function test_processing_with_test_flag() 279 | { 280 | // no database entry yet 281 | $this->assertEquals(0, Operation::count()); 282 | 283 | // create file 284 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 285 | 286 | // process with test flag 287 | $this->artisan('operations:process --test') 288 | ->assertSuccessful() 289 | ->expectsOutputToContain('Testmode! Operation won\'t be tagged as `processed`.') 290 | ->expectsOutputToContain('Processing operations.') 291 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') 292 | ->expectsOutputToContain('Processing finished.'); 293 | 294 | // still no database entry because of test mode 295 | $this->assertEquals(0, Operation::count()); 296 | } 297 | 298 | public function test_processing_with_tags() 299 | { 300 | // create files 301 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 302 | $this->artisan('operations:make NarfPuitOperation')->assertSuccessful(); 303 | $this->artisan('operations:make NullTagOperation')->assertSuccessful(); 304 | 305 | // edit files so they will use tags 306 | $this->editFile('2015_10_21_072800_foo_bar_operation.php', '$tag = null;', '$tag = \'foobar\';'); 307 | $this->editFile('2015_10_21_072800_narf_puit_operation.php', '$tag = null;', '$tag = \'narfpuit\';'); 308 | 309 | // error because tag is empty 310 | $this->artisan('operations:process --test --tag') 311 | ->expectsOutputToContain('Abort! Do not provide empty tags!') 312 | ->assertFailed(); 313 | 314 | // error because tag is empty 315 | $this->artisan('operations:process --test --tag=') 316 | ->expectsOutputToContain('Abort! Do not provide empty tags!') 317 | ->assertFailed(); 318 | 319 | // error because second tag is empty 320 | $this->artisan('operations:process --test --tag=narfpuit --tag=') 321 | ->expectsOutputToContain('Abort! Do not provide empty tags!') 322 | ->assertFailed(); 323 | 324 | // tag does not match, so operations won't get processed 325 | $this->artisan('operations:process --test --tag=awesome') 326 | ->expectsOutputToContain('No operations to process.') 327 | ->doesntExpectOutputToContain('2015_10_21_072800_null_tag_operation') 328 | ->doesntExpectOutputToContain('2015_10_21_072800_foo_bar_operation') 329 | ->doesntExpectOutputToContain('2015_10_21_072800_narf_puit_operation') 330 | ->assertSuccessful(); 331 | 332 | // foobar operation will be processed because tag matches 333 | $this->artisan('operations:process --test --tag=foobar') 334 | ->doesntExpectOutputToContain('2015_10_21_072800_null_tag_operation') 335 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') 336 | ->doesntExpectOutputToContain('No operations to process.') 337 | ->doesntExpectOutputToContain('2015_10_21_072800_narf_puit_operation') 338 | ->assertSuccessful(); 339 | 340 | // narfpuit operation will be processed because tag matches 341 | $this->artisan('operations:process --test --tag=narfpuit') 342 | ->doesntExpectOutputToContain('2015_10_21_072800_null_tag_operation') 343 | ->expectsOutputToContain('2015_10_21_072800_narf_puit_operation') 344 | ->doesntExpectOutputToContain('No operations to process.') 345 | ->doesntExpectOutputToContain('2015_10_21_072800_foo_bar_operation') 346 | ->assertSuccessful(); 347 | 348 | // only foobar operations will be processed because awesome tag does not match 349 | $this->artisan('operations:process --test --tag=awesome --tag=foobar') 350 | ->doesntExpectOutputToContain('2015_10_21_072800_null_tag_operation') 351 | ->doesntExpectOutputToContain('2015_10_21_072800_narf_puit_operation') 352 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') 353 | ->doesntExpectOutputToContain('No operations to process.') 354 | ->assertSuccessful(); 355 | 356 | // both operations will be processed because tag match 357 | $this->artisan('operations:process --test --tag=narfpuit --tag=foobar') 358 | ->doesntExpectOutputToContain('2015_10_21_072800_null_tag_operation') 359 | ->expectsOutputToContain('2015_10_21_072800_narf_puit_operation') 360 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') 361 | ->doesntExpectOutputToContain('No operations to process.') 362 | ->assertSuccessful(); 363 | 364 | // both operation will be processed because no tag is given 365 | $this->artisan('operations:process --test') 366 | ->expectsOutputToContain('2015_10_21_072800_null_tag_operation') 367 | ->expectsOutputToContain('2015_10_21_072800_narf_puit_operation') 368 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') 369 | ->doesntExpectOutputToContain('No operations to process.') 370 | ->assertSuccessful(); 371 | } 372 | 373 | public function test_operations_show_command() 374 | { 375 | // no files found 376 | $this->artisan('operations:show') 377 | ->expectsOutputToContain('No operations found.'); 378 | 379 | // create operations 380 | $this->artisan('operations:make FooBarOperation')->assertSuccessful(); 381 | 382 | // no files found 383 | $this->artisan('operations:show') 384 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation'); // PENDING 385 | 386 | $this->artisan('operations:process')->assertSuccessful(); 387 | 388 | $this->artisan('operations:show') 389 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation'); // PROCESSED 390 | 391 | // create operations 392 | $this->artisan('operations:make AwesomeOperation')->assertSuccessful(); 393 | 394 | // new pending operation added 395 | $this->artisan('operations:show') 396 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') // PROCESSED 397 | ->expectsOutputToContain('2015_10_21_072800_awesome_operation'); // PENDING 398 | 399 | $this->artisan('operations:process')->assertSuccessful(); 400 | 401 | // create operations 402 | $this->artisan('operations:make SuperiorOperation')->assertSuccessful(); 403 | 404 | // seconds operation was processed, third operation was added 405 | $this->artisan('operations:show') 406 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') // PROCESSED 407 | ->expectsOutputToContain('2015_10_21_072800_awesome_operation') // PROCESSED 408 | ->expectsOutputToContain('2015_10_21_072800_superior_operation'); // PENDING 409 | 410 | // delete first file 411 | File::delete($this->filepath('2015_10_21_072800_foo_bar_operation.php')); 412 | 413 | $this->artisan('operations:show') 414 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') // DISPOSED 415 | ->expectsOutputToContain('2015_10_21_072800_awesome_operation') // PROCESSED 416 | ->expectsOutputToContain('2015_10_21_072800_superior_operation'); // PENDING 417 | 418 | // filter disposed 419 | $this->artisan('operations:show disposed') 420 | ->expectsOutputToContain('2015_10_21_072800_foo_bar_operation') // DISPOSED 421 | ->doesntExpectOutputToContain('2015_10_21_072800_awesome_operation') // PROCESSED 422 | ->doesntExpectOutputToContain('2015_10_21_072800_superior_operation'); // PENDING 423 | 424 | // filter processed 425 | $this->artisan('operations:show processed') 426 | ->doesntExpectOutputToContain('2015_10_21_072800_foo_bar_operation') // DISPOSED 427 | ->expectsOutputToContain('2015_10_21_072800_awesome_operation') // PROCESSED 428 | ->doesntExpectOutputToContain('2015_10_21_072800_superior_operation'); // PENDING 429 | 430 | // filter pending 431 | $this->artisan('operations:show pending') 432 | ->doesntExpectOutputToContain('2015_10_21_072800_foo_bar_operation') // DISPOSED 433 | ->doesntExpectOutputToContain('2015_10_21_072800_awesome_operation') // PROCESSED 434 | ->expectsOutputToContain('2015_10_21_072800_superior_operation'); // PENDING 435 | 436 | // filter pending 437 | $this->artisan('operations:show stuff') 438 | ->assertFailed() 439 | ->expectsOutputToContain('Given filter is not valid. Allowed filters: pending|processed|disposed.'); 440 | } 441 | 442 | protected function filepath(string $filename): string 443 | { 444 | return base_path(config('one-time-operations.directory')).DIRECTORY_SEPARATOR.$filename; 445 | } 446 | 447 | protected function tearDown(): void 448 | { 449 | File::deleteDirectory(base_path(config('one-time-operations.directory'))); 450 | 451 | parent::tearDown(); 452 | } 453 | 454 | protected function editFile(string $filename, string $search, string $replace) 455 | { 456 | $filepath = $this->filepath($filename); 457 | $fileContent = File::get($filepath); 458 | $newContent = Str::replaceFirst($search, $replace, $fileContent); 459 | File::put($filepath, $newContent); 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /tests/Feature/OneTimeOperationCreatorTest.php: -------------------------------------------------------------------------------- 1 | assertFileExists($filepath); 22 | $this->assertInstanceOf(OneTimeOperationFile::class, $operationFile); 23 | $this->assertInstanceOf(OneTimeOperation::class, $operationFile->getClassObject()); 24 | $this->assertEquals('2015_10_21_072800_test_operation', $operationFile->getOperationName()); 25 | 26 | File::delete($filepath); 27 | } 28 | 29 | /** @test */ 30 | public function it_creates_an_operation_file_with_custom_stub() 31 | { 32 | $mockFile = File::partialMock(); 33 | $mockFile->allows('exists')->with(base_path('stubs/one-time-operation.stub'))->andReturnTrue(); 34 | $mockFile->allows('get')->with(base_path('stubs/one-time-operation.stub'))->andReturns('This is a custom stub'); 35 | 36 | $directory = Config::get('one-time-operations.directory'); 37 | $filepath = base_path($directory).DIRECTORY_SEPARATOR.'2015_10_21_072800_test_operation.php'; 38 | 39 | OneTimeOperationCreator::createOperationFile('TestOperation'); 40 | 41 | $this->assertFileExists($filepath); 42 | $this->assertStringContainsString('This is a custom stub', File::get($filepath)); 43 | 44 | File::delete($filepath); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Feature/OneTimeOperationManagerTest.php: -------------------------------------------------------------------------------- 1 | mockFileDirectory(); 23 | } 24 | 25 | protected function tearDown(): void 26 | { 27 | $this->deleteFileDirectory(); 28 | } 29 | 30 | public function test_get_directory_path() 31 | { 32 | $this->assertStringEndsWith('tests/files/', OneTimeOperationManager::getDirectoryPath()); // path was set by self::mockDirectory() 33 | } 34 | 35 | public function test_get_path_to_file_by_name() 36 | { 37 | $this->assertStringEndsWith('/tests/files/narfpuit.php', OneTimeOperationManager::pathToFileByName('narfpuit')); 38 | $this->assertStringEndsWith('/tests/files/20220101_223355_foobar.php', OneTimeOperationManager::pathToFileByName('20220101_223355_foobar')); 39 | } 40 | 41 | public function test_build_filename() 42 | { 43 | $this->assertEquals('foo.php', OneTimeOperationManager::buildFilename('foo')); 44 | $this->assertEquals('bar.php', OneTimeOperationManager::buildFilename('bar')); 45 | } 46 | 47 | public function test_get_operation_name_from_filename() 48 | { 49 | $this->assertEquals('20220223_foo', OneTimeOperationManager::getOperationNameFromFilename('20220223_foo.php')); 50 | $this->assertEquals('20220223_bar', OneTimeOperationManager::getOperationNameFromFilename('20220223_bar.php')); 51 | } 52 | 53 | public function test_get_table_name() 54 | { 55 | $this->assertEquals('operations', OneTimeOperationManager::getTableName()); // was set in parent::mockTable(); 56 | } 57 | 58 | public function test_get_operation_file_by_model() 59 | { 60 | $operationModel = Operation::factory()->make(['name' => self::TEST_OPERATION_NAME]); 61 | 62 | $operationFile = OneTimeOperationManager::getOperationFileByModel($operationModel); 63 | 64 | $this->assertInstanceOf(OneTimeOperationFile::class, $operationFile); 65 | $this->assertInstanceOf(OneTimeOperation::class, $operationFile->getClassObject()); 66 | $this->assertEquals($operationModel->name, $operationFile->getOperationName()); 67 | } 68 | 69 | public function test_get_operation_file_by_model_throws_exception() 70 | { 71 | $operationModel = Operation::factory()->make(['name' => 'file_does_not_exist']); // matching file does noe exist 72 | 73 | $this->expectException(FileNotFoundException::class); 74 | 75 | OneTimeOperationManager::getOperationFileByModel($operationModel); 76 | } 77 | 78 | public function test_get_class_object_by_name_missing_file() 79 | { 80 | $this->expectException(FileNotFoundException::class); 81 | 82 | OneTimeOperationManager::getClassObjectByName('file_does_not_exist'); 83 | } 84 | 85 | public function test_get_class_object_by_name() 86 | { 87 | $operationClass = OneTimeOperationManager::getClassObjectByName(self::TEST_OPERATION_NAME); 88 | 89 | $this->assertInstanceOf(OneTimeOperation::class, $operationClass); 90 | } 91 | 92 | public function test_get_all_operation_files() 93 | { 94 | $files = OneTimeOperationManager::getAllFiles(); 95 | 96 | /** @var SplFileInfo $firstFile */ 97 | $firstFile = $files->first(); 98 | /** @var SplFileInfo $secondFile */ 99 | $secondFile = $files->last(); 100 | 101 | $this->assertInstanceOf(Collection::class, $files); 102 | $this->assertCount(2, $files); 103 | 104 | $this->assertInstanceOf(SplFileInfo::class, $firstFile); 105 | $this->assertEquals('xxxx_xx_xx_xxxxxx_foo_bar.php', $firstFile->getBasename()); 106 | 107 | $this->assertInstanceOf(SplFileInfo::class, $secondFile); 108 | $this->assertEquals('xxxx_xx_xx_xxxxxx_narf_puit.php', $secondFile->getBasename()); 109 | } 110 | 111 | public function test_get_unprocessed_files() 112 | { 113 | $files = OneTimeOperationManager::getUnprocessedFiles(); 114 | 115 | /** @var SplFileInfo $firstFile */ 116 | $firstFile = $files->first(); 117 | 118 | $this->assertInstanceOf(Collection::class, $files); 119 | $this->assertCount(2, $files); 120 | 121 | $this->assertInstanceOf(SplFileInfo::class, $firstFile); 122 | 123 | // create entry for file #1 -> file is processed 124 | Operation::storeOperation('xxxx_xx_xx_xxxxxx_foo_bar', true); 125 | 126 | $files = OneTimeOperationManager::getUnprocessedFiles(); 127 | $this->assertCount(1, $files); 128 | 129 | // create entry for file #2 -> file is processed 130 | Operation::storeOperation('xxxx_xx_xx_xxxxxx_narf_puit', false); 131 | 132 | $files = OneTimeOperationManager::getUnprocessedFiles(); 133 | $this->assertCount(0, $files); 134 | } 135 | 136 | public function test_get_unprocessed_operation_files() 137 | { 138 | $files = OneTimeOperationManager::getUnprocessedOperationFiles(); 139 | 140 | $this->assertInstanceOf(Collection::class, $files); 141 | $this->assertCount(2, $files); 142 | 143 | /** @var SplFileInfo $firstFile */ 144 | $firstFile = $files->first(); 145 | 146 | $this->assertInstanceOf(OneTimeOperationFile::class, $firstFile); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Feature/OperationModelTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Operation::class, $operationModel); 18 | $this->assertEquals('foobar_amazing', $operationModel->name); 19 | $this->assertStringEndsWith('tests/files/foobar_amazing.php', $operationModel->file_path); 20 | $this->assertEquals('async', $operationModel->dispatched); 21 | $this->assertEquals('2015-10-21 07:28:00', $operationModel->processed_at); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/resources/xxxx_xx_xx_xxxxxx_foo_bar.php: -------------------------------------------------------------------------------- 1 |