├── .github └── workflows │ ├── run-linter.yml │ └── run-tests.yml ├── .php_cs.dist.php ├── LICENSE.md ├── MIGRATION.md ├── README.md ├── composer.json ├── composer.lock ├── database └── factories │ ├── CommentModelFactory.php │ ├── PostModelFactory.php │ └── UserModelFactory.php ├── phpstan-baseline.neon ├── phpstan.neon.dist └── src ├── UnionPaginator.php └── UnionPaginatorServiceProvider.php /.github/workflows/run-linter.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | phpstan: 11 | name: PHPStan 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: '8.2' 21 | coverage: none 22 | tools: composer:v2 23 | 24 | - name: Install Dependencies 25 | run: composer install --prefer-dist --no-progress 26 | 27 | - name: Run Static Analysis 28 | run: composer analyse 29 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Cache dependencies 20 | uses: actions/cache@v4 21 | with: 22 | path: vendor 23 | key: dependencies-composer-${{ hashFiles('composer.json') }} 24 | 25 | - name: Set up PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: '8.2' 29 | extensions: mbstring, pdo, sqlite 30 | ini-values: post_max_size=256M, upload_max_filesize=256M 31 | coverage: none 32 | 33 | - name: Install Composer dependencies 34 | run: composer install --prefer-dist --no-progress 35 | 36 | - name: Run tests 37 | run: vendor/bin/phpunit 38 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'method_argument_space' => [ 30 | 'on_multiline' => 'ensure_fully_multiline', 31 | 'keep_multiple_spaces_after_comma' => true, 32 | ], 33 | 'single_trait_insert_per_statement' => true, 34 | ]) 35 | ->setFinder($finder); 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Austin White 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | ## Version 2.1.1 to 2.2.0 4 | 5 | To migrate from version 2.1.1 to 2.2.1 of the `UnionPaginator`, you need to make a small adjustment due to a method name change. Here's a concise guide: 6 | 7 | ### Changes 8 | 9 | 1. **Method Name Update:** 10 | - The method `addFilterFor` has been renamed to `getScopesFor`. Update any usage of this method in your codebase. 11 | 12 | **Before:** 13 | ```php 14 | foreach ($this->addFilterFor($modelType) as $modelScope) { 15 | $modelScope($query); 16 | } 17 | ``` 18 | 19 | **After:** 20 | ```php 21 | foreach ($this->getScopesFor($modelType) as $modelScope) { 22 | $modelScope($query); 23 | } 24 | ``` 25 | 26 | 2. **Update Method Definition:** 27 | - Change the method definition in your `UnionPaginator` class. 28 | 29 | **Before:** 30 | ```php 31 | public function addFilterFor(string $modelType): Collection 32 | ``` 33 | 34 | **After:** 35 | ```php 36 | public function getScopesFor(string $modelType): Collection 37 | ``` 38 | 39 | This change is primarily a correction for better naming clarity and should be straightforward to implement. 40 | 41 | ## Version 1 to Version 2 42 | 43 | This guide will help you update from the original `UnionPaginator` class to the newer version with improved functionality and developer experience. 44 | 45 | ## Overview of Changes 46 | 47 | **Key improvements include:** 48 | 49 | 1. **Better naming conventions:** 50 | - `forModels` replaces `for` to clearly indicate that you are passing model classes. 51 | - `transformResultsFor` replaces `transform` to clarify that you are applying transformations for a particular model type. 52 | 53 | 2. **Model-Based Query Construction:** 54 | Instead of building union queries purely from `DB::table()`, the paginator now starts from Eloquent query builders (`$model->newQuery()`), making it easier to leverage Eloquent features and ensuring consistent model loading. 55 | 56 | 3. **Bulk Loading of Models (Performance Optimization):** 57 | The updated implementation retrieves models in bulk after pagination, preventing N+1 query issues when transforming or returning models. Instead of calling `Model::find($id)` for each record, the paginator uses `findMany()` to load all required models at once. 58 | 59 | 4. **Scopes and Filters per Model Type:** 60 | You can now apply custom query modifications ("scopes") to each model type using `applyScope()`. This approach is more flexible and clearer than modifying the original union queries directly. 61 | 62 | 5. **Transformers, not `through` Callbacks:** 63 | Previously, `transform` stored callbacks in a `$through` array. Now, they are stored in a `$transformers` array to better convey their purpose. The naming and approach now more closely follow Laravel conventions. 64 | 65 | 6. **Optional Raw Records (`preventModelRetrieval()`):** 66 | By default, the paginator attempts to load Eloquent models for each record. A new `preventModelRetrieval()` method allows you to opt-out of this behavior and receive raw database records instead. Transformations can still be applied to these raw records if needed. 67 | 68 | 7. **Stricter Validations and Error Handling:** 69 | Using `InvalidArgumentException` instead of `BadMethodCallException` for invalid model types clarifies the nature of the error. Attempting to paginate without model types or an established union query now produces clearer exceptions. 70 | 71 | ## Step-by-Step Upgrade Instructions 72 | 73 | 1. **Class Instantiation:** 74 | - **Before:** 75 | ```php 76 | $paginator = UnionPaginator::for([User::class, Post::class]); 77 | ``` 78 | 79 | - **After:** 80 | ```php 81 | $paginator = UnionPaginator::forModels([User::class, Post::class]); 82 | ``` 83 | 84 | This makes it explicit that you are passing model classes. 85 | 86 | 2. **Transforming Results:** 87 | - **Before:** 88 | ```php 89 | $paginator->transform(User::class, function ($record) { 90 | return ['name' => strtoupper($record->name)]; 91 | }); 92 | ``` 93 | 94 | - **After:** 95 | ```php 96 | $paginator->transformResultsFor(User::class, function ($model) { 97 | return ['name' => strtoupper($model->name)]; 98 | }); 99 | ``` 100 | 101 | The new name clarifies that the transformation is applied to the results of that model type. 102 | 103 | 3. **Apply Scopes:** 104 | - **New Feature (No direct equivalent previously):** 105 | ```php 106 | $paginator->applyScope(User::class, fn($query) => $query->where('active', true)); 107 | ``` 108 | 109 | Now, you can easily apply filters or modifications to a specific model type’s query before the union is performed. 110 | 111 | 4. **Prevent Model Retrieval:** 112 | - **New Feature:** 113 | ```php 114 | $paginator->preventModelRetrieval()->paginate(); 115 | ``` 116 | 117 | With this option enabled, you receive raw records without the paginator attempting to load Eloquent models from the IDs. Transformations—if defined—are applied to the raw records directly. 118 | 119 | 5. **Mass Loading vs. N+1 Queries:** 120 | Previously, each record’s model would be loaded individually using `Model::find($id)` inside the transformation callback, leading to many queries. The new version batch-loads all records per model type using `findMany()`, significantly improving performance. No code changes are needed on your part to benefit from this; it’s an internal improvement. 121 | 122 | 6. **Selected Columns:** 123 | - **New Feature:** 124 | ```php 125 | $paginator->setSelectedColumns(User::class, ['id', 'email', DB::raw("'User' as type")]); 126 | ``` 127 | 128 | This allows you to customize which columns are selected for each model type before building the union. This feature did not exist in the previous version. 129 | 130 | 7. **Exception Handling:** 131 | If you pass a non-model class to `forModels()`, an `InvalidArgumentException` is thrown. Ensure that all provided classes are valid Eloquent models. 132 | 133 | ## Example Before and After 134 | 135 | **Before:** 136 | 137 | ```php 138 | $paginator = UnionPaginator::for([User::class, Post::class]) 139 | ->transform(User::class, function ($record) { 140 | return ['name' => $record->name]; 141 | }) 142 | ->paginate(10); 143 | ``` 144 | 145 | **After:** 146 | 147 | ```php 148 | $paginator = UnionPaginator::forModels([User::class, Post::class]) 149 | ->applyScope(User::class, fn($query) => $query->where('active', true)) 150 | ->transformResultsFor(User::class, fn($user) => ['name' => strtoupper($user->name)]) 151 | ->paginate(10); 152 | ``` 153 | 154 | Now the queries are built using Eloquent builders, and user models are filtered and transformed more cleanly. Additionally, all User and Post records are loaded in one go, eliminating N+1 queries. 155 | 156 | ## Summary 157 | 158 | This migration provides a more fluent experience. You gain: 159 | - More explicit method names. 160 | - The ability to scope queries per model. 161 | - Performance improvements by mass-loading models and avoiding N+1 queries. 162 | - Flexibility to opt-in or out of model retrieval. 163 | - Cleaner transformations and error handling. 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # UnionPaginator Documentation 3 | 4 | [![Tests](https://github.com/AustinW/laravel-union-paginator/actions/workflows/run-tests.yml/badge.svg)](https://github.com/AustinW/laravel-union-paginator/actions/workflows/run-tests.yml) 5 | [![Linter](https://github.com/AustinW/laravel-union-paginator/actions/workflows/run-linter.yml/badge.svg)](https://github.com/AustinW/laravel-union-paginator/actions/workflows/run-linter.yml) 6 | Total Downloads 7 | Latest Stable Version 8 | License 9 | 10 | ## Introduction 11 | 12 | The `UnionPaginator` package enables you to paginate and unify results from multiple Eloquent models into a single dataset. By merging multiple model queries, it allows for straightforward pagination and sorting of data drawn from various sources. 13 | 14 | **Key Features:** 15 | - Unite and paginate multiple Eloquent model results in one go. 16 | - Apply per-model filters (scopes) before the union. 17 | - Choose between retrieving actual Eloquent models or working with raw database records. 18 | - Mitigate N+1 queries by loading models in bulk. 19 | - Customize selected columns for each model type. 20 | 21 | ## Installation 22 | 23 | Install via Composer: 24 | 25 | ```bash 26 | composer require austinw/laravel-union-paginator 27 | ``` 28 | 29 | ## Migration Guide 30 | 31 | If you are upgrading from an earlier version of `UnionPaginator`, please refer to the [Migration Guide](MIGRATION.md) for detailed instructions on updating your code to take advantage of the latest features and improvements. 32 | 33 | ## Getting Started 34 | 35 | ### Initializing the UnionPaginator 36 | 37 | Specify which Eloquent models you want to combine: 38 | 39 | ```php 40 | use AustinW\UnionPaginator\UnionPaginator; 41 | 42 | $paginator = UnionPaginator::forModels([User::class, Post::class]); 43 | ``` 44 | 45 | All provided classes must be subclasses of `Illuminate\Database\Eloquent\Model`. 46 | 47 | ### Paginating Data 48 | 49 | Call `paginate` to get paginated results: 50 | 51 | ```php 52 | $results = $paginator->paginate(15); 53 | ``` 54 | 55 | This returns a `LengthAwarePaginator` instance, seamlessly integrating with Laravel’s pagination utilities. 56 | 57 | ### Applying Scopes to Individual Models 58 | 59 | You can apply specific query conditions to a single model type before creating the union: 60 | 61 | ```php 62 | $paginator->applyScope(User::class, fn($query) => $query->where('active', true)); 63 | ``` 64 | 65 | ### Customizing Mass Model Retrieval 66 | The UnionPaginator class allows you to customize how models are retrieved during pagination. This can be useful if you need to apply specific logic or optimizations when fetching models from the database. 67 | 68 | #### Registering a Custom Retrieval Callback 69 | You can register a custom callback for retrieving models by type using the fetchModelsUsing method. This method allows you to define how models should be fetched for a specific model type. 70 | 71 | Now only active users are included in the final union. 72 | 73 | ```php 74 | use AustinW\UnionPaginator\UnionPaginator; 75 | use App\Models\Post; 76 | use App\Models\Comment; 77 | 78 | // Create a new UnionPaginator instance for the specified models 79 | $paginator = new UnionPaginator([Post::class, Comment::class]); 80 | 81 | // Register a custom retrieval callback for the Post model 82 | $paginator->fetchModelsUsing(Post::class, function (array $ids) { 83 | // Custom logic to retrieve Post models 84 | return Post::with('author')->findMany($ids); 85 | }); 86 | 87 | // Register a custom retrieval callback for the Comment model 88 | $paginator->fetchModelsUsing(Comment::class, function (array $ids) { 89 | // Custom logic to retrieve Comment models 90 | return Comment::with('post')->findMany($ids); 91 | }); 92 | 93 | // Use the paginator as usual 94 | $paginatedResults = $paginator->paginate(); 95 | ``` 96 | 97 | #### Important Considerations 98 | - Model Type Registration: Ensure that the model type you are registering a callback for has been added to the UnionPaginator instance using the constructor or addModelType method. 99 | - Callback Signature: The callback should accept an array of IDs and return a collection of models. You can use Eloquent's findMany method or any other custom logic to retrieve the models. 100 | - Default Behavior: If no custom callback is registered for a model type, the UnionPaginator will use the default retrieval logic, which is to call findMany on the model type. 101 | 102 | By using custom retrieval callbacks, you can optimize and tailor the model fetching process to suit your application's specific needs. 103 | 104 | ### Transforming Results 105 | 106 | Use `transformResultsFor` to alter records for a particular model type: 107 | 108 | ```php 109 | $paginator->transformResultsFor(User::class, fn($user) => [ 110 | 'id' => $user->id, 111 | 'uppercase_name' => strtoupper($user->name), 112 | ]); 113 | ``` 114 | 115 | If model retrieval is active, `$user` is an Eloquent model. If you call `preventModelRetrieval()`, `$user` is a raw database record (`stdClass`). 116 | 117 | ### Preventing Model Retrieval 118 | 119 | If you don’t need Eloquent models and prefer raw records: 120 | 121 | ```php 122 | $paginator->preventModelRetrieval()->paginate(); 123 | ``` 124 | 125 | Transformations still apply, but are run on raw records. 126 | 127 | ### Selecting Columns 128 | 129 | Choose specific columns for each model type to reduce overhead: 130 | 131 | ```php 132 | $paginator->setSelectedColumns(User::class, ['id', 'email', DB::raw("'User' as type")]); 133 | ``` 134 | 135 | ### Soft Deletes 136 | 137 | Models using `SoftDeletes` are automatically filtered so that soft-deleted records do not appear. 138 | 139 | ## Methods 140 | 141 | - **forModels(array $modelTypes): self** 142 | Set the models to combine. Throws an exception if a non-model class is provided. 143 | 144 | - **applyScope(string \$modelType, Closure $callable): self** 145 | Modify queries for an individual model type. 146 | 147 | - **transformResultsFor(string \$modelType, Closure $callable): self** 148 | Apply transformations to either models or raw records of a particular model type. 149 | 150 | - **preventModelRetrieval(): self** 151 | Skip loading actual models. Return raw database rows instead. 152 | 153 | - **setSelectedColumns(string \$modelType, array $columns): self** 154 | Specify which columns to fetch for each model type. 155 | 156 | - **paginate(\$perPage = 15, \$columns = ['*'], \$pageName = 'page', $page = null): LengthAwarePaginator** 157 | Execute the union query, apply scopes and transformations, and return a paginator. 158 | 159 | - **__call(\$method, $parameters)** 160 | Forward method calls to the underlying union query builder, enabling sorting and other query modifications. 161 | 162 | ## Example Usage 163 | 164 | ```php 165 | use AustinW\UnionPaginator\UnionPaginator; 166 | 167 | $paginator = UnionPaginator::forModels([User::class, Post::class]) 168 | ->applyScope(User::class, fn($query) => $query->where('active', true)) 169 | ->transformResultsFor(User::class, fn($user) => ['id' => $user->id, 'name' => strtoupper($user->name)]) 170 | ->transformResultsFor(Post::class, fn($post) => ['title' => $post->title, 'date' => $post->created_at->toDateString()]) 171 | ->paginate(10); 172 | 173 | foreach ($paginator->items() as $item) { 174 | // Each $item could be a transformed array or a raw record, depending on your configuration. 175 | } 176 | ``` 177 | 178 | ## Advanced Usage 179 | 180 | ### Ordering and Complex Queries 181 | 182 | You can chain Eloquent methods before `paginate()`: 183 | 184 | ```php 185 | $paginator->latest()->paginate(); 186 | ``` 187 | 188 | or 189 | 190 | ```php 191 | $paginator->orderBy('created_at', 'desc')->paginate(); 192 | ``` 193 | 194 | ### Multiple Transformations for the Same Model 195 | 196 | Applying multiple transformations for the same model type overwrites earlier ones: 197 | 198 | ```php 199 | $paginator->transformResultsFor(User::class, fn($user) => ['transformed' => true]) 200 | ->transformResultsFor(User::class, fn($user) => ['overridden' => true]); 201 | ``` 202 | 203 | The latter transformation takes precedence. 204 | 205 | ### Handling Empty Results 206 | 207 | If no matching records are found, the paginator returns an empty result set without errors. 208 | 209 | ## Testing 210 | 211 | `UnionPaginator` is well-tested across various scenarios, including: 212 | - Multiple model unions. 213 | - Soft deletes handling. 214 | - Both raw and model-based transformations. 215 | - Large datasets and edge cases. 216 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "austinw/laravel-union-paginator", 3 | "description": "Combines data from multiple models into a single unified query using SQL unions, allowing for consistent pagination and customization across diverse data sources.", 4 | "keywords": [ 5 | "austinw", 6 | "laravel-union-paginator", 7 | "pagination", 8 | "union", 9 | "multiple" 10 | ], 11 | "homepage": "https://github.com/austinw/laravel-union-paginator", 12 | "license": "MIT", 13 | "support": { 14 | "issues": "https://github.com/austinw/laravel-union-paginator/issues", 15 | "source": "https://github.com/austinw/laravel-union-paginator" 16 | }, 17 | "authors": [ 18 | { 19 | "name": "Austin White", 20 | "email": "austingym@gmail.com", 21 | "homepage": "https://austinw.dev", 22 | "role": "Developer" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.2", 27 | "illuminate/contracts": "^10.0|^11.0|^12.0", 28 | "illuminate/database": "^10.0|^11.0|^12.0", 29 | "illuminate/support": "^10.0|^11.0|^12.0", 30 | "illuminate/pagination": "^10.0|^11.0|^12.0" 31 | }, 32 | "require-dev": { 33 | "roave/security-advisories": "dev-latest", 34 | "ext-json": "*", 35 | "larastan/larastan": "^2.9", 36 | "mockery/mockery": "^1.4", 37 | "orchestra/testbench": "^7.0|^8.0", 38 | "pestphp/pest": "^2.0", 39 | "phpunit/phpunit": "^10.0" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "AustinW\\UnionPaginator\\": "src", 44 | "AustinW\\UnionPaginator\\Database\\Factories\\": "database/factories" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "AustinW\\UnionPaginator\\Tests\\": "tests" 50 | } 51 | }, 52 | "scripts": { 53 | "test": "vendor/bin/pest", 54 | "test-coverage": "phpunit --coverage-html coverage", 55 | "analyse": "vendor/bin/phpstan analyse --ansi --memory-limit=4G", 56 | "baseline": "vendor/bin/phpstan analyse --generate-baseline --memory-limit=4G" 57 | }, 58 | "config": { 59 | "sort-packages": true, 60 | "allow-plugins": { 61 | "pestphp/pest-plugin": true 62 | } 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "AustinW\\UnionPaginator\\UnionPaginatorServiceProvider" 68 | ] 69 | } 70 | }, 71 | "minimum-stability": "dev", 72 | "prefer-stable": true 73 | } 74 | -------------------------------------------------------------------------------- /database/factories/CommentModelFactory.php: -------------------------------------------------------------------------------- 1 | UserModel::factory(), 17 | 'post_id' => PostModel::factory(), 18 | 'content' => $this->faker->paragraph, 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /database/factories/PostModelFactory.php: -------------------------------------------------------------------------------- 1 | UserModel::factory(), 16 | 'title' => $this->faker->sentence, 17 | 'content' => $this->faker->paragraph, 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /database/factories/UserModelFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 15 | 'email' => $this->faker->email, 16 | 'password' => $this->faker->password, 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | 4 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - phpstan-baseline.neon 4 | 5 | parameters: 6 | 7 | paths: 8 | - src/ 9 | 10 | # Level 9 is the highest level 11 | level: 5 12 | 13 | checkModelProperties: true 14 | checkOctaneCompatibility: true 15 | reportUnmatchedIgnoredErrors: false 16 | noUnnecessaryCollectionCall: true 17 | checkNullables: true 18 | treatPhpDocTypesAsCertain: false 19 | 20 | ignoreErrors: 21 | - '#PHPDoc tag @var#' 22 | 23 | excludePaths: 24 | -------------------------------------------------------------------------------- /src/UnionPaginator.php: -------------------------------------------------------------------------------- 1 | addModelType($modelType); 78 | } 79 | } 80 | 81 | /** 82 | * Create a new instance for the given model types. 83 | * 84 | * @param array $modelTypes 85 | * @return self 86 | */ 87 | public static function forModels(array $modelTypes): self 88 | { 89 | return new self($modelTypes); 90 | } 91 | 92 | /** 93 | * Add a model type to the paginator. 94 | * 95 | * @param string $modelType 96 | * @return self 97 | */ 98 | public function addModelType(string $modelType): self 99 | { 100 | $this->modelTypes[] = $modelType; 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * Prevent model retrieval during pagination. 107 | * 108 | * @return self 109 | */ 110 | public function preventModelRetrieval(): self 111 | { 112 | $this->preventModelRetrieval = true; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Prepare the union query for pagination. 119 | * 120 | * @return self 121 | * @throws BadMethodCallException 122 | */ 123 | public function prepareUnionQuery(): self 124 | { 125 | $this->unionQuery = null; 126 | 127 | if (empty($this->modelTypes)) { 128 | throw new BadMethodCallException('No models have been added to the UnionPaginator.'); 129 | } 130 | 131 | foreach ($this->modelTypes as $modelType) { 132 | /** @var Model $model */ 133 | $model = new $modelType; 134 | $columns = $this->selectedColumns[$modelType] ?? $this->defaultColumns($model); 135 | 136 | $query = $model->newQuery()->select($columns); 137 | 138 | if ($this->hasScope($modelType)) { 139 | foreach ($this->getScopesFor($modelType) as $modelScope) { 140 | $modelScope($query); 141 | } 142 | } 143 | 144 | if ($this->unionQuery) { 145 | $this->unionQuery = $this->unionQuery->union($query); 146 | } else { 147 | $this->unionQuery = $query; 148 | } 149 | } 150 | 151 | return $this; 152 | } 153 | 154 | /** 155 | * Paginate the results of the union query. 156 | * 157 | * @param int $perPage 158 | * @param array|string $columns 159 | * @param string $pageName 160 | * @param int|null $page 161 | * @return LengthAwarePaginator 162 | */ 163 | public function paginate(int $perPage = 15, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null): LengthAwarePaginator 164 | { 165 | if (!$this->unionQuery) { 166 | $this->prepareUnionQuery(); 167 | } 168 | 169 | $paginated = $this->executePagination($perPage, $columns, $pageName, $page); 170 | 171 | $items = $paginated->items(); 172 | 173 | if (empty($items)) { 174 | return $paginated; 175 | } 176 | 177 | $transformedItems = $this->preventModelRetrieval 178 | ? $this->transformItemsWithoutModels($items) 179 | : $this->transformItemsWithModels($items); 180 | 181 | $paginated->setCollection(collect($transformedItems)); 182 | 183 | return $paginated; 184 | } 185 | 186 | /** 187 | * Execute the pagination query. 188 | * 189 | * @param int $perPage 190 | * @param array|string $columns 191 | * @param string $pageName 192 | * @param int|null $page 193 | * @return LengthAwarePaginator 194 | */ 195 | protected function executePagination(int $perPage, array|string $columns, string $pageName, ?int $page): LengthAwarePaginator 196 | { 197 | $items = DB::table(DB::raw("({$this->unionQuery->toSql()}) as subquery")) 198 | ->mergeBindings($this->unionQuery->getQuery()) 199 | ->forPage($page, $perPage) 200 | ->get($columns); 201 | 202 | return new LengthAwarePaginator( 203 | $items, 204 | $this->unionQuery->count(), 205 | $perPage, 206 | $page, 207 | ['path' => LengthAwarePaginator::resolveCurrentPath(), 'pageName' => $pageName] 208 | ); 209 | } 210 | 211 | /** 212 | * Transform items without retrieving models. 213 | * 214 | * @param array $items 215 | * @return array 216 | */ 217 | protected function transformItemsWithoutModels(array $items): array 218 | { 219 | $transformedItems = []; 220 | 221 | foreach ($items as $item) { 222 | $modelType = $item->type; 223 | 224 | $transformedItems[] = $this->applyTransformer($modelType, $item); 225 | } 226 | 227 | return $transformedItems; 228 | } 229 | 230 | /** 231 | * Transform items with retrieved models. 232 | * 233 | * @param array $items 234 | * @return array 235 | */ 236 | protected function transformItemsWithModels(array $items): array 237 | { 238 | $itemsByType = collect($items)->groupBy('type'); 239 | $modelsByType = $this->loadModelsByType($itemsByType); 240 | 241 | $transformedItems = []; 242 | 243 | foreach ($items as $item) { 244 | $modelType = $item->type; 245 | $id = $item->id; 246 | 247 | $loadedModel = $modelsByType[$modelType][$id] ?? null; 248 | 249 | $transformedItems[] = $this->applyTransformer($modelType, $loadedModel); 250 | } 251 | 252 | return $transformedItems; 253 | } 254 | 255 | /** 256 | * Load models by their type. 257 | * 258 | * @param Collection $itemsByType 259 | * @return array 260 | */ 261 | protected function loadModelsByType(Collection $itemsByType): array 262 | { 263 | $modelsByType = []; 264 | 265 | foreach ($itemsByType as $modelType => $groupedItems) { 266 | $ids = $groupedItems->pluck('id')->unique()->toArray(); 267 | 268 | $models = $this->retrieveModels($modelType, $ids); 269 | 270 | $modelsByType[$modelType] = $models->keyBy( 271 | $models->first()?->getKeyName() ?? 'id' 272 | ); 273 | } 274 | 275 | return $modelsByType; 276 | } 277 | 278 | /** 279 | * Apply a transformer to an item. 280 | * 281 | * @param string $modelType 282 | * @param mixed $item 283 | * @return mixed 284 | */ 285 | protected function applyTransformer(string $modelType, $item): mixed 286 | { 287 | if (isset($this->transformers[$modelType])) { 288 | $callable = $this->transformers[$modelType]; 289 | return $callable($item); 290 | } 291 | 292 | return $item; 293 | } 294 | 295 | /** 296 | * Register a transformer for a specific model type. 297 | * 298 | * @param string $modelType 299 | * @param Closure $callable 300 | * @return self 301 | */ 302 | public function transformResultsFor(string $modelType, Closure $callable): self 303 | { 304 | if (!in_array($modelType, $this->modelTypes)) { 305 | return $this; 306 | } 307 | 308 | $this->transformers[$modelType] = $callable; 309 | 310 | return $this; 311 | } 312 | 313 | /** 314 | * Check if a scope exists for a model type. 315 | * 316 | * @param string $modelType 317 | * @return bool 318 | */ 319 | public function hasScope(string $modelType): bool 320 | { 321 | return collect($this->scopes)->filter(fn ($scope) => $scope[0] === $modelType)->isNotEmpty(); 322 | } 323 | 324 | /** 325 | * Get scopes for a specific model type. 326 | * 327 | * @param string $modelType 328 | * @return Collection 329 | */ 330 | public function getScopesFor(string $modelType): Collection 331 | { 332 | return collect($this->scopes)->filter(fn ($scope) => $scope[0] === $modelType)->map(fn ($scope) => $scope[1]); 333 | } 334 | 335 | /** 336 | * Apply a scope to a model type. 337 | * 338 | * @param string $modelType 339 | * @param Closure $callable 340 | * @return self 341 | */ 342 | public function applyScope(string $modelType, Closure $callable): self 343 | { 344 | $this->scopes[] = [$modelType, $callable]; 345 | 346 | return $this; 347 | } 348 | 349 | /** 350 | * Retrieve models for a given type using a registered callback or default logic. 351 | * 352 | * @param string $modelType 353 | * @param array $ids 354 | * @return Collection 355 | */ 356 | protected function retrieveModels(string $modelType, array $ids): Collection 357 | { 358 | if (isset($this->modelRetrievalCallbacks[$modelType])) { 359 | return call_user_func($this->modelRetrievalCallbacks[$modelType], $ids); 360 | } 361 | 362 | // Default retrieval logic 363 | return $modelType::findMany($ids); 364 | } 365 | 366 | /** 367 | * Register a custom callback for retrieving models by type. 368 | * 369 | * @param string $modelType 370 | * @param Closure $callback 371 | * @return self 372 | * @throws InvalidArgumentException 373 | */ 374 | public function fetchModelsUsing(string $modelType, Closure $callback): self 375 | { 376 | if (!in_array($modelType, $this->modelTypes)) { 377 | throw new InvalidArgumentException("Model type {$modelType} is not registered in this paginator."); 378 | } 379 | 380 | $this->modelRetrievalCallbacks[$modelType] = $callback; 381 | 382 | return $this; 383 | } 384 | 385 | /** 386 | * Get the list of model types. 387 | * 388 | * @return array 389 | */ 390 | public function getModelTypes(): array 391 | { 392 | return $this->modelTypes; 393 | } 394 | 395 | /** 396 | * Set the list of model types. 397 | * 398 | * @param array $modelTypes 399 | * @return self 400 | */ 401 | public function setModelTypes(array $modelTypes): self 402 | { 403 | $this->modelTypes = $modelTypes; 404 | 405 | return $this; 406 | } 407 | 408 | /** 409 | * Set the selected columns for a specific model type. 410 | * 411 | * @param string $modelType 412 | * @param array $columns 413 | * @return self 414 | */ 415 | public function setSelectedColumns(string $modelType, array $columns): self 416 | { 417 | $this->selectedColumns[$modelType] = $columns; 418 | 419 | return $this; 420 | } 421 | 422 | /** 423 | * Get the default columns for a model. 424 | * 425 | * @param Model $model 426 | * @return array 427 | */ 428 | protected function defaultColumns(Model $model): array 429 | { 430 | $className = $model::class; 431 | 432 | if (!in_array(DB::getDriverName(), ['sqlite', 'pgsql'])) { 433 | $className = addslashes($className); 434 | } 435 | 436 | return [ 437 | $model->getKeyName(), 438 | 'created_at', 439 | 'updated_at', 440 | DB::raw(sprintf("'%s' as type", $className)) 441 | ]; 442 | } 443 | 444 | /** 445 | * Handle dynamic method calls into the union query. 446 | * 447 | * @param string $method 448 | * @param array $parameters 449 | * @return $this 450 | */ 451 | public function __call(string $method, array $parameters) 452 | { 453 | if (!$this->unionQuery) { 454 | $this->prepareUnionQuery(); 455 | } 456 | 457 | $this->forwardCallTo($this->unionQuery, $method, $parameters); 458 | 459 | return $this; 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /src/UnionPaginatorServiceProvider.php: -------------------------------------------------------------------------------- 1 |