├── .codeclimate.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── coverage.yml │ ├── php-cs-fixer.yml │ ├── phpunit.yml │ └── update-changelog.yml ├── .gitignore ├── .idea ├── .gitignore ├── blade.xml ├── codeStyles │ └── codeStyleConfig.xml ├── conveyor-belt.iml ├── inspectionProfiles │ └── Project_Default.xml ├── laravel-idea-personal.xml ├── laravel-idea.xml ├── modules.xml ├── php-test-framework.xml ├── php.xml ├── phpunit.xml └── vcs.xml ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── artisan ├── bin └── pre-commit.sh ├── composer.json ├── config.php ├── img ├── default.svg ├── diff.svg ├── more.svg ├── step.svg └── verbose.svg ├── phpunit.xml.dist ├── src ├── Belts │ ├── ConveyorBelt.php │ ├── EnumerableBelt.php │ ├── JsonBelt.php │ ├── QueryBelt.php │ └── SpreadsheetBelt.php ├── Concerns │ ├── InteractsWithOutputDuringProgress.php │ └── SetsUpConveyorBelt.php ├── Exceptions │ └── AbortConveyorBeltException.php ├── IteratesData.php ├── IteratesEnumerable.php ├── IteratesIdQuery.php ├── IteratesJson.php ├── IteratesQuery.php ├── IteratesSpreadsheet.php ├── RespectsVerbosity.php └── Support │ ├── CollectedException.php │ ├── ConveyorBeltServiceProvider.php │ └── ProgressBar.php ├── tests ├── Commands │ ├── Concerns │ │ └── CallsTestCallbacks.php │ ├── TestCommand.php │ ├── TestEnumerableCommand.php │ ├── TestIdQueryCommand.php │ ├── TestJsonEndpointCommand.php │ ├── TestJsonFileCommand.php │ ├── TestQueryCommand.php │ └── TestSpreadsheetCommand.php ├── Concerns │ ├── ArtificiallyFails.php │ ├── CallsTestCommands.php │ ├── ProvidesData.php │ ├── RegistersTestCallbacks.php │ └── TestsDatabaseTransactions.php ├── DatabaseTestCase.php ├── IteratesEnumerableTest.php ├── IteratesIdQueryTest.php ├── IteratesJsonTest.php ├── IteratesQueryTest.php ├── IteratesSpreadsheetTest.php ├── Models │ ├── Company.php │ └── User.php ├── PendingConveyorBeltCommand.php ├── TestCase.php ├── TestHelpersTest.php ├── database │ └── migrations │ │ └── 2022_01_18_131057_create_test_tables.php └── sources │ ├── botw.json │ ├── people-nested.json │ ├── people.csv │ ├── people.json │ └── people.xlsx └── translations └── en └── messages.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | exclude_patterns: 2 | - ".github/" 3 | - ".idea/" 4 | - "stubs/" 5 | - "tests/" 6 | - "**/vendor/" 7 | - "**/node_modules/" 8 | - "*.md" 9 | - ".*.yml" 10 | - "LICENSE" 11 | - "composer.json" 12 | - "phpunit.xml" 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **What version does this affect?** 14 | - Laravel Version: [e.g. 5.8.0] 15 | - Package Version: [e.g. 1.5.0] 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature idea or improvement 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | 12 | name: Publish code coverage 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.3 22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 23 | coverage: pcov 24 | 25 | - name: Get composer cache directory 26 | id: composer-cache 27 | run: | 28 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 29 | 30 | - name: Cache dependencies 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ steps.composer-cache.outputs.dir }} 34 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-composer- 37 | 38 | - name: Install dependencies 39 | env: 40 | COMPOSER_DISCARD_CHANGES: true 41 | run: composer require --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^11.0" 42 | 43 | - name: Run and publish code coverage 44 | uses: paambaati/codeclimate-action@v9.0 45 | env: 46 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 47 | with: 48 | coverageCommand: vendor/bin/phpunit --coverage-clover ${{ github.workspace }}/clover.xml 49 | coverageLocations: 50 | "${{github.workspace}}/clover.xml:clover" 51 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | 13 | name: Run code style checks 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.3 23 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 24 | coverage: none 25 | 26 | - name: Get composer cache directory 27 | id: composer-cache 28 | run: | 29 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 30 | 31 | - name: Cache dependencies 32 | uses: actions/cache@v4 33 | with: 34 | path: ${{ steps.composer-cache.outputs.dir }} 35 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-composer- 38 | 39 | - name: Install dependencies 40 | env: 41 | COMPOSER_DISCARD_CHANGES: true 42 | run: composer require --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^11.0" 43 | 44 | - name: Run PHP CS Fixer 45 | run: ./vendor/bin/php-cs-fixer fix --diff --dry-run 46 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: '0 14 * * 3' # Run Wednesdays at 2pm EST 10 | 11 | jobs: 12 | php-tests: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | php: [ 8.2, 8.3, 8.4 ] 18 | laravel: [ 10.*, 11.*, 12.* ] 19 | dependency-version: [ stable, lowest ] 20 | 21 | timeout-minutes: 10 22 | name: "${{ matrix.php }} / ${{ matrix.laravel }} (${{ matrix.dependency-version }})" 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v3 27 | 28 | - name: Setup PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: ${{ matrix.php }} 32 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 33 | tools: composer:v2 34 | 35 | - name: Register composer cache directory 36 | id: composer-cache 37 | run: | 38 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 39 | 40 | - name: Cache dependencies 41 | uses: actions/cache@v4 42 | with: 43 | path: ${{ steps.composer-cache.outputs.dir }} 44 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 45 | restore-keys: | 46 | ${{ runner.os }}-composer- 47 | 48 | - name: Install dependencies 49 | run: | 50 | composer require --no-interaction --prefer-dist --prefer-${{ matrix.dependency-version }} --update-with-all-dependencies "laravel/framework:${{ matrix.laravel }}" 51 | 52 | - name: Execute tests 53 | run: vendor/bin/phpunit 54 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Update Changelog 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | update-publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | repository: ${{ github.event.repository.full_name }} 14 | ref: 'main' 15 | 16 | - name: Update changelog 17 | uses: thomaseizinger/keep-a-changelog-new-release@v1 18 | with: 19 | version: ${{ github.event.release.tag_name }} 20 | 21 | - name: Commit changelog back to repo 22 | uses: EndBug/add-and-commit@v8 23 | with: 24 | add: 'CHANGELOG.md' 25 | message: ${{ github.event.release.tag_name }} 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.phar 3 | composer.lock 4 | .phpunit.result.cache 5 | .php-cs-fixer.cache 6 | 7 | .DS_Store 8 | .phpstorm.meta.php 9 | _ide_helper.php 10 | 11 | node_modules 12 | mix-manifest.json 13 | yarn-error.log 14 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/blade.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/conveyor-belt.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 134 | -------------------------------------------------------------------------------- /.idea/laravel-idea-personal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/laravel-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/php-test-framework.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 160 | 161 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 171 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 7 | ->setIndent("\t") 8 | ->setLineEnding("\n") 9 | ->setRules([ 10 | '@PSR2' => true, 11 | 'function_declaration' => [ 12 | 'closure_function_spacing' => 'none', 13 | 'closure_fn_spacing' => 'none', 14 | ], 15 | 'ordered_imports' => [ 16 | 'sort_algorithm' => 'alpha', 17 | ], 18 | 'array_indentation' => true, 19 | 'braces' => [ 20 | 'allow_single_line_closure' => true, 21 | ], 22 | 'no_break_comment' => false, 23 | 'return_type_declaration' => [ 24 | 'space_before' => 'none', 25 | ], 26 | 'blank_line_after_opening_tag' => true, 27 | 'compact_nullable_typehint' => true, 28 | 'cast_spaces' => true, 29 | 'concat_space' => [ 30 | 'spacing' => 'none', 31 | ], 32 | 'declare_equal_normalize' => [ 33 | 'space' => 'none', 34 | ], 35 | 'function_typehint_space' => true, 36 | 'new_with_braces' => true, 37 | 'method_argument_space' => true, 38 | 'no_empty_statement' => true, 39 | 'no_empty_comment' => true, 40 | 'no_empty_phpdoc' => true, 41 | 'no_extra_blank_lines' => [ 42 | 'tokens' => [ 43 | 'extra', 44 | 'use', 45 | 'use_trait', 46 | 'return', 47 | ], 48 | ], 49 | 'no_leading_import_slash' => true, 50 | 'no_leading_namespace_whitespace' => true, 51 | 'no_blank_lines_after_class_opening' => true, 52 | 'no_blank_lines_after_phpdoc' => true, 53 | 'no_whitespace_in_blank_line' => false, 54 | 'no_whitespace_before_comma_in_array' => true, 55 | 'no_useless_else' => true, 56 | 'no_useless_return' => true, 57 | 'single_trait_insert_per_statement' => true, 58 | 'psr_autoloading' => true, 59 | 'dir_constant' => true, 60 | 'single_line_comment_style' => [ 61 | 'comment_types' => ['hash'], 62 | ], 63 | 'include' => true, 64 | 'is_null' => true, 65 | 'linebreak_after_opening_tag' => true, 66 | 'lowercase_cast' => true, 67 | 'lowercase_static_reference' => true, 68 | 'magic_constant_casing' => true, 69 | 'magic_method_casing' => true, 70 | 'class_attributes_separation' => [ 71 | // TODO: This can be reverted when https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5869 is merged 72 | 'elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one'], 73 | ], 74 | 'modernize_types_casting' => true, 75 | 'native_function_casing' => true, 76 | 'native_function_type_declaration_casing' => true, 77 | 'no_alias_functions' => true, 78 | 'no_multiline_whitespace_around_double_arrow' => true, 79 | 'multiline_whitespace_before_semicolons' => true, 80 | 'no_short_bool_cast' => true, 81 | 'no_unused_imports' => true, 82 | 'no_php4_constructor' => true, 83 | 'no_singleline_whitespace_before_semicolons' => true, 84 | 'no_spaces_around_offset' => true, 85 | 'no_trailing_comma_in_list_call' => true, 86 | 'no_trailing_comma_in_singleline_array' => true, 87 | 'normalize_index_brace' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'phpdoc_annotation_without_dot' => true, 90 | 'phpdoc_indent' => true, 91 | 'phpdoc_no_package' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_useless_inheritdoc' => true, 94 | 'phpdoc_single_line_var_spacing' => true, 95 | 'phpdoc_trim' => true, 96 | 'phpdoc_types' => true, 97 | 'semicolon_after_instruction' => true, 98 | 'array_syntax' => [ 99 | 'syntax' => 'short', 100 | ], 101 | 'list_syntax' => [ 102 | 'syntax' => 'short', 103 | ], 104 | 'short_scalar_cast' => true, 105 | 'single_blank_line_before_namespace' => true, 106 | 'single_quote' => true, 107 | 'standardize_not_equals' => true, 108 | 'ternary_operator_spaces' => true, 109 | 'whitespace_after_comma_in_array' => true, 110 | 'not_operator_with_successor_space' => true, 111 | 'trailing_comma_in_multiline' => true, 112 | 'trim_array_spaces' => true, 113 | 'binary_operator_spaces' => true, 114 | 'unary_operator_spaces' => true, 115 | 'php_unit_method_casing' => [ 116 | 'case' => 'snake_case', 117 | ], 118 | 'php_unit_test_annotation' => [ 119 | 'style' => 'prefix', 120 | ], 121 | ]) 122 | ->setFinder( 123 | PhpCsFixer\Finder::create() 124 | ->exclude('.circleci') 125 | ->exclude('bin') 126 | ->exclude('node_modules') 127 | ->exclude('vendor') 128 | ->notPath('.phpstorm.meta.php') 129 | ->notPath('_ide_helper.php') 130 | ->notPath('artisan') 131 | ->in(__DIR__) 132 | ); 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes will be documented in this file following the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 4 | format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ## [2.1.0] - 2024-03-12 9 | 10 | ## [2.0.0] - 2023-07-03 11 | 12 | ### Added 13 | 14 | - Added support for `filterRow()` and `rejectRow()` methods 15 | 16 | ### Changed 17 | 18 | - Requires PHP 8.0 or higher 19 | - Moved from `box\sprout` to `opensprout\opensprout` and upgraded to version 4 20 | 21 | ## [1.0.0] - 2023-02-17 22 | 23 | ### Added 24 | 25 | - Added Laravel 10 support 26 | 27 | ## [0.3.3] - 2023-01-31 28 | 29 | ### Fixed 30 | 31 | - Fixed issue where query builder was getting re-used during tests 32 | - Improved docblock comments for IDE users 33 | 34 | ## [0.3.2] - 2022-11-11 35 | 36 | ### Fixed 37 | 38 | - Fixed signature compatibility issue introduced in Laravel 9.36.0 39 | 40 | ## [0.3.1] - 2022-02-18 41 | 42 | ### Fixed 43 | 44 | - Fixed an issue where the progress bar would reappear after new output 45 | 46 | ## [0.3.0] - 2022-02-16 47 | 48 | ### Added 49 | 50 | - Added better support for JSON APIs 51 | - Added support for any `Enumerable` object (like `Collection` or `LazyCollection`) 52 | 53 | ## [0.2.0] - 2022-02-16 54 | 55 | ### Added 56 | 57 | - Added support for JSON files 58 | - Added support for CSV files 59 | - Added support for Excel spreadsheets 60 | 61 | ### Changed 62 | 63 | - All configuration has been moved to command properties rather than functions (see README.md for more info) 64 | - Refactored most of the internals to support many more source types 65 | - Exceptions will always be shown even when `$collect_exceptions` is enabled. If `$collect_exceptions` 66 | is enabled, Conveyor Belt will _also_ show the exceptions at the end of execution 67 | 68 | ## [0.1.0] - 2022-01-31 69 | 70 | ### Added 71 | 72 | - Added config 73 | - Added translations 74 | - Added a `--pause-on-error` option 75 | 76 | ## [0.0.1] - 2022-01-28 77 | 78 | ### Added 79 | 80 | - Initial release 81 | 82 | # Keep a Changelog Syntax 83 | 84 | - `Added` for new features. 85 | - `Changed` for changes in existing functionality. 86 | - `Deprecated` for soon-to-be removed features. 87 | - `Removed` for now removed features. 88 | - `Fixed` for any bug fixes. 89 | - `Security` in case of vulnerabilities. 90 | 91 | [Unreleased]: https://github.com/glhd/conveyor-belt/compare/2.1.0...HEAD 92 | 93 | [2.1.0]: https://github.com/glhd/conveyor-belt/compare/2.0.0...2.1.0 94 | 95 | [2.0.0]: https://github.com/glhd/conveyor-belt/compare/1.0.0...2.0.0 96 | 97 | [1.0.0]: https://github.com/glhd/conveyor-belt/compare/0.3.3...1.0.0 98 | 99 | [0.3.3]: https://github.com/glhd/conveyor-belt/compare/0.3.2...0.3.3 100 | 101 | [0.3.2]: https://github.com/glhd/conveyor-belt/compare/0.3.1...0.3.2 102 | 103 | [0.3.1]: https://github.com/glhd/conveyor-belt/compare/0.3.0...0.3.1 104 | 105 | [0.3.0]: https://github.com/glhd/conveyor-belt/compare/0.2.0...0.3.0 106 | 107 | [0.2.0]: https://github.com/glhd/conveyor-belt/compare/0.1.0...0.2.0 108 | 109 | [0.1.0]: https://github.com/glhd/conveyor-belt/compare/0.0.1...0.1.0 110 | 111 | [0.0.1]: https://github.com/glhd/conveyor-belt/compare/0.0.1...0.0.1 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Galahad 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 |
2 | 3 | Build Status 7 | 8 | 9 | Coverage Status 13 | 14 | 15 | Latest Stable Release 19 | 20 | 21 | MIT Licensed 25 | 26 | 27 | Follow @inxilpro on Twitter 31 | 32 |
33 | 34 | # Conveyor Belt 35 | 36 | Conveyor Belt provides all the underlying mechanics necessary to write artisan commands that process lots of data efficiently. 37 | 38 | ## Quickly process 1000's of records 39 | ![Screencast of default behavior](img/default.svg) 40 | 41 | ## Get verbose output when necessary 42 | ![Screencast of verbose behavior](img/verbose.svg) 43 | 44 | ## Step through execution & log operations if needed 45 | ![Screencast of step behavior](img/step.svg) 46 | 47 | ## See what data is changed by your commands 48 | ![Screencast of diff behavior](img/diff.svg) 49 | 50 | ## And so much more 51 | ![Screencast of help view](img/more.svg) 52 | 53 | ## Installation 54 | 55 | ```shell 56 | # composer require glhd/conveyor-belt 57 | ``` 58 | 59 | ## Usage 60 | 61 | To use Conveyor Belt, use one of the conveyor belt traits in your Laravel command: 62 | 63 | ### Databases 64 | 65 | - `\Glhd\ConveyorBelt\IteratesIdQuery` — use this if your underlying query can be ordered by `id` (improves performance) 66 | - `\Glhd\ConveyorBelt\IteratesQuery` — use this if your query **is not ordered by `id`** 67 | 68 | ### Files 69 | 70 | - `\Glhd\ConveyorBelt\IteratesSpreadsheet` — use this to read CSV or Excel files 71 | - `\Glhd\ConveyorBelt\IteratesJson` — use this to read JSON files or JSON API data 72 | 73 | ### Other 74 | 75 | - `\Glhd\ConveyorBelt\IteratesEnumerable` — use this to work with any generic data source 76 | 77 | ## Configuration 78 | 79 | Most commands can be configured by setting public properties on the command itself. For example, if you want 80 | to enable exception handling, you would add `public $collect_exceptions = true;` to your command. Each config 81 | option can also be managed by overriding a function (if you need more dynamic control over its value). See the 82 | source of each trait to find the appropriate function name. 83 | 84 | ### Common for all commands 85 | 86 | - `$collect_exceptions` — set to `true` to have your command continue to run if an exception is triggered 87 | (the exception will be printed at the end of command execution) 88 | - `$row_name` — set this to customize command output (e.g. if you're operating on `User` models you could 89 | set this to `"user"`) 90 | - `$row_name_plural` — the plural of `$row_name` (usually not necessary, as we use `Str::plural` for you) 91 | 92 | ### `IteratesQuery` 93 | 94 | - `$chunk_size` — the number of database records to load at one time 95 | - `$use_transaction` — whether to run the whole command inside a database transaction (can cause locking 96 | issues if your command runs for a long time) 97 | 98 | ### `IteratesIdQuery` 99 | 100 | The `IteratesIdQuery` trait accepts all the options that `IteratesQuery` does, as well as: 101 | 102 | - `$id_column` — the name of your ID column (if it is not `"id"`) 103 | - `$id_alias` — the alias to your ID column in your query 104 | 105 | ### `IteratesSpreadsheet` 106 | 107 | - `$use_headings` — whether to treat the first row of each sheet as headings 108 | - `$preserve_empty_rows` — whether empty rows should be included 109 | - `$format_dates` — whether date columns should be formatted (typically you don't need this because Conveyor Belt 110 | automatically converts date cells to `Carbon` instances for you) 111 | - `$filename` — the file to load (only set if this is not dynamic in any way, which is unusual) 112 | - `$excel_temp_directory` — set if you need to customize where temp files are stored 113 | - `$field_delimiter` — change this if you need to import non-standard CSV files (e.g. tab-delimited) 114 | - `$field_enclosure` — change this if you need to import non-standard CSV files (that don't use the `"` character) 115 | - `$spreadsheet_encoding` — change this if you're dealing with non-UTF-8 data 116 | - `$heading_format` — Change this to any `Str::` function to change the format of your array keys (`"snake"` by default) 117 | 118 | ### `IteratesJson` 119 | 120 | - `$filename` — the file to load (only set if this is not dynamic in any way, which is unusual) 121 | - `$json_endpoint` — the JSON endpoint to query for data (use `getJsonEndpoint` to set this dynamically) 122 | - `$json_pointer` — use this to iterate over nested JSON data ([see spec](https://datatracker.ietf.org/doc/html/rfc6901)) 123 | 124 | ## Examples 125 | 126 | ### Database Example 127 | 128 | ```php 129 | class ProcessUnverifiedUsers extends Command 130 | { 131 | use \Glhd\ConveyorBelt\IteratesIdQuery; 132 | 133 | // By setting $collect_exceptions to true, we tell Conveyor Belt to catch 134 | // and log exceptions for display, rather than aborting execution 135 | public $collect_exceptions = true; 136 | 137 | // First, set up the query for the data that your command will operate on. 138 | // In this example, we're querying for all users that haven't verified their emails. 139 | public function query() 140 | { 141 | return User::query() 142 | ->whereNull('email_verified_at') 143 | ->orderBy('id'); 144 | } 145 | 146 | // Then, set up a handler for each row. Our example command is either going to 147 | // remind users to verify their email (if they signed up recently), or queue 148 | // a job to prune them from the database. 149 | public function handleRow(User $user) 150 | { 151 | // The `progressMessage()` method updates the progress bar in normal mode, 152 | // or prints the message in verbose/step mode 153 | $this->progressMessage("{$user->name} <{$user->email}>"); 154 | 155 | $days = $user->created_at->diffInDays(now()); 156 | 157 | // The `progressSubMessage()` method adds additional context. If you're in 158 | // normal mode, this gets appended to the `progressMessage()`. In verbose or 159 | // step mode, this gets added as a list item below your `progressMessage()` 160 | $this->progressSubMessage('Registered '.$days.' '.Str::plural('day', $days).' ago…'); 161 | 162 | // Sometimes our command trigger exceptions. Conveyor Belt makes it easy 163 | // to handle them and not have to lose all our progress 164 | ThirdParty::checkSomethingThatMayFail(); 165 | 166 | if (1 === $days) { 167 | $this->progressSubmessage('Sending reminder'); 168 | Mail::send(new EmailVerificationReminderMail($user)); 169 | } 170 | 171 | if ($days >= 7) { 172 | $this->progressSubmessage('Queuing to be pruned'); 173 | PruneUnverifiedUserJob::dispatch($user); 174 | } 175 | } 176 | } 177 | ``` 178 | 179 | ### File Example 180 | 181 | ```php 182 | class ProcessSignUpSheet extends Command 183 | { 184 | use \Glhd\ConveyorBelt\IteratesSpreadsheet; 185 | 186 | // Conveyor Belt will automatically pick up a "filename" argument. If one 187 | // is missing you can set a $filename property or implement the getSpreadsheetFilename method 188 | protected $signature = 'process:sign-up-sheet {filename}'; 189 | 190 | public function handleRow($item) 191 | { 192 | // $item is an object keyed by the spreadsheet headings in snake_case, 193 | // so for example, the following CSV: 194 | // 195 | // Full Name, Sign Up Date, Email 196 | // Chris Morrell, 2022-01-02, chris@mailinator.com 197 | // 198 | // Will result in a full_name, sign_up_date, and email property 199 | // on the $item object. You can change from snake case to any other 200 | // string helper format by setting $heading_format 201 | } 202 | } 203 | ``` 204 | 205 | The `IteratesJson` trait works exactly the same as the `IteratesSpreadsheet` trait, just 206 | with different configuration options. 207 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 11 | 12 | $status = $kernel->handle( 13 | $input = new Symfony\Component\Console\Input\ArgvInput(), 14 | new Symfony\Component\Console\Output\ConsoleOutput() 15 | ); 16 | 17 | $kernel->terminate($input, $status); 18 | 19 | exit($status); 20 | -------------------------------------------------------------------------------- /bin/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Move into project root 4 | BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 | cd "$BIN_DIR" 6 | cd .. 7 | 8 | # Exit on errors 9 | set -e 10 | 11 | CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '***.php') 12 | 13 | if [[ -z "$CHANGED_FILES" ]]; then 14 | echo 'No changed files' 15 | exit 0 16 | fi 17 | 18 | if [[ -x vendor/bin/php-cs-fixer ]]; then 19 | vendor/bin/php-cs-fixer fix $CHANGED_FILES 20 | git add $CHANGED_FILES 21 | else 22 | echo 'PHP-CS-Fixer is not installed' 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glhd/conveyor-belt", 3 | "description": "", 4 | "keywords": [ 5 | "laravel" 6 | ], 7 | "authors": [ 8 | { 9 | "name": "Chris Morrell", 10 | "homepage": "http://www.cmorrell.com" 11 | } 12 | ], 13 | "type": "library", 14 | "license": "MIT", 15 | "require": { 16 | "php": ">= 8.0", 17 | "illuminate/support": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", 18 | "illuminate/collections": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", 19 | "illuminate/console": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", 20 | "illuminate/http": "^8|^9|^10|^11|^12|13.x-dev|dev-master|dev-main", 21 | "symfony/console": "^5.4|^6.0|^7.0", 22 | "ext-json": "*", 23 | "jdorn/sql-formatter": "^1.2", 24 | "halaxa/json-machine": "^1.0", 25 | "openspout/openspout": "^4.0", 26 | "guzzlehttp/guzzle": "^7.0" 27 | }, 28 | "require-dev": { 29 | "orchestra/testbench": "^6.24|^7.10|^8.33|^9.11|^10.0|11.x-dev|dev-master|dev-main", 30 | "friendsofphp/php-cs-fixer": "^3.0", 31 | "mockery/mockery": "^1.3", 32 | "phpunit/phpunit": "^10.5|^11.5" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Glhd\\ConveyorBelt\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "classmap": [ 41 | "tests/TestCase.php" 42 | ], 43 | "psr-4": { 44 | "Glhd\\ConveyorBelt\\Tests\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "fix-style": "vendor/bin/php-cs-fixer fix", 49 | "check-style": "vendor/bin/php-cs-fixer fix --diff --dry-run" 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "Glhd\\ConveyorBelt\\Support\\ConveyorBeltServiceProvider" 55 | ] 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | false, 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Chunk Size 20 | |-------------------------------------------------------------------------- 21 | | 22 | | By default, Conveyor Belt will run your queries in 1000 record chunks. 23 | | You can change the default chunk size here. 24 | | 25 | */ 26 | 27 | 'chunk_count' => 1000, 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Progress Bar Format 32 | |-------------------------------------------------------------------------- 33 | | 34 | | The default Conveyor Belt progress bar will show a detailed summary 35 | | of your command's progress. You can update the format used here. 36 | | 37 | | See: https://symfony.com/doc/current/components/console/helpers/progressbar.html#custom-formats 38 | | 39 | */ 40 | 41 | 'progress_formats' => [ 42 | 'base' => '%bar% %current% %message%', 43 | 'base_with_memory' => '%bar% %current% (%memory%) %message%', 44 | 'count' => '%bar% %current%/%max% (~%remaining%) %message%', 45 | 'count_with_memory' => '%bar% %current%/%max% (%memory%, ~%remaining%) %message%', 46 | ], 47 | ]; 48 | -------------------------------------------------------------------------------- /img/default.svg: -------------------------------------------------------------------------------- 1 | $$phpartisanusers:process-unverifiedQueryingrecords(nodatabasetransaction)…Processing100records…▓▓▓▓▓▓▓▓100/100(~<1sec)YvetteMorissetteIV<brekke.ellie@yahoo.com>Queuingtobepruned…$ph░░░░░░░░░░░░░░░░░░░░░░░░░░░░0/100(~<1sec)▓░░░░░░░░░░░░10/100(~9secs)XavierKingJr.<kaylie.eichmann@hotmail.com>Queuingtobepruned…▓▓░░░░░░░░░░░20/100(~4secs)RileyCremin<herzog.bennie@gutmann.com>Queuingtobepruned…▓▓░░░░░░░30/100(~2secs)TimmothyWilliamson<sandy.tremblay@kshlerin.com>Queuingtobepruned…▓▓▓░░░░░░40/100(~3secs)DamianBerge<lora77@mayer.com>Queuingtobepruned…▓▓▓▓░░░░░50/100(~2secs)AveryTreutel<osinski.coralie@okuneva.com>Queuingtobepruned…▓▓▓▓▓░░░░60/100(~1sec)UrsulaFisher<gritchie@yahoo.com>Queuingtobepruned…▓▓▓▓▓░░░70/100(~1sec)Prof.GerardoOndricka<buckridge.peyton@yahoo.com>Queuingtobepruned…▓▓▓▓▓▓░░80/100(~1sec)KaelaStanton<smith.maryam@hotmail.com>Queuingtobepruned…▓▓▓▓▓▓▓░90/100(~<1sec)AureliaWaelchi<priscilla29@hotmail.com>Queuingtobepruned…$exit -------------------------------------------------------------------------------- /img/diff.svg: -------------------------------------------------------------------------------- 1 | $$phpartisanusers:fix-email$phpartisanusers:fix-email--diff$phpartisanusers:fix-email--diff--stepQueryingrecords(nodatabasetransaction)…Processing100records…RosellaStokesChangestoRecord┌────────────┬─────────────────────┬─────────────────────┐BeforeAfter├────────────┼─────────────────────┼─────────────────────┤emailrosella@foo.comrosella@foo.comupdated_at2022-01-3119:48:482022-01-3119:49:23└────────────┴─────────────────────┴─────────────────────┘Continue?(yes/no)[yes]:>>yJodieWhiteemailjodie@foo.comjodie@foo.comupdated_at2022-01-3119:48:512022-01-3119:49:24Prof.CamrenHauckSr.emailprof.@foo.comprof.@foo.comupdated_at2022-01-3119:48:532022-01-3119:49:26>noOperation cancelled.$phpartisan$phpartisanusers:fix-email-$phpartisanusers:fix-email--$phpartisanusers:fix-email--d$phpartisanusers:fix-email--di$phpartisanusers:fix-email--dif$phpartisanusers:fix-email--diff-$phpartisanusers:fix-email--diff--$phpartisanusers:fix-email--diff--s$phpartisanusers:fix-email--diff--st$phpartisanusers:fix-email--diff--ste>n$exit -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ./src 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Belts/ConveyorBelt.php: -------------------------------------------------------------------------------- 1 | command = $command; 42 | 43 | $this->addConveyorBeltOptions($command->getDefinition()); 44 | } 45 | 46 | public function initialize(InputInterface $input, OutputStyle $output): void 47 | { 48 | $this->input = $input; 49 | $this->output = $output; 50 | 51 | $this->progress = new ProgressBar($input, $output); 52 | } 53 | 54 | public function handle(): int 55 | { 56 | $this->newLine(); 57 | 58 | try { 59 | $this->prepare(); 60 | $this->header(); 61 | $this->start(); 62 | $this->run(); 63 | $this->finish(); 64 | 65 | return Command::SUCCESS; 66 | } catch (AbortConveyorBeltException $exception) { 67 | if (! empty($message = $exception->getMessage())) { 68 | $this->error($message); 69 | } 70 | 71 | return $exception->getCode(); 72 | } finally { 73 | $this->newLine(); 74 | } 75 | } 76 | 77 | protected function prepare(): void 78 | { 79 | $this->verifyCommandSetup(); 80 | $this->setVerbosityBasedOnOptions(); 81 | 82 | if (method_exists($this->command, 'beforeFirstRow')) { 83 | $this->command->beforeFirstRow(); 84 | } 85 | } 86 | 87 | protected function header(): void 88 | { 89 | $this->info(trans('conveyor-belt::messages.querying', ['records' => $this->command->getRowNamePlural()])); 90 | } 91 | 92 | protected function start(): void 93 | { 94 | $count = null; 95 | 96 | if ($this instanceof Countable && ! $count = $this->count()) { 97 | $this->command->info(trans('conveyor-belt::messages.no_matches', ['records' => $this->command->getRowNamePlural()])); 98 | return; 99 | } 100 | 101 | $this->progress->start($count, $this->command->getRowName(), $this->command->getRowNamePlural()); 102 | } 103 | 104 | protected function run(): void 105 | { 106 | // Implementations may need to wrap the execution in another 107 | // process (like a transaction), so we'll add a layer here for extension 108 | 109 | $this->execute(); 110 | } 111 | 112 | protected function execute(): void 113 | { 114 | $this->collect() 115 | ->filter(fn($item) => $this->filter($item)) 116 | ->each(fn($item) => $this->handleRow($item)); 117 | } 118 | 119 | protected function filter($item): bool 120 | { 121 | $filtered = null; 122 | if (method_exists($this->command, 'filterRow')) { 123 | $filtered = $this->command->filterRow($item); 124 | } 125 | 126 | $rejected = null; 127 | if (method_exists($this->command, 'rejectRow')) { 128 | $rejected = $this->command->rejectRow($item); 129 | } 130 | 131 | if (true === $filtered && true === $rejected) { 132 | throw new LogicException('The results from "filterRow" and "rejectRow" conflict.'); 133 | } 134 | 135 | return false !== $filtered && true !== $rejected; 136 | } 137 | 138 | protected function finish(): void 139 | { 140 | $this->progress->finish(); 141 | 142 | if (method_exists($this->command, 'afterLastRow')) { 143 | $this->command->afterLastRow(); 144 | } 145 | 146 | $this->showCollectedExceptions(); 147 | } 148 | 149 | protected function abort(string $message = '', int $code = Command::FAILURE): void 150 | { 151 | throw new AbortConveyorBeltException($message, $code); 152 | } 153 | 154 | protected function handleRow($item): bool 155 | { 156 | $original = $this->getOriginalForDiff($item); 157 | 158 | try { 159 | $this->command->handleRow($item); 160 | } catch (PhpUnitException $exception) { 161 | throw $exception; 162 | } catch (Throwable $throwable) { 163 | $this->handleRowException($throwable, $item); 164 | } 165 | 166 | $this->progress->advance(); 167 | 168 | $this->afterRow($item, $original); 169 | 170 | return true; 171 | } 172 | 173 | protected function afterRow($item, array $original): void 174 | { 175 | $this->logDiff($item, $original); 176 | $this->pauseIfStepping(); 177 | } 178 | 179 | protected function handleRowException(Throwable $exception, $item): void 180 | { 181 | if ($this->shouldThrowRowException()) { 182 | $this->progress->finish(); 183 | throw $exception; 184 | } 185 | 186 | $this->printError($exception); 187 | $this->pauseOnErrorIfRequested(); 188 | 189 | if ($this->command->shouldCollectExceptions()) { 190 | $this->exceptions[] = new CollectedException($exception, $item); 191 | } 192 | } 193 | 194 | protected function shouldThrowRowException(): bool 195 | { 196 | return ! $this->command->shouldCollectExceptions() 197 | && ! $this->option('pause-on-error'); 198 | } 199 | 200 | protected function printError(Throwable $exception): void 201 | { 202 | $message = $this->output->isVerbose() || $this->option('pause-on-error') 203 | ? (string) $exception 204 | : get_class($exception).': '.$exception->getMessage(); 205 | 206 | $this->progress->interrupt(fn() => $this->error($message)); 207 | } 208 | 209 | protected function pauseOnErrorIfRequested(): void 210 | { 211 | if (! $this->option('pause-on-error')) { 212 | return; 213 | } 214 | 215 | $this->progress->pause(); 216 | 217 | if (! $this->confirm(trans('conveyor-belt::messages.confirm_continue'))) { 218 | $this->progress->finish(); 219 | $this->abort(trans('conveyor-belt::messages.operation_cancelled')); 220 | } 221 | 222 | $this->progress->resume(); 223 | } 224 | 225 | protected function getOriginalForDiff($item): array 226 | { 227 | if (! $item instanceof Model || ! $this->option('diff')) { 228 | return []; 229 | } 230 | 231 | return $item->getOriginal(); 232 | } 233 | 234 | protected function logDiff($item, array $original): void 235 | { 236 | if (! $this->option('diff')) { 237 | return; 238 | } 239 | 240 | if (! $item instanceof Model) { 241 | $this->abort('The --diff flag requires Eloquent models'); 242 | } 243 | 244 | if (empty($changes = $item->getChanges())) { 245 | return; 246 | } 247 | 248 | $table = collect($changes)->map(fn($value, $key) => ["{$key}", $original[$key] ?? null, $value]); 249 | 250 | $this->progress->pause(); 251 | 252 | $this->newLine(); 253 | 254 | $this->line(trans('conveyor-belt::messages.changes_to_record', ['record' => $this->command->getRowName()])); 255 | $this->table([trans('conveyor-belt::messages.column_heading'), trans('conveyor-belt::messages.before_heading'), trans('conveyor-belt::messages.after_heading')], $table); 256 | 257 | $this->progress->resume(); 258 | } 259 | 260 | protected function pauseIfStepping(): void 261 | { 262 | if ($this->option('step') && ! $this->confirm(trans('conveyor-belt::messages.confirm_continue'), true)) { 263 | $this->abort(trans('conveyor-belt::messages.operation_cancelled')); 264 | } 265 | } 266 | 267 | protected function verifyCommandSetup(): void 268 | { 269 | if (! method_exists($this->command, 'handleRow')) { 270 | $this->abort('You must implement '.class_basename($this->command).'::handleRow()', Command::INVALID); 271 | } 272 | } 273 | 274 | protected function setVerbosityBasedOnOptions(): void 275 | { 276 | if ($this->option('step')) { 277 | $this->output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); 278 | } 279 | } 280 | 281 | protected function showCollectedExceptions(): void 282 | { 283 | if (! $count = count($this->exceptions)) { 284 | return; 285 | } 286 | 287 | $this->newLine(); 288 | 289 | $this->error(trans_choice('conveyor-belt::messages.exceptions_triggered', $count)); 290 | 291 | $headers = [ 292 | Str::title($this->command->getRowName()), 293 | trans('conveyor-belt::messages.exception_heading'), 294 | trans('conveyor-belt::messages.message_heading'), 295 | ]; 296 | 297 | $rows = collect($this->exceptions) 298 | ->map(fn(CollectedException $exception) => [$exception->key, get_class($exception->exception), (string) $exception]); 299 | 300 | $this->table($headers, $rows); 301 | 302 | $this->abort(); 303 | } 304 | 305 | public function table($headers, $rows, $tableStyle = 'box', array $columnStyles = []) 306 | { 307 | $this->defaultTable($headers, $rows, $tableStyle, $columnStyles); 308 | } 309 | 310 | protected function addConveyorBeltOptions(InputDefinition $definition): void 311 | { 312 | $definition->addOption(new InputOption('step', null, null, "Step through each {$this->command->getRowName()} one-by-one")); 313 | $definition->addOption(new InputOption('diff', null, null, 'See a diff of any changes made to your models')); 314 | $definition->addOption(new InputOption('show-memory-usage', null, null, 'Include the command’s memory usage in the progress bar')); 315 | $definition->addOption(new InputOption('pause-on-error', null, null, 'Pause if an exception is thrown')); 316 | } 317 | } 318 | -------------------------------------------------------------------------------- /src/Belts/EnumerableBelt.php: -------------------------------------------------------------------------------- 1 | command->collect(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Belts/JsonBelt.php: -------------------------------------------------------------------------------- 1 | command->getItems($this->getItemsOptions())); 16 | } 17 | 18 | protected function getItemsOptions(): array 19 | { 20 | $config = []; 21 | 22 | if ($pointer = $this->command->getJsonPointer()) { 23 | $config['pointer'] = $pointer; 24 | } 25 | 26 | return $config; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Belts/QueryBelt.php: -------------------------------------------------------------------------------- 1 | query()->count(); 28 | } 29 | 30 | protected function prepare(): void 31 | { 32 | // Because commands are instantiated only once, we need to reset this 33 | // so that they same query isn't re-used in tests 34 | $this->query = null; 35 | 36 | // We need to prepare for query logging first, because if this flag is 37 | // turned on, it will implicitly turn on the --step option as well 38 | $this->prepareForQueryLogging(); 39 | 40 | parent::prepare(); 41 | 42 | // Once everything else is prepared, we'll check for the --dump-sql 43 | // flag and if it's set, print the query and exit 44 | $this->dumpSqlAndAbortIfRequested(); 45 | } 46 | 47 | protected function header(): void 48 | { 49 | $message = $this->command->shouldUseTransaction() 50 | ? trans('conveyor-belt::messages.querying_with_transaction', ['records' => $this->command->getRowNamePlural()]) 51 | : trans('conveyor-belt::messages.querying_without_transaction', ['records' => $this->command->getRowNamePlural()]); 52 | 53 | $this->info($message); 54 | } 55 | 56 | protected function collect(): Enumerable 57 | { 58 | return $this->command->queryToEnumerable($this->query()); 59 | } 60 | 61 | protected function run(): void 62 | { 63 | if ($this->command->shouldUseTransaction()) { 64 | DB::transaction(fn() => $this->execute()); 65 | } else { 66 | $this->execute(); 67 | } 68 | } 69 | 70 | protected function execute(): void 71 | { 72 | $this->command->beforeFirstQuery(); 73 | 74 | parent::execute(); 75 | } 76 | 77 | protected function afterRow($item, array $original): void 78 | { 79 | $this->logSql(); 80 | 81 | parent::afterRow($item, $original); 82 | } 83 | 84 | protected function logSql(): void 85 | { 86 | if (! $this->option('log-sql')) { 87 | return; 88 | } 89 | 90 | $table = collect(DB::getQueryLog()) 91 | ->map(fn($log) => [$this->getFormattedQuery($log['query'], $log['bindings']), $log['time']]); 92 | 93 | if ($table->isEmpty()) { 94 | return; 95 | } 96 | 97 | $this->newLine(); 98 | $this->line(trans_choice('conveyor-belt::messages.queries_executed', $table->count())); 99 | $this->table([trans('conveyor-belt::messages.query_heading'), trans('conveyor-belt::messages.time_heading')], $table); 100 | 101 | DB::flushQueryLog(); 102 | } 103 | 104 | protected function prepareForQueryLogging(): void 105 | { 106 | if ($this->option('log-sql')) { 107 | $this->input->setOption('step', true); 108 | DB::enableQueryLog(); 109 | } 110 | } 111 | 112 | protected function dumpSqlAndAbortIfRequested(): void 113 | { 114 | if (! $this->option('dump-sql')) { 115 | return; 116 | } 117 | 118 | $query = $this->query(); 119 | $this->printFormattedQuery($query->toSql(), $query->getBindings()); 120 | 121 | $this->abort(); 122 | } 123 | 124 | protected function printFormattedQuery(string $sql, array $bindings): void 125 | { 126 | $this->newLine(); 127 | 128 | $this->line($this->getFormattedQuery($sql, $bindings)); 129 | } 130 | 131 | protected function getFormattedQuery(string $sql, array $bindings): string 132 | { 133 | $bindings = Arr::flatten($bindings); 134 | 135 | $sql = preg_replace_callback('/\?/', static function() use (&$bindings) { 136 | return DB::getPdo()->quote(array_shift($bindings)); 137 | }, $sql); 138 | 139 | return SqlFormatter::format($sql); 140 | } 141 | 142 | protected function addConveyorBeltOptions(InputDefinition $definition): void 143 | { 144 | parent::addConveyorBeltOptions($definition); 145 | 146 | $definition->addOption(new InputOption('dump-sql', null, null, 'Dump the SQL of the query this command will execute')); 147 | $definition->addOption(new InputOption('log-sql', null, null, 'Log all SQL queries executed and print them')); 148 | } 149 | 150 | /** 151 | * @return BaseBuilder|EloquentBuilder|Relation 152 | */ 153 | protected function query() 154 | { 155 | return $this->query ??= $this->fetchQueryFromCommand(); 156 | } 157 | 158 | protected function fetchQueryFromCommand() 159 | { 160 | if (! method_exists($this->command, 'query')) { 161 | $this->abort('You must implement '.class_basename($this->command).'::query()', Command::INVALID); 162 | } 163 | 164 | $query = $this->command->query(); 165 | 166 | $expected = [ 167 | BaseBuilder::class, 168 | EloquentBuilder::class, 169 | Relation::class, 170 | ]; 171 | 172 | foreach ($expected as $name) { 173 | if ($query instanceof $name) { 174 | return $query; 175 | } 176 | } 177 | 178 | $this->abort(class_basename($this->command).'::query() must return a query builder', Command::INVALID); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Belts/SpreadsheetBelt.php: -------------------------------------------------------------------------------- 1 | reader(); 27 | 28 | $reader->open($this->command->getSpreadsheetFilename()); 29 | 30 | foreach ($reader->getSheetIterator() as $sheet) { 31 | $this->headings = null; 32 | foreach ($sheet->getRowIterator() as $row) { 33 | if ($result = $this->mapRow($row)) { 34 | yield $result; 35 | } 36 | } 37 | } 38 | 39 | $reader->close(); 40 | }); 41 | } 42 | 43 | protected function mapRow(Row $row) 44 | { 45 | if (null === $this->headings && $this->command->shouldUseHeadings()) { 46 | $this->setHeadings($row); 47 | return null; 48 | } 49 | 50 | return $this->command->mapCells($row->getCells(), $this->headings); 51 | } 52 | 53 | protected function setHeadings(Row $row): void 54 | { 55 | $this->headings = $this->command->mapHeadings($row->getCells()); 56 | } 57 | 58 | protected function reader(): ReaderInterface 59 | { 60 | $path = $this->command->getSpreadsheetFilename(); 61 | 62 | $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); 63 | 64 | return match ($extension) { 65 | 'csv' => $this->createCsvReader(), 66 | 'xlsx' => $this->createXlsxReader(), 67 | 'ods' => $this->createOdsReader(), 68 | default => $this->abort("Unable to determine spreadsheet type for '{$path}'"), 69 | }; 70 | } 71 | 72 | protected function createCsvReader(): CsvReader 73 | { 74 | $options = new CsvOptions(); 75 | $options->ENCODING = $this->command->getSpreadsheetEncoding(); 76 | $options->FIELD_DELIMITER = $this->command->getFieldDelimiter(); 77 | $options->FIELD_ENCLOSURE = $this->command->getFieldEnclosure(); 78 | $options->SHOULD_PRESERVE_EMPTY_ROWS = $this->command->shouldPreserveEmptyRows(); 79 | 80 | return new CsvReader($options); 81 | } 82 | 83 | protected function createXlsxReader(): XlsxReader 84 | { 85 | $options = new XlsxOptions(); 86 | $options->SHOULD_PRESERVE_EMPTY_ROWS = $this->command->shouldPreserveEmptyRows(); 87 | $options->SHOULD_FORMAT_DATES = $this->command->shouldFormatDates(); 88 | $options->setTempFolder($this->command->getExcelTempDirectory()); 89 | 90 | return new XlsxReader($options); 91 | } 92 | 93 | protected function createOdsReader(): OdsReader 94 | { 95 | $options = new OdsOptions(); 96 | $options->SHOULD_FORMAT_DATES = $this->command->shouldFormatDates(); 97 | $options->SHOULD_PRESERVE_EMPTY_ROWS = $this->command->shouldPreserveEmptyRows(); 98 | 99 | return new OdsReader($options); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithOutputDuringProgress.php: -------------------------------------------------------------------------------- 1 | conveyor_belt->progress->message($message); 12 | } 13 | 14 | public function progressSubMessage($message) 15 | { 16 | $this->conveyor_belt->progress->subMessage($message); 17 | } 18 | 19 | public function withoutProgress(Closure $callback) 20 | { 21 | return $this->conveyor_belt->progress->interrupt($callback); 22 | } 23 | 24 | public function confirm($question, $default = false) 25 | { 26 | return $this->withoutProgress(fn() => parent::confirm($question, $default)); 27 | } 28 | 29 | public function ask($question, $default = null) 30 | { 31 | return $this->withoutProgress(fn() => parent::ask($question, $default)); 32 | } 33 | 34 | public function anticipate($question, $choices, $default = null) 35 | { 36 | return $this->withoutProgress(fn() => parent::anticipate($question, $choices, $default)); 37 | } 38 | 39 | public function askWithCompletion($question, $choices, $default = null) 40 | { 41 | return $this->withoutProgress(fn() => parent::askWithCompletion($question, $choices, $default)); 42 | } 43 | 44 | public function secret($question, $fallback = true) 45 | { 46 | return $this->withoutProgress(fn() => parent::secret($question, $fallback)); 47 | } 48 | 49 | public function choice($question, array $choices, $default = null, $attempts = null, $multiple = false) 50 | { 51 | return $this->withoutProgress(fn() => parent::choice($question, $choices, $default, $attempts, $multiple)); 52 | } 53 | 54 | public function table($headers, $rows, $tableStyle = 'default', array $columnStyles = []) 55 | { 56 | return $this->withoutProgress(fn() => parent::table($headers, $rows, $tableStyle, $columnStyles)); 57 | } 58 | 59 | public function info($string, $verbosity = null) 60 | { 61 | return $this->withoutProgress(fn() => parent::info($string, $verbosity)); 62 | } 63 | 64 | public function line($string, $style = null, $verbosity = null) 65 | { 66 | return $this->withoutProgress(fn() => parent::line($string, $style, $verbosity)); 67 | } 68 | 69 | public function comment($string, $verbosity = null) 70 | { 71 | return $this->withoutProgress(fn() => parent::comment($string, $verbosity)); 72 | } 73 | 74 | public function question($string, $verbosity = null) 75 | { 76 | return $this->withoutProgress(fn() => parent::question($string, $verbosity)); 77 | } 78 | 79 | public function error($string, $verbosity = null) 80 | { 81 | return $this->withoutProgress(fn() => parent::error($string, $verbosity)); 82 | } 83 | 84 | public function warn($string, $verbosity = null) 85 | { 86 | return $this->withoutProgress(fn() => parent::warn($string, $verbosity)); 87 | } 88 | 89 | public function alert($string, $verbosity = null) 90 | { 91 | return $this->withoutProgress(fn() => parent::alert($string, $verbosity)); 92 | } 93 | 94 | public function newLine($count = 1) 95 | { 96 | if ($this->conveyor_belt->progress->enabled()) { 97 | return; 98 | } 99 | 100 | parent::newLine($count); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Concerns/SetsUpConveyorBelt.php: -------------------------------------------------------------------------------- 1 | conveyor_belt = $this->makeConveyorBelt(); 23 | } 24 | 25 | protected function handleWithConveyorBelt(): int 26 | { 27 | return $this->conveyor_belt->handle(); 28 | } 29 | 30 | protected function initialize(InputInterface $input, OutputInterface $output) 31 | { 32 | if (! $output instanceof OutputStyle) { 33 | throw new InvalidArgumentException('Conveyor Belt requires output to be of type "Symfony\Component\Console\Style\OutputStyle"'); 34 | } 35 | 36 | parent::initialize($input, $output); 37 | 38 | $this->conveyor_belt->initialize($input, $output); 39 | } 40 | 41 | protected function useCommandPropertyIfExists(string $snake_name, $default) 42 | { 43 | if (property_exists($this, $snake_name)) { 44 | return $this->{$snake_name}; 45 | } 46 | 47 | if (property_exists($this, $camel_name = Str::camel($snake_name))) { 48 | return $this->{$camel_name}; 49 | } 50 | 51 | return value($default); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exceptions/AbortConveyorBeltException.php: -------------------------------------------------------------------------------- 1 | handleWithConveyorBelt(); 30 | } 31 | 32 | public function getRowName(): string 33 | { 34 | return $this->useCommandPropertyIfExists( 35 | 'row_name', 36 | trans('conveyor-belt::messages.record') 37 | ); 38 | } 39 | 40 | public function getRowNamePlural(): string 41 | { 42 | return $this->useCommandPropertyIfExists( 43 | 'row_name_plural', 44 | Str::plural($this->getRowName()) 45 | ); 46 | } 47 | 48 | public function shouldCollectExceptions(): bool 49 | { 50 | return $this->useCommandPropertyIfExists( 51 | 'collect_exceptions', 52 | config('conveyor-belt.collect_exceptions', false) 53 | ); 54 | } 55 | 56 | protected function abort(string $message = '', int $code = Command::FAILURE): void 57 | { 58 | throw new AbortConveyorBeltException($message, $code); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/IteratesEnumerable.php: -------------------------------------------------------------------------------- 1 | lazyById($this->getChunkSize(), $this->getIdColumn(), $this->getIdAlias()); 23 | } 24 | 25 | protected function getIdColumn(): string 26 | { 27 | return $this->useCommandPropertyIfExists('id_column', 'id'); 28 | } 29 | 30 | protected function getIdAlias(): string 31 | { 32 | return $this->useCommandPropertyIfExists('id_alias', Str::afterLast($this->getIdColumn(), '.')); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/IteratesJson.php: -------------------------------------------------------------------------------- 1 | getJsonFilename()) { 25 | return Items::fromFile($filename, $options); 26 | } 27 | 28 | if ($endpoint = $this->getJsonEndpoint()) { 29 | $body = $this->prepareHttpRequest($endpoint)->toPsrResponse()->getBody(); 30 | 31 | return Items::fromStream(StreamWrapper::getResource($body), $options); 32 | } 33 | 34 | $class_name = class_basename($this); 35 | $this->abort("Please implement {$class_name}::getItems(), add a 'json_endpoint' property to your command, or add a 'filename' argument or property to your command."); 36 | } 37 | 38 | public function getJsonPointer() 39 | { 40 | return $this->useCommandPropertyIfExists('json_pointer', null); 41 | } 42 | 43 | protected function getJsonFilename(): ?string 44 | { 45 | if ($this->hasArgument('filename') && $filename = $this->argument('filename')) { 46 | return $filename; 47 | } 48 | 49 | if ($filename = $this->useCommandPropertyIfExists('filename', null)) { 50 | return $filename; 51 | } 52 | 53 | return null; 54 | } 55 | 56 | protected function getJsonEndpoint(): ?string 57 | { 58 | return $this->useCommandPropertyIfExists('json_endpoint', null); 59 | } 60 | 61 | protected function prepareHttpRequest($endpoint): ClientResponse 62 | { 63 | return Http::get($endpoint); 64 | } 65 | 66 | protected function makeConveyorBelt(): ConveyorBelt 67 | { 68 | return new JsonBelt($this); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/IteratesQuery.php: -------------------------------------------------------------------------------- 1 | lazy($this->getChunkSize()); 26 | } 27 | 28 | public function beforeFirstQuery(): void 29 | { 30 | // Implement this if you need to do some work before the initial 31 | // query is executed 32 | } 33 | 34 | public function getChunkSize(): int 35 | { 36 | return $this->useCommandPropertyIfExists( 37 | 'chunk_size', 38 | config('conveyor-belt.chunk_count', 1000) 39 | ); 40 | } 41 | 42 | public function shouldUseTransaction(): bool 43 | { 44 | return $this->useCommandPropertyIfExists('use_transaction', false); 45 | } 46 | 47 | protected function makeConveyorBelt(): ConveyorBelt 48 | { 49 | return new QueryBelt($this); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/IteratesSpreadsheet.php: -------------------------------------------------------------------------------- 1 | argument('filename')) { 32 | return $filename; 33 | } 34 | 35 | if ($filename = $this->useCommandPropertyIfExists('filename', null)) { 36 | return $filename; 37 | } 38 | 39 | $class_name = class_basename($this); 40 | $this->abort("Please implement {$class_name}::getSpreadsheetFilename() or add a 'filename' argument or property to your command."); 41 | } 42 | 43 | /** 44 | * @param \OpenSpout\Common\Entity\Cell[] $cells 45 | * @return \stdClass 46 | */ 47 | public function mapCells(array $cells, array $headings) 48 | { 49 | $format = $this->getHeadingFormat(); 50 | $result = []; 51 | 52 | foreach ($cells as $index => $cell) { 53 | $value = match ($cell::class) { 54 | DateTimeCell::class => Date::instance($cell->getValue()), 55 | default => $cell->getValue(), 56 | }; 57 | 58 | $key = $headings[$index] ?? Str::{$format}('column '.($index + 1)); 59 | 60 | $result[$key] = $value; 61 | } 62 | 63 | return (object) $result; 64 | } 65 | 66 | /** 67 | * @param \OpenSpout\Common\Entity\Cell[] $cells 68 | * @return array 69 | */ 70 | public function mapHeadings(array $cells): array 71 | { 72 | $format = $this->getHeadingFormat(); 73 | $headings = []; 74 | 75 | foreach ($cells as $index => $cell) { 76 | $value = $cell->getValue(); 77 | 78 | if (! is_string($value)) { 79 | $value = 'column '.($index + 1); 80 | } 81 | 82 | $value = trim($value); 83 | $heading = Str::{$format}($value); 84 | 85 | if (in_array($heading, $headings)) { 86 | $heading = Str::{$format}("$value $index"); 87 | } 88 | 89 | $headings[] = $heading; 90 | } 91 | 92 | return $headings; 93 | } 94 | 95 | public function shouldUseHeadings(): bool 96 | { 97 | return $this->useCommandPropertyIfExists('use_headings', true); 98 | } 99 | 100 | public function shouldPreserveEmptyRows(): bool 101 | { 102 | return $this->useCommandPropertyIfExists('preserve_empty_rows', false); 103 | } 104 | 105 | public function shouldFormatDates(): bool 106 | { 107 | return $this->useCommandPropertyIfExists('format_dates', false); 108 | } 109 | 110 | public function getFieldDelimiter(): string 111 | { 112 | return $this->useCommandPropertyIfExists('field_delimiter', ','); 113 | } 114 | 115 | public function getFieldEnclosure(): string 116 | { 117 | return $this->useCommandPropertyIfExists('field_enclosure', '"'); 118 | } 119 | 120 | public function getSpreadsheetEncoding(): string 121 | { 122 | return $this->useCommandPropertyIfExists('spreadsheet_encoding', EncodingHelper::ENCODING_UTF8); 123 | } 124 | 125 | public function getExcelTempDirectory(): string 126 | { 127 | return $this->useCommandPropertyIfExists('excel_temp_directory', sys_get_temp_dir()); 128 | } 129 | 130 | public function getHeadingFormat(): string 131 | { 132 | return $this->useCommandPropertyIfExists('heading_format', 'snake'); 133 | } 134 | 135 | protected function makeConveyorBelt(): ConveyorBelt 136 | { 137 | return new SpreadsheetBelt($this); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/RespectsVerbosity.php: -------------------------------------------------------------------------------- 1 | getOutput()->isVerbose()) { 13 | return $closure(); 14 | } 15 | 16 | return value($default); 17 | } 18 | 19 | public function confirmWhenVerbose($question, $default = false) 20 | { 21 | return $this->whenVerbose(fn() => $this->confirm($question, $default), $default); 22 | } 23 | 24 | public function askWhenVerbose($question, $default = null) 25 | { 26 | return $this->whenVerbose(fn() => $this->ask($question, $default), $default); 27 | } 28 | 29 | public function anticipateWhenVerbose($question, $choices, $default = null) 30 | { 31 | return $this->whenVerbose(fn() => $this->anticipate($question, $choices, $default), $default); 32 | } 33 | 34 | public function askWithCompletionWhenVerbose($question, $choices, $default = null) 35 | { 36 | return $this->whenVerbose(fn() => $this->askWithCompletion($question, $choices, $default), $default); 37 | } 38 | 39 | public function secretWhenVerbose($question, $fallback = true, $default = null) 40 | { 41 | return $this->whenVerbose(fn() => $this->secret($question, $fallback), $default); 42 | } 43 | 44 | public function choiceWhenVerbose($question, array $choices, $default = null, $attempts = null, $multiple = false) 45 | { 46 | return $this->whenVerbose(fn() => $this->choice($question, $choices, $default, $attempts, $multiple), $default); 47 | } 48 | 49 | public function tableWhenVerbose($headers, $rows, $tableStyle = 'default', array $columnStyles = []) 50 | { 51 | return $this->whenVerbose(fn() => $this->table($headers, $rows, $tableStyle, $columnStyles)); 52 | } 53 | 54 | public function infoWhenVerbose($string, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 55 | { 56 | $this->info($string, $verbosity); 57 | } 58 | 59 | public function lineWhenVerbose($string, $style = null, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 60 | { 61 | $this->line($string, $style, $verbosity); 62 | } 63 | 64 | public function commentWhenVerbose($string, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 65 | { 66 | $this->comment($string, $verbosity); 67 | } 68 | 69 | public function questionWhenVerbose($string, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 70 | { 71 | $this->question($string, $verbosity); 72 | } 73 | 74 | public function errorWhenVerbose($string, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 75 | { 76 | $this->error($string, $verbosity); 77 | } 78 | 79 | public function warnWhenVerbose($string, $verbosity = OutputInterface::VERBOSITY_VERBOSE) 80 | { 81 | $this->warn($string, $verbosity); 82 | } 83 | 84 | public function alertWhenVerbose($string) 85 | { 86 | return $this->whenVerbose(fn() => $this->alert($string)); 87 | } 88 | 89 | public function newLineWhenVerbose($count = 1) 90 | { 91 | return $this->whenVerbose(fn() => $this->newLine($count)); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Support/CollectedException.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 17 | 18 | if ($item instanceof Model) { 19 | $this->key = $item->getKey(); 20 | } 21 | } 22 | 23 | public function __toString() 24 | { 25 | return $this->exception->getMessage(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Support/ConveyorBeltServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom($this->packageTranslationsDirectory(), 'conveyor-belt'); 12 | 13 | $this->publishes( 14 | [$this->packageConfigFile() => $this->app->configPath('conveyor-belt.php')], 15 | ['conveyor-belt', 'conveyor-belt-config'] 16 | ); 17 | 18 | $this->publishes( 19 | [$this->packageTranslationsDirectory() => $this->app->resourcePath('lang/vendor/conveyor-belt')], 20 | ['conveyor-belt', 'conveyor-belt-translations'] 21 | ); 22 | } 23 | 24 | public function register() 25 | { 26 | $this->mergeConfigFrom($this->packageConfigFile(), 'conveyor-belt'); 27 | } 28 | 29 | protected function packageConfigFile(): string 30 | { 31 | return dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'config.php'; 32 | } 33 | 34 | protected function packageTranslationsDirectory(): string 35 | { 36 | return dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'translations'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Support/ProgressBar.php: -------------------------------------------------------------------------------- 1 | input = $input; 21 | $this->output = $output; 22 | } 23 | 24 | public function enabled(): bool 25 | { 26 | return null !== $this->bar; 27 | } 28 | 29 | public function start(?int $count, string $row_singular = 'record', string $row_plural = 'records'): self 30 | { 31 | $this->newLine(); 32 | 33 | $this->line(trans_choice('conveyor-belt::messages.processing_records', $count, [ 34 | 'count' => $count, 35 | 'record' => $row_singular, 36 | 'records' => $row_plural, 37 | ])); 38 | 39 | $this->newLine(); 40 | 41 | if (0 === $count || $this->output->isVerbose()) { 42 | return $this; 43 | } 44 | 45 | $this->bar = $this->output->createProgressBar(); 46 | $this->bar->setFormat($this->getFormat(null !== $count)); 47 | $this->bar->setMessage(''); 48 | $this->bar->start($count); 49 | 50 | return $this; 51 | } 52 | 53 | public function advance(string $message = null): self 54 | { 55 | if ($this->bar) { 56 | $this->bar->advance(); 57 | } 58 | 59 | if ($message) { 60 | $this->message($message); 61 | } 62 | 63 | return $this; 64 | } 65 | 66 | public function finish(): self 67 | { 68 | if ($this->bar) { 69 | $this->bar->display(); 70 | $this->bar->finish(); 71 | $this->bar = null; 72 | } 73 | 74 | $this->newLine(); 75 | 76 | return $this; 77 | } 78 | 79 | public function message(string $message): self 80 | { 81 | if ($this->output->isVerbose()) { 82 | $this->newLine(); 83 | $this->info($message); 84 | 85 | return $this; 86 | } 87 | 88 | $message = trim($message); 89 | $this->bar->setMessage($message); 90 | 91 | return $this; 92 | } 93 | 94 | public function subMessage(string $message): self 95 | { 96 | if ($this->output->isVerbose()) { 97 | $this->line(" - {$message}"); 98 | return $this; 99 | } 100 | 101 | $message = trim($message); 102 | $this->bar->setMessage(Str::of($this->bar->getMessage())->before('→')->trim()->append(" → {$message}")); 103 | 104 | return $this; 105 | } 106 | 107 | public function pause(): self 108 | { 109 | if ($this->bar) { 110 | $this->bar->clear(); 111 | } 112 | 113 | return $this; 114 | } 115 | 116 | public function resume(): self 117 | { 118 | if ($this->bar) { 119 | $this->bar->display(); 120 | } 121 | 122 | return $this; 123 | } 124 | 125 | public function interrupt(Closure $callback) 126 | { 127 | $this->pause(); 128 | 129 | $result = $callback(); 130 | 131 | $this->resume(); 132 | 133 | return $result; 134 | } 135 | 136 | protected function getFormat(bool $has_count): string 137 | { 138 | $format = $has_count 139 | ? 'count' 140 | : 'base'; 141 | 142 | if ($this->input->getOption('show-memory-usage')) { 143 | $format .= '_with_memory'; 144 | } 145 | 146 | return config("conveyor-belt.progress_formats.{$format}", '%bar% %current% %message%'); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Commands/Concerns/CallsTestCallbacks.php: -------------------------------------------------------------------------------- 1 | callTestCallback($item); 10 | } 11 | 12 | public function rejectRow($item) 13 | { 14 | return $this->callTestCallback($item); 15 | } 16 | 17 | public function handleRow($item) 18 | { 19 | $this->callTestCallback($item); 20 | } 21 | 22 | public function beforeFirstRow(): void 23 | { 24 | $this->callTestCallback(); 25 | } 26 | 27 | public function afterLastRow(): void 28 | { 29 | $this->callTestCallback(); 30 | } 31 | 32 | protected function callTestCallback(...$args) 33 | { 34 | [$_, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); 35 | 36 | return $this->callNamedTestCallback($caller['function'], $args); 37 | } 38 | 39 | protected function callNamedTestCallback(string $function, array $args) 40 | { 41 | return app("test_callbacks.{$function}")(...$args); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Commands/TestCommand.php: -------------------------------------------------------------------------------- 1 | option('throw'); 18 | } 19 | 20 | public function collect(): Enumerable 21 | { 22 | return new LazyCollection(function() { 23 | $data = json_decode($this->argument('data'), false, 512, JSON_THROW_ON_ERROR); 24 | 25 | foreach ($data as $datum) { 26 | yield $datum; 27 | } 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Commands/TestIdQueryCommand.php: -------------------------------------------------------------------------------- 1 | collect_exceptions = ! $this->option('throw'); 21 | $this->use_transaction = true === $this->option('transaction'); 22 | 23 | $this->callTestCallback(); 24 | } 25 | 26 | public function query() 27 | { 28 | switch ($this->argument('case')) { 29 | case 'eloquent': 30 | return User::query()->orderBy('id'); 31 | case 'base': 32 | return User::query()->toBase()->orderBy('id'); 33 | } 34 | 35 | $this->abort('Invalid case.'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Commands/TestJsonEndpointCommand.php: -------------------------------------------------------------------------------- 1 | option('throw'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Commands/TestJsonFileCommand.php: -------------------------------------------------------------------------------- 1 | collect_exceptions = ! $this->option('throw'); 20 | $this->json_pointer = $this->option('pointer'); 21 | 22 | $this->callTestCallback(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Commands/TestQueryCommand.php: -------------------------------------------------------------------------------- 1 | collect_exceptions = ! $this->option('throw'); 21 | $this->use_transaction = true === $this->option('transaction'); 22 | 23 | $this->callTestCallback(); 24 | } 25 | 26 | public function query() 27 | { 28 | switch ($this->argument('case')) { 29 | case 'eloquent': 30 | return User::query()->orderBy('name'); 31 | case 'base': 32 | return User::query()->toBase()->orderBy('name'); 33 | } 34 | 35 | $this->abort('Invalid case.'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Commands/TestSpreadsheetCommand.php: -------------------------------------------------------------------------------- 1 | collect_exceptions = ! $this->option('throw'); 18 | 19 | $this->callTestCallback(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Concerns/ArtificiallyFails.php: -------------------------------------------------------------------------------- 1 | iteration_count_for_exception_trigger = 0; 15 | } 16 | 17 | protected function triggerExceptionAfterTimes(int $times, string $exception = RuntimeException::class, string $message = 'Artificial failure.') 18 | { 19 | if ($this->iteration_count_for_exception_trigger <= $times) { 20 | $this->iteration_count_for_exception_trigger++; 21 | } 22 | 23 | if ($this->iteration_count_for_exception_trigger > $times) { 24 | throw new $exception($message); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Concerns/CallsTestCommands.php: -------------------------------------------------------------------------------- 1 | app, $command, $parameters); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/Concerns/ProvidesData.php: -------------------------------------------------------------------------------- 1 | $value) { 23 | if (is_numeric($label)) { 24 | $label = $value; 25 | } 26 | 27 | foreach ($results as $index => $result) { 28 | $next_labels[] = [...$labels[$index], $label]; 29 | $next_results[] = [...$result, $value]; 30 | } 31 | } 32 | 33 | $labels = $next_labels; 34 | $results = $next_results; 35 | } 36 | 37 | $labels = array_map(function($labels) { 38 | return implode(', ', array_filter($labels)); 39 | }, $labels); 40 | 41 | return array_combine($labels, $results); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Concerns/RegistersTestCallbacks.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function() { 15 | $this->registerHandleRowCallback(static fn() => null); 16 | $this->registerBeforeFirstRowCallback(static fn() => null); 17 | $this->registerAfterLastRowCallback(static fn() => null); 18 | $this->registerFilterRowCallback(static fn() => true); 19 | $this->registerRejectRowCallback(static fn() => false); 20 | }); 21 | } 22 | 23 | public function registerHandleRowCallback(Closure $callback) 24 | { 25 | return $this->registerTestCallback('handleRow', $callback); 26 | } 27 | 28 | public function registerBeforeFirstRowCallback(Closure $callback) 29 | { 30 | return $this->registerTestCallback('beforeFirstRow', $callback); 31 | } 32 | 33 | public function registerFilterRowCallback(Closure $callback) 34 | { 35 | return $this->registerTestCallback('filterRow', $callback); 36 | } 37 | 38 | public function registerRejectRowCallback(Closure $callback) 39 | { 40 | return $this->registerTestCallback('rejectRow', $callback); 41 | } 42 | 43 | public function registerAfterLastRowCallback(Closure $callback) 44 | { 45 | return $this->registerTestCallback('afterLastRow', $callback); 46 | } 47 | 48 | protected function assertHookMethodsWereCalledInExpectedOrder() 49 | { 50 | $this->assertEquals([ 51 | 'beforeFirstRow' => 0, 52 | 'filterRow' => 1, 53 | 'rejectRow' => 2, 54 | 'handleRow' => 3, 55 | 'afterLastRow' => 4, 56 | ], $this->test_callback_order); 57 | } 58 | 59 | protected function registerTestCallback(string $function, Closure $callback) 60 | { 61 | $this->app->instance("test_callbacks.{$function}", function(...$args) use ($function, $callback) { 62 | $this->test_callback_order[$function] ??= count($this->test_callback_order); 63 | return $callback(...$args); 64 | }); 65 | 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Concerns/TestsDatabaseTransactions.php: -------------------------------------------------------------------------------- 1 | false, 14 | TransactionCommitted::class => false, 15 | TransactionRolledBack::class => false, 16 | ]; 17 | 18 | /** @before */ 19 | public function setUpTestsDatabaseTransactions() 20 | { 21 | $this->afterApplicationCreated(function() { 22 | $dispatcher = DB::getEventDispatcher(); 23 | $dispatcher->listen(array_keys($this->transaction_events), function($event) { 24 | $this->transaction_events[get_class($event)] = true; 25 | }); 26 | }); 27 | } 28 | 29 | protected function assertDatabaseTransactionWasCommitted() 30 | { 31 | $this->assertTrue($this->transaction_events[TransactionBeginning::class]); 32 | $this->assertTrue($this->transaction_events[TransactionCommitted::class]); 33 | $this->assertFalse($this->transaction_events[TransactionRolledBack::class]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/DatabaseTestCase.php: -------------------------------------------------------------------------------- 1 | afterApplicationCreated(function() { 18 | Company::factory()->create(['id' => 2, 'name' => 'Laravel LLC']); 19 | Company::factory()->create(['id' => 1, 'name' => 'Galahad, Inc.']); 20 | 21 | User::factory()->create(['id' => 3, 'name' => 'Taylor Otwell', 'company_id' => 2]); 22 | User::factory()->create(['id' => 1, 'name' => 'Chris Morrell', 'company_id' => 1]); 23 | User::factory()->create(['id' => 4, 'name' => 'Mohamed Said', 'company_id' => 2]); 24 | User::factory()->create(['id' => 2, 'name' => 'Bogdan Kharchenko', 'company_id' => 1]); 25 | }); 26 | } 27 | 28 | protected function defineDatabaseMigrations() 29 | { 30 | $this->loadMigrationsFrom(__DIR__.'/database/migrations'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/IteratesEnumerableTest.php: -------------------------------------------------------------------------------- 1 | registerHandleRowCallback(function($row) use (&$expectations, $exceptions) { 23 | $expected = array_shift($expectations); 24 | $this->assertEquals($expected, $row); 25 | 26 | if ($exceptions) { 27 | $this->triggerExceptionAfterTimes(1); 28 | } 29 | }); 30 | 31 | $this->callTestCommand(TestEnumerableCommand::class) 32 | ->withArgument('data', json_encode($expectations, JSON_THROW_ON_ERROR)) 33 | ->withStepMode($step) 34 | ->expectingSuccessfulReturnCode(false === $exceptions) 35 | ->throwingExceptions('throw' === $exceptions) 36 | ->run(); 37 | 38 | $this->assertEmpty($expectations); 39 | $this->assertHookMethodsWereCalledInExpectedOrder(); 40 | } 41 | 42 | /** @dataProvider dataProvider */ 43 | public function test_it_can_filter_rows($exceptions, $step): void 44 | { 45 | $data = [ 46 | 'A1', 47 | 'A2', 48 | 'B1', 49 | 'B2', 50 | 'C1', 51 | 'C2', 52 | ]; 53 | 54 | $expectations = [ 55 | 'A1', 56 | 'A2', 57 | 'C1', 58 | 'C2', 59 | ]; 60 | 61 | $filtered = false; 62 | $rejected = false; 63 | 64 | $this->registerFilterRowCallback(function($row) use (&$filtered) { 65 | if ('B1' === $row) { 66 | $filtered = true; 67 | return false; 68 | } 69 | }); 70 | 71 | $this->registerRejectRowCallback(function($row) use (&$rejected) { 72 | if ('B2' === $row) { 73 | $rejected = true; 74 | return true; 75 | } 76 | }); 77 | 78 | $this->registerHandleRowCallback(function($row) use (&$expectations, $exceptions) { 79 | $expected = array_shift($expectations); 80 | $this->assertEquals($expected, $row); 81 | 82 | if ($exceptions) { 83 | $this->triggerExceptionAfterTimes(1); 84 | } 85 | }); 86 | 87 | $this->callTestCommand(TestEnumerableCommand::class) 88 | ->withArgument('data', json_encode($data, JSON_THROW_ON_ERROR)) 89 | ->withStepMode($step) 90 | ->expectingSuccessfulReturnCode(false === $exceptions) 91 | ->throwingExceptions('throw' === $exceptions) 92 | ->run(); 93 | 94 | $this->assertEmpty($expectations); 95 | $this->assertTrue($filtered); 96 | $this->assertTrue($rejected); 97 | } 98 | 99 | public static function dataProvider() 100 | { 101 | return static::getDataProvider( 102 | ['no exceptions' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 103 | ['' => false, 'step mode' => true], 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/IteratesIdQueryTest.php: -------------------------------------------------------------------------------- 1 | registerHandleRowCallback(function($row) use (&$expectations, $case, $exceptions) { 26 | $expected = array_shift($expectations); 27 | $this->assertEquals($expected, $row->name); 28 | 29 | if ('eloquent' === $case) { 30 | $this->assertInstanceOf(User::class, $row); 31 | } 32 | 33 | if ($exceptions) { 34 | $this->triggerExceptionAfterTimes(1); 35 | } 36 | }); 37 | 38 | $this->callTestCommand(TestIdQueryCommand::class) 39 | ->withArgument('case', $case) 40 | ->withOption('transaction', $transaction) 41 | ->withStepMode($step) 42 | ->expectingSuccessfulReturnCode(false === $exceptions) 43 | ->throwingExceptions('throw' === $exceptions) 44 | ->run(); 45 | 46 | if ($transaction) { 47 | $this->assertDatabaseTransactionWasCommitted(); 48 | } 49 | 50 | $this->assertEmpty($expectations); 51 | $this->assertHookMethodsWereCalledInExpectedOrder(); 52 | } 53 | 54 | public static function dataProvider() 55 | { 56 | return static::getDataProvider( 57 | ['eloquent', 'base'], 58 | ['' => false, 'step mode' => true], 59 | ['' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 60 | ['' => false, 'in transaction' => true], 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/IteratesJsonTest.php: -------------------------------------------------------------------------------- 1 | 'Chris Morrell', 'company' => 'Galahad, Inc.', 'quote' => '"I hate final classes."'], 25 | (object) ['full_name' => 'Bogdan Kharchenko', 'company' => 'Galahad, Inc.', 'quote' => '"It works."'], 26 | (object) ['full_name' => 'Mohamed Said', 'company' => 'Laravel LLC', 'quote' => null], 27 | (object) ['full_name' => 'Taylor Otwell', 'company' => 'Laravel LLC', 'quote' => '"No plans to merge."'], 28 | ]; 29 | 30 | $this->registerHandleRowCallback(function($row) use (&$expectations, $exceptions) { 31 | $expected = array_shift($expectations); 32 | $this->assertEquals($expected, $row); 33 | 34 | if ($exceptions) { 35 | $this->triggerExceptionAfterTimes(1); 36 | } 37 | }); 38 | 39 | $this->callTestCommand(TestJsonFileCommand::class) 40 | ->withArgument('filename', $filename) 41 | ->withOption('pointer', $pointer) 42 | ->withStepMode($step) 43 | ->expectingSuccessfulReturnCode(false === $exceptions) 44 | ->throwingExceptions('throw' === $exceptions) 45 | ->run(); 46 | 47 | $this->assertEmpty($expectations); 48 | $this->assertHookMethodsWereCalledInExpectedOrder(); 49 | } 50 | 51 | public static function fileDataProvider() 52 | { 53 | return static::getDataProvider( 54 | ['root json' => __DIR__.'/sources/people.json', 'nested json' => __DIR__.'/sources/people-nested.json'], 55 | ['' => false, 'step mode' => true], 56 | ['' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 57 | ); 58 | } 59 | 60 | /** @dataProvider endpointDataProvider */ 61 | public function test_it_streams_json_api_data($step, $exceptions): void 62 | { 63 | $stub = file_get_contents(__DIR__.'/sources/botw.json'); 64 | 65 | // This forces the JSON to be read in small chunks, which lets us test 66 | // whether the JsonMachine parser is working as expected 67 | $chunks = str_split($stub, random_int(50, 200)); 68 | $stream = new PumpStream(function() use (&$chunks) { 69 | return count($chunks) 70 | ? array_shift($chunks) 71 | : false; 72 | }); 73 | 74 | Http::fake([ 75 | 'botw-compendium.herokuapp.com/*' => Http::response($stream, 200, ['content-type' => 'application/json']), 76 | ]); 77 | 78 | $botw = json_decode($stub); 79 | $equipment = $botw->data->equipment; 80 | 81 | $this->registerHandleRowCallback(function($row) use ($exceptions, &$equipment) { 82 | $expected = array_shift($equipment); 83 | $this->assertEquals($expected, $row); 84 | 85 | if ($exceptions) { 86 | $this->triggerExceptionAfterTimes(1); 87 | } 88 | }); 89 | 90 | $this->callTestCommand(TestJsonEndpointCommand::class) 91 | ->expectingSuccessfulReturnCode(false === $exceptions) 92 | ->throwingExceptions('throw' === $exceptions) 93 | ->withStepMode($step, count($equipment)) 94 | ->run(); 95 | 96 | $this->assertEmpty($equipment); 97 | $this->assertHookMethodsWereCalledInExpectedOrder(); 98 | } 99 | 100 | public static function endpointDataProvider() 101 | { 102 | return static::getDataProvider( 103 | ['' => false, 'step mode' => true], 104 | ['no exceptions' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/IteratesQueryTest.php: -------------------------------------------------------------------------------- 1 | registerHandleRowCallback(function($row) use (&$expectations, $case, $exceptions) { 27 | $expected = array_shift($expectations); 28 | $this->assertEquals($expected, $row->name); 29 | 30 | if ('eloquent' === $case) { 31 | $this->assertInstanceOf(User::class, $row); 32 | } 33 | 34 | if ($exceptions) { 35 | $this->triggerExceptionAfterTimes(1); 36 | } 37 | }); 38 | 39 | $this->callTestCommand(TestQueryCommand::class) 40 | ->withArgument('case', $case) 41 | ->withOption('transaction', $transaction) 42 | ->withStepMode($step) 43 | ->expectingSuccessfulReturnCode(false === $exceptions) 44 | ->throwingExceptions('throw' === $exceptions) 45 | ->run(); 46 | 47 | if ($transaction) { 48 | $this->assertDatabaseTransactionWasCommitted(); 49 | } 50 | 51 | $this->assertEmpty($expectations); 52 | $this->assertHookMethodsWereCalledInExpectedOrder(); 53 | } 54 | 55 | public static function dataProvider() 56 | { 57 | return static::getDataProvider( 58 | ['eloquent', 'base'], 59 | ['' => false, 'step mode' => true], 60 | ['' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 61 | ['' => false, 'in transaction' => true], 62 | ); 63 | } 64 | 65 | public function test_dump_sql(): void 66 | { 67 | $formatted = SqlFormatter::format('select * from "users" order by "name" asc'); 68 | 69 | $this->artisan(TestQueryCommand::class, ['case' => 'eloquent', '--dump-sql' => true]) 70 | ->expectsOutput($formatted) 71 | ->assertFailed(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/IteratesSpreadsheetTest.php: -------------------------------------------------------------------------------- 1 | 'Chris Morrell', 'company' => 'Galahad, Inc.', 'quote' => '"I hate final classes."', 'quoted_at' => '2021-01-01'], 19 | (object) ['full_name' => 'Bogdan Kharchenko', 'company' => 'Galahad, Inc.', 'quote' => '"It works."', 'quoted_at' => '2020-01-15'], 20 | (object) ['full_name' => 'Mohamed Said', 'company' => 'Laravel LLC', 'quote' => '', 'quoted_at' => ''], 21 | (object) ['full_name' => 'Taylor Otwell', 'company' => 'Laravel LLC', 'quote' => '"No plans to merge."', 'quoted_at' => '2019-03-04'], 22 | ]; 23 | 24 | $this->registerHandleRowCallback(function($row) use (&$expectations, $exceptions) { 25 | $expected = array_shift($expectations); 26 | $this->assertEquals($this->normalizeData($expected), $this->normalizeData($row)); 27 | 28 | if ($exceptions) { 29 | $this->triggerExceptionAfterTimes(1); 30 | } 31 | }); 32 | 33 | $this->callTestCommand(TestSpreadsheetCommand::class) 34 | ->withArgument('filename', $filename) 35 | ->withStepMode($step) 36 | ->expectingSuccessfulReturnCode(false === $exceptions) 37 | ->throwingExceptions('throw' === $exceptions) 38 | ->run(); 39 | 40 | $this->assertEmpty($expectations); 41 | $this->assertHookMethodsWereCalledInExpectedOrder(); 42 | } 43 | 44 | public static function dataProvider() 45 | { 46 | return static::getDataProvider( 47 | ['CSV' => __DIR__.'/sources/people.csv', 'Excel' => __DIR__.'/sources/people.xlsx'], 48 | ['' => false, 'step mode' => true], 49 | ['' => false, 'throw exceptions' => 'throw', 'collect exceptions' => 'collect'], 50 | ); 51 | } 52 | 53 | protected function normalizeData($data) 54 | { 55 | if (! empty($data->quoted_at)) { 56 | if (! ($data->quoted_at instanceof Carbon)) { 57 | $data->quoted_at = Date::parse($data->quoted_at); 58 | } 59 | 60 | $data->quoted_at = $data->quoted_at->format('Y-m-d'); 61 | } 62 | 63 | return $data; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Models/Company.php: -------------------------------------------------------------------------------- 1 | $this->faker->company(), 27 | ]; 28 | } 29 | }; 30 | } 31 | 32 | public function users() 33 | { 34 | return $this->hasMany(User::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | $this->faker->name(), 27 | 'email' => $this->faker->email(), 28 | 'company_id' => Company::class, 29 | ]; 30 | } 31 | }; 32 | } 33 | 34 | public function company() 35 | { 36 | return $this->belongsTo(Company::class); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/PendingConveyorBeltCommand.php: -------------------------------------------------------------------------------- 1 | false, 24 | '--throw' => false, 25 | ]; 26 | 27 | protected ?PendingCommand $pending = null; 28 | 29 | protected int $expected_exit_code = 0; 30 | 31 | protected int $step_times = 4; 32 | 33 | public function __construct(TestCase $test, Application $app, string $command, array $parameters = []) 34 | { 35 | $this->test = $test; 36 | $this->app = $app; 37 | $this->command = $command; 38 | $this->parameters = array_merge($this->parameters, $parameters); 39 | } 40 | 41 | public function command(): PendingCommand 42 | { 43 | if (null === $this->pending) { 44 | $this->pending = new PendingCommand($this->test, $this->app, $this->command, $this->parameters); 45 | 46 | $this->setStepExpectations($this->pending); 47 | 48 | $this->pending->assertExitCode($this->expected_exit_code); 49 | } 50 | 51 | return $this->pending; 52 | } 53 | 54 | public function withArgument(string $key, $value = true): self 55 | { 56 | $this->parameters[$key] = $value; 57 | 58 | return $this; 59 | } 60 | 61 | public function withOption(string $key, $value = true): self 62 | { 63 | $this->parameters["--{$key}"] = $value; 64 | 65 | return $this; 66 | } 67 | 68 | public function throwingExceptions(bool $throw): self 69 | { 70 | if ($throw) { 71 | $this->parameters['--throw'] = true; 72 | // expectException is protected 73 | (fn() => $this->expectException(RuntimeException::class))->call($this->test); 74 | } 75 | 76 | return $this; 77 | } 78 | 79 | public function expectingSuccessfulReturnCode(bool $succeed): self 80 | { 81 | $this->expected_exit_code = $succeed 82 | ? 0 83 | : 1; 84 | 85 | return $this; 86 | } 87 | 88 | public function withStepMode(bool $step, int $times = 4): self 89 | { 90 | if ($step) { 91 | $this->parameters['--step'] = true; 92 | $this->step_times = $times; 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | protected function setStepExpectations(PendingCommand $command) 99 | { 100 | if ($this->parameters['--step']) { 101 | $command->expectsQuestion('Continue?', true); 102 | 103 | if (! $this->parameters['--throw']) { 104 | for ($i = $this->step_times; $i > 1; $i--) { 105 | $command->expectsQuestion('Continue?', true); 106 | } 107 | } 108 | } 109 | } 110 | 111 | public function __call($name, $arguments) 112 | { 113 | return $this->forwardDecoratedCallTo($this->command(), $name, $arguments); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | resolve(TestSpreadsheetCommand::class); 29 | $app->resolve(TestJsonFileCommand::class); 30 | $app->resolve(TestJsonEndpointCommand::class); 31 | $app->resolve(TestQueryCommand::class); 32 | $app->resolve(TestIdQueryCommand::class); 33 | $app->resolve(TestEnumerableCommand::class); 34 | }); 35 | } 36 | 37 | protected function getPackageProviders($app) 38 | { 39 | return [ 40 | ConveyorBeltServiceProvider::class, 41 | ]; 42 | } 43 | 44 | protected function getPackageAliases($app) 45 | { 46 | return []; 47 | } 48 | 49 | protected function getApplicationTimezone($app) 50 | { 51 | return 'America/New_York'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/TestHelpersTest.php: -------------------------------------------------------------------------------- 1 | 'eh', 'b' => 'bee'], 11 | ['one' => 1, 'two' => 2], 12 | ['yes' => true, 'no' => false], 13 | ); 14 | 15 | $expected = [ 16 | 'a, one, yes' => ['eh', 1, true], 17 | 'b, one, yes' => ['bee', 1, true], 18 | 'a, two, yes' => ['eh', 2, true], 19 | 'b, two, yes' => ['bee', 2, true], 20 | 'a, one, no' => ['eh', 1, false], 21 | 'b, one, no' => ['bee', 1, false], 22 | 'a, two, no' => ['eh', 2, false], 23 | 'b, two, no' => ['bee', 2, false], 24 | ]; 25 | 26 | $this->assertEquals($expected, $provided); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/database/migrations/2022_01_18_131057_create_test_tables.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->string('name'); 14 | $table->string('email'); 15 | $table->foreignId('company_id')->nullable(); 16 | $table->timestamps(); 17 | $table->softDeletes(); 18 | }); 19 | 20 | Schema::create('companies', function(Blueprint $table) { 21 | $table->bigIncrements('id'); 22 | $table->string('name'); 23 | $table->timestamps(); 24 | $table->softDeletes(); 25 | }); 26 | } 27 | 28 | public function down() 29 | { 30 | Schema::dropIfExists('companies'); 31 | Schema::dropIfExists('users'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/sources/people-nested.json: -------------------------------------------------------------------------------- 1 | { 2 | "results": { 3 | "people": [ 4 | { 5 | "full_name": "Chris Morrell", 6 | "company": "Galahad, Inc.", 7 | "quote": "\"I hate final classes.\"" 8 | }, 9 | { 10 | "full_name": "Bogdan Kharchenko", 11 | "company": "Galahad, Inc.", 12 | "quote": "\"It works.\"" 13 | }, 14 | { 15 | "full_name": "Mohamed Said", 16 | "company": "Laravel LLC", 17 | "quote": null 18 | }, 19 | { 20 | "full_name": "Taylor Otwell", 21 | "company": "Laravel LLC", 22 | "quote": "\"No plans to merge.\"" 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/sources/people.csv: -------------------------------------------------------------------------------- 1 | Full Name,Company,Quote,Quoted At 2 | Chris Morrell,"Galahad, Inc.","""I hate final classes.""",1/1/21 3 | Bogdan Kharchenko,"Galahad, Inc.","""It works.""",1/15/20 4 | Mohamed Said,Laravel LLC,, 5 | Taylor Otwell,Laravel LLC,"""No plans to merge.""",3/4/19 -------------------------------------------------------------------------------- /tests/sources/people.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "full_name": "Chris Morrell", 4 | "company": "Galahad, Inc.", 5 | "quote": "\"I hate final classes.\"" 6 | }, 7 | { 8 | "full_name": "Bogdan Kharchenko", 9 | "company": "Galahad, Inc.", 10 | "quote": "\"It works.\"" 11 | }, 12 | { 13 | "full_name": "Mohamed Said", 14 | "company": "Laravel LLC", 15 | "quote": null 16 | }, 17 | { 18 | "full_name": "Taylor Otwell", 19 | "company": "Laravel LLC", 20 | "quote": "\"No plans to merge.\"" 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /tests/sources/people.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glhd/conveyor-belt/76f959546b0e4ca18ee46fc171157cf955f20817/tests/sources/people.xlsx -------------------------------------------------------------------------------- /translations/en/messages.php: -------------------------------------------------------------------------------- 1 | 'Querying :records…', 5 | 'querying_without_transaction' => 'Querying :records (no database transaction)…', 6 | 'querying_with_transaction' => 'Querying :records (using a database transaction)…', 7 | 8 | 'processing_records' => 'Processing :count :record|Processing :count :records…', 9 | 'no_matches' => 'There are no :records that match your query.', 10 | 11 | 'queries_executed' => 'Query Executed|Queries Executed', 12 | 'changes_to_record' => 'Changes to :Record', 13 | 'exceptions_triggered' => ':Count Exception Triggered During Run|:Count Exceptions Triggered During Run', 14 | 15 | 'confirm_continue' => 'Continue?', 16 | 'operation_cancelled' => 'Operation cancelled.', 17 | 18 | 'record' => 'record', 19 | 'query_heading' => 'Query', 20 | 'time_heading' => 'Time', 21 | 'column_heading' => 'Column', 22 | 'before_heading' => 'Before', 23 | 'after_heading' => 'After', 24 | 'exception_heading' => 'Exception', 25 | 'message_heading' => 'Message', 26 | ]; 27 | --------------------------------------------------------------------------------