├── .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 |
4 |
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 |
4 |
5 |
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 |
--------------------------------------------------------------------------------
/.idea/laravel-idea-personal.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/laravel-idea.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 |
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 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
--------------------------------------------------------------------------------
/.idea/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
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 |
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 | 
40 |
41 | ## Get verbose output when necessary
42 | 
43 |
44 | ## Step through execution & log operations if needed
45 | 
46 |
47 | ## See what data is changed by your commands
48 | 
49 |
50 | ## And so much more
51 | 
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 |
--------------------------------------------------------------------------------
/img/diff.svg:
--------------------------------------------------------------------------------
1 | $$phpartisanusers:fix-email$phpartisanusers:fix-email--diff$phpartisanusers:fix-email--diff--stepQueryingrecords(nodatabasetransaction)…Processing100records…RosellaStokesChangestoRecord┌────────────┬─────────────────────┬─────────────────────┐│Before│After││├────────────┼─────────────────────┼─────────────────────┤│email│rosella@foo.com│rosella@foo.com││updated_at│2022-01-3119:48:48│2022-01-3119:49:23│└────────────┴─────────────────────┴─────────────────────┘Continue?(yes/no)[yes]:>>yJodieWhite│email│jodie@foo.com│jodie@foo.com││updated_at│2022-01-3119:48:51│2022-01-3119:49:24│Prof.CamrenHauckSr.│email│prof.@foo.com│prof.@foo.com││updated_at│2022-01-3119:48:53│2022-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 |
--------------------------------------------------------------------------------