├── phpstan-baseline.neon ├── src ├── UnionPaginatorServiceProvider.php └── UnionPaginator.php ├── phpstan.neon.dist ├── database └── factories │ ├── UserModelFactory.php │ ├── PostModelFactory.php │ └── CommentModelFactory.php ├── .github └── workflows │ ├── run-linter.yml │ └── run-tests.yml ├── LICENSE.md ├── .php_cs.dist.php ├── composer.json ├── MIGRATION.md └── README.md /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | 4 | -------------------------------------------------------------------------------- /src/UnionPaginatorServiceProvider.php: -------------------------------------------------------------------------------- 1 | $this->faker->name, 15 | 'email' => $this->faker->email, 16 | 'password' => $this->faker->password, 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /database/factories/PostModelFactory.php: -------------------------------------------------------------------------------- 1 | UserModel::factory(), 16 | 'title' => $this->faker->sentence, 17 | 'content' => $this->faker->paragraph, 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /database/factories/CommentModelFactory.php: -------------------------------------------------------------------------------- 1 | UserModel::factory(), 17 | 'post_id' => PostModel::factory(), 18 | 'content' => $this->faker->paragraph, 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | if (is_null($page)) { 170 | $page = LengthAwarePaginator::resolveCurrentPage($pageName); 171 | } 172 | 173 | $paginated = $this->executePagination($perPage, $columns, $pageName, $page); 174 | 175 | $items = $paginated->items(); 176 | 177 | if (empty($items)) { 178 | return $paginated; 179 | } 180 | 181 | $transformedItems = $this->preventModelRetrieval 182 | ? $this->transformItemsWithoutModels($items) 183 | : $this->transformItemsWithModels($items); 184 | 185 | $paginated->setCollection(collect($transformedItems)); 186 | 187 | return $paginated; 188 | } 189 | 190 | /** 191 | * Execute the pagination query. 192 | * 193 | * @param int $perPage 194 | * @param array|string $columns 195 | * @param string $pageName 196 | * @param int|null $page 197 | * @return LengthAwarePaginator 198 | */ 199 | protected function executePagination(int $perPage, array|string $columns, string $pageName, ?int $page): LengthAwarePaginator 200 | { 201 | $items = DB::table(DB::raw("({$this->unionQuery->toSql()}) as subquery")) 202 | ->mergeBindings($this->unionQuery->getQuery()) 203 | ->forPage($page, $perPage) 204 | ->get($columns); 205 | 206 | return new LengthAwarePaginator( 207 | $items, 208 | $this->unionQuery->count(), 209 | $perPage, 210 | $page, 211 | ['path' => LengthAwarePaginator::resolveCurrentPath(), 'pageName' => $pageName] 212 | ); 213 | } 214 | 215 | /** 216 | * Transform items without retrieving models. 217 | * 218 | * @param array $items 219 | * @return array 220 | */ 221 | protected function transformItemsWithoutModels(array $items): array 222 | { 223 | $transformedItems = []; 224 | 225 | foreach ($items as $item) { 226 | $modelType = $item->type; 227 | 228 | $transformedItems[] = $this->applyTransformer($modelType, $item); 229 | } 230 | 231 | return $transformedItems; 232 | } 233 | 234 | /** 235 | * Transform items with retrieved models. 236 | * 237 | * @param array $items 238 | * @return array 239 | */ 240 | protected function transformItemsWithModels(array $items): array 241 | { 242 | $itemsByType = collect($items)->groupBy('type'); 243 | $modelsByType = $this->loadModelsByType($itemsByType); 244 | 245 | $transformedItems = []; 246 | 247 | foreach ($items as $item) { 248 | $modelType = $item->type; 249 | $id = $item->id; 250 | 251 | $loadedModel = $modelsByType[$modelType][$id] ?? null; 252 | 253 | $transformedItems[] = $this->applyTransformer($modelType, $loadedModel); 254 | } 255 | 256 | return $transformedItems; 257 | } 258 | 259 | /** 260 | * Load models by their type. 261 | * 262 | * @param Collection $itemsByType 263 | * @return array 264 | */ 265 | protected function loadModelsByType(Collection $itemsByType): array 266 | { 267 | $modelsByType = []; 268 | 269 | foreach ($itemsByType as $modelType => $groupedItems) { 270 | $ids = $groupedItems->pluck('id')->unique()->toArray(); 271 | 272 | $models = $this->retrieveModels($modelType, $ids); 273 | 274 | $modelsByType[$modelType] = $models->keyBy( 275 | $models->first()?->getKeyName() ?? 'id' 276 | ); 277 | } 278 | 279 | return $modelsByType; 280 | } 281 | 282 | /** 283 | * Apply a transformer to an item. 284 | * 285 | * @param string $modelType 286 | * @param mixed $item 287 | * @return mixed 288 | */ 289 | protected function applyTransformer(string $modelType, $item): mixed 290 | { 291 | if (isset($this->transformers[$modelType])) { 292 | $callable = $this->transformers[$modelType]; 293 | return $callable($item); 294 | } 295 | 296 | return $item; 297 | } 298 | 299 | /** 300 | * Register a transformer for a specific model type. 301 | * 302 | * @param string $modelType 303 | * @param Closure $callable 304 | * @return self 305 | */ 306 | public function transformResultsFor(string $modelType, Closure $callable): self 307 | { 308 | if (!in_array($modelType, $this->modelTypes)) { 309 | return $this; 310 | } 311 | 312 | $this->transformers[$modelType] = $callable; 313 | 314 | return $this; 315 | } 316 | 317 | /** 318 | * Check if a scope exists for a model type. 319 | * 320 | * @param string $modelType 321 | * @return bool 322 | */ 323 | public function hasScope(string $modelType): bool 324 | { 325 | return collect($this->scopes)->filter(fn ($scope) => $scope[0] === $modelType)->isNotEmpty(); 326 | } 327 | 328 | /** 329 | * Get scopes for a specific model type. 330 | * 331 | * @param string $modelType 332 | * @return Collection 333 | */ 334 | public function getScopesFor(string $modelType): Collection 335 | { 336 | return collect($this->scopes)->filter(fn ($scope) => $scope[0] === $modelType)->map(fn ($scope) => $scope[1]); 337 | } 338 | 339 | /** 340 | * Apply a scope to a model type. 341 | * 342 | * @param string $modelType 343 | * @param Closure $callable 344 | * @return self 345 | */ 346 | public function applyScope(string $modelType, Closure $callable): self 347 | { 348 | $this->scopes[] = [$modelType, $callable]; 349 | 350 | return $this; 351 | } 352 | 353 | /** 354 | * Retrieve models for a given type using a registered callback or default logic. 355 | * 356 | * @param string $modelType 357 | * @param array $ids 358 | * @return Collection 359 | */ 360 | protected function retrieveModels(string $modelType, array $ids): Collection 361 | { 362 | if (isset($this->modelRetrievalCallbacks[$modelType])) { 363 | return call_user_func($this->modelRetrievalCallbacks[$modelType], $ids); 364 | } 365 | 366 | // Default retrieval logic 367 | return $modelType::findMany($ids); 368 | } 369 | 370 | /** 371 | * Register a custom callback for retrieving models by type. 372 | * 373 | * @param string $modelType 374 | * @param Closure $callback 375 | * @return self 376 | * @throws InvalidArgumentException 377 | */ 378 | public function fetchModelsUsing(string $modelType, Closure $callback): self 379 | { 380 | if (!in_array($modelType, $this->modelTypes)) { 381 | throw new InvalidArgumentException("Model type {$modelType} is not registered in this paginator."); 382 | } 383 | 384 | $this->modelRetrievalCallbacks[$modelType] = $callback; 385 | 386 | return $this; 387 | } 388 | 389 | /** 390 | * Get the list of model types. 391 | * 392 | * @return array 393 | */ 394 | public function getModelTypes(): array 395 | { 396 | return $this->modelTypes; 397 | } 398 | 399 | /** 400 | * Set the list of model types. 401 | * 402 | * @param array $modelTypes 403 | * @return self 404 | */ 405 | public function setModelTypes(array $modelTypes): self 406 | { 407 | $this->modelTypes = $modelTypes; 408 | 409 | return $this; 410 | } 411 | 412 | /** 413 | * Set the selected columns for a specific model type. 414 | * 415 | * @param string $modelType 416 | * @param array $columns 417 | * @return self 418 | */ 419 | public function setSelectedColumns(string $modelType, array $columns): self 420 | { 421 | $this->selectedColumns[$modelType] = $columns; 422 | 423 | return $this; 424 | } 425 | 426 | /** 427 | * Get the default columns for a model. 428 | * 429 | * @param Model $model 430 | * @return array 431 | */ 432 | protected function defaultColumns(Model $model): array 433 | { 434 | $className = $model::class; 435 | 436 | if (!in_array(DB::getDriverName(), ['sqlite', 'pgsql'])) { 437 | $className = addslashes($className); 438 | } 439 | 440 | return [ 441 | $model->getKeyName(), 442 | 'created_at', 443 | 'updated_at', 444 | DB::raw(sprintf("'%s' as type", $className)) 445 | ]; 446 | } 447 | 448 | /** 449 | * Handle dynamic method calls into the union query. 450 | * 451 | * @param string $method 452 | * @param array $parameters 453 | * @return $this 454 | */ 455 | public function __call(string $method, array $parameters) 456 | { 457 | if (!$this->unionQuery) { 458 | $this->prepareUnionQuery(); 459 | } 460 | 461 | $this->forwardCallTo($this->unionQuery, $method, $parameters); 462 | 463 | return $this; 464 | } 465 | } 466 | --------------------------------------------------------------------------------