├── .editorconfig ├── .github └── workflows │ └── PHPUnit.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── repository.php ├── phpstan.neon.dist ├── phpunit.xml ├── src ├── BaseRepository.php ├── Contracts │ ├── BaseRepositoryInterface.php │ ├── CriteriaInterface.php │ ├── ExtendedRepositoryInterface.php │ ├── FindsModelsByTranslationInterface.php │ ├── HandlesEloquentRelationManipulationInterface.php │ ├── HandlesEloquentSavingInterface.php │ └── HandlesListifyModelsInterface.php ├── Criteria │ ├── AbstractCriteria.php │ ├── Common │ │ ├── Custom.php │ │ ├── FieldIsValue.php │ │ ├── Has.php │ │ ├── IsActive.php │ │ ├── OrderBy.php │ │ ├── Scope.php │ │ ├── Scopes.php │ │ ├── Take.php │ │ ├── UseCache.php │ │ ├── WhereHas.php │ │ └── WithRelations.php │ ├── NullCriteria.php │ └── Translatable │ │ └── WhereHasTranslation.php ├── Enums │ └── CriteriaKey.php ├── Exceptions │ └── RepositoryException.php ├── ExtendedRepository.php ├── RepositoryServiceProvider.php └── Traits │ ├── FindsModelsByTranslationTrait.php │ ├── HandlesEloquentRelationManipulationTrait.php │ ├── HandlesEloquentSavingTrait.php │ └── HandlesListifyModelsTrait.php └── tests ├── BaseRepositoryTest.php ├── CommonCriteriaTest.php ├── ExtendedRepositoryTest.php ├── ExtendedRepositoryTraitsTest.php ├── Helpers ├── TestBaseRepository.php ├── TestExtendedModel.php ├── TestExtendedModelTranslation.php ├── TestExtendedRepository.php ├── TestSimpleModel.php └── TranslatableConfig.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /.github/workflows/PHPUnit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: [push] 4 | 5 | jobs: 6 | phpunit: 7 | name: PHPUnit 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Setup PHP 13 | uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: "8.2" 16 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 17 | coverage: none 18 | 19 | - name: Create database 20 | run: | 21 | sudo /etc/init.d/mysql start 22 | mysql -u root -proot -e 'CREATE DATABASE IF NOT EXISTS laravel;' 23 | 24 | - name: Get Composer Cache Directory 25 | id: composer-cache 26 | run: | 27 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 28 | 29 | - name: Cache Composer dependencies 30 | uses: actions/cache@v4 31 | with: 32 | path: ${{ steps.composer-cache.outputs.dir }} 33 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-composer- 36 | 37 | - name: Run composer install 38 | run: composer install -n --prefer-dist 39 | 40 | - name: Run PHPUnit 41 | run: ./vendor/bin/phpunit 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Introduced for version 4.0. 4 | 5 | 6 | ## 4.1.0 - 2022-09-29 7 | 8 | - Generic type templates added. 9 | - Style cleanup. 10 | 11 | ## 4.0.0 - 2022-09-29 12 | 13 | ### Breaking Changes 14 | - Changed signatures of all methods with stricter types. 15 | - Removed many fluent syntax implementations (return `$this`). 16 | - `pluck()` now returns a Collection instance, not an array. 17 | - Removed `lists()` method. 18 | - Removed Artisan command to make repository from stub. 19 | I felt this was a minor feature, not worth the hassle of upgrading. 20 | - Removed ExtendedPostProcessing variant of the repository. 21 | If you depend on this, either rebuild it for your local needs, or stick with an older version. 22 | 23 | ## 3.0.0 24 | 25 | Laravel 9 support without breaking changes. 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/czim/laravel-repository). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Coen Zimmerman 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Repository 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Latest Stable Version](http://img.shields.io/packagist/v/czim/laravel-repository.svg)](https://packagist.org/packages/czim/laravel-repository) 6 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/029ab930-9064-4acf-8602-87f8c010f387/mini.png)](https://insight.sensiolabs.com/projects/029ab930-9064-4acf-8602-87f8c010f387) 7 | 8 | Repository setup inspired by the Bosnadev/Repository package. This package is an extended, adjusted (but entirely independent) version of that, with its own interfaces. 9 | 10 | One major difference to the Bosnadev repository is that this one is able to deal with repeated and varying calls to the same repository instance, without breaking down or undesirable repeated application of Criteria. 11 | You can instantiate a repository once and do anything with it in any order, and both queries and model manipulation methods will keep working. 12 | 13 | Among the added functionality is the ability to override or 'temporarily' set and remove Criteria, post-processing models after retrieval. 14 | 15 | I'm well aware that there is *much* to say against using Repositories like this (and the repository pattern in general), but I find they have their uses. 16 | I prefer using them to make for easier unit testing in large projects. 17 | 18 | > Note: I recommand against using this package. I'm making some updates for my personal legacy projects, 19 | > but I consider this approach to be a serious antipattern (at least with Eloquent). 20 | 21 | ## Version Compatibility 22 | 23 | | Laravel | Package | 24 | |:-----------|:---------| 25 | | 5.1 | 1.0 | 26 | | 5.2 | 1.2 | 27 | | 5.3 | 1.2 | 28 | | 5.4 to 5.8 | 1.4 | 29 | | 6.0 | 2.0 | 30 | | 7.0, 8.0 | 2.1 | 31 | | 9.0 | 3.0, 4.0 | 32 | | 10.0 | 4.2 | 33 | | 11.0 | 4.3 | 34 | | 12.0 | 4.4 | 35 | 36 | ### Warning 37 | 38 | Version 4.0 has many breaking changes. 39 | Refer to the [Changelog](CHANGELOG.md) for details. 40 | 41 | 42 | ## Install 43 | 44 | Via Composer 45 | 46 | ``` bash 47 | $ composer require czim/laravel-repository 48 | ``` 49 | 50 | If you want to use the repository generator through the `make:repository` Artisan command, add the `RepositoryServiceProvider` to your `config/app.php`: 51 | 52 | ``` php 53 | Czim\Repository\RepositoryServiceProvider::class, 54 | ``` 55 | 56 | Publish the repostory configuration file. 57 | 58 | ``` bash 59 | php artisan vendor:publish --tag="repository" 60 | ``` 61 | 62 | 63 | ## Basic Usage 64 | 65 | Simply extend the (abstract) repository class of your choice, either `Czim\Repository\BaseRepository`, `Czim\Repository\ExtendedRepository` or `Czim\Repository\ExtendedPostProcessingRepository`. 66 | 67 | The only abstract method that must be provided is the `model` method (this is just like the way Bosnadev's repositories are used). 68 | 69 | 70 | ### Base- and Extended Repositories 71 | 72 | Depending on what you require, three different abstract repository classes may be extended: 73 | 74 | * `BaseRepository` 75 | 76 | Only has the retrieval and simple manipulation methods (`create()`, `update()` and `delete()`), and Criteria handling. 77 | 78 | * `ExtendedRepository` 79 | 80 | Handles an **active** check for Models, which will by default exclude any model which will not have its `active` attribute set to true (configurable by setting `hasActive` and/or `activeColumn`). 81 | Handles caching, using [dwightwatson/rememberable](https://github.com/dwightwatson/rememberable) by default (but you can use your own Caching Criteria if desired). 82 | Allows you to set Model scopes, for when you want to use an Eloquent model scope to build your query. 83 | 84 | ### Using the repository to retrieve models 85 | 86 | Apart from the basic stuff (inspired by Bosnadev), there are some added methods for retrieval: 87 | 88 | * `query()`: returns an Eloquent\Builder object reflecting the active criteria, for added flexibility 89 | * `count()` 90 | * `first()` 91 | * `findOrFail()`: just like `find()`, but throws an exception if nothing found 92 | * `firstOrFail()`: just like `first()`, but throws an exception if nothing found 93 | 94 | Every retrieval method takes into account the currently active Criteria (including one-time overrides), see below. 95 | 96 | For the `ExtendedPostProcessingRepository` goes that postprocessors affect all models returned, and so are applied in all the retrieval methods (`find()`, `firstOrFail()`, `all()`, `allCallback`, etc). 97 | The `query()` method returns a Builder object and therefore circumvents postprocessing. If you want to manually use the postprocessors, simply call `postProcess()` on any Model or Collection of models. 98 | 99 | 100 | #### Handling Criteria 101 | 102 | Just like Bosnadev's repository, Criteria may be pushed onto the repository to build queries. 103 | It is also possible to set default Criteria for the repository by overriding the `defaultCriteria()` method and returning a Collection of Criteria instances. 104 | 105 | Criteria may be defined or pushed onto the repository by **key**, like so: 106 | 107 | ``` php 108 | $repository->pushCriteria(new SomeCriteria(), 'KeyForCriteria'); 109 | ``` 110 | 111 | This allows you to later remove the Criteria by referring to its key: 112 | 113 | ``` php 114 | // you can remove Criteria by key 115 | $repository->removeCriteria('KeyForCriteria'); 116 | ``` 117 | 118 | To change the Criteria that are to be used only for one call, there are helper methods that will preserve your currently active Criteria. 119 | If you use any of the following, the active Criteria are applied (insofar they are not removed or overridden), and additional Criteria are applied only for the next retrieval method. 120 | 121 | ``` php 122 | // you can push one-time Criteria 123 | $repository->pushCriteriaOnce(new SomeOtherCriteria()); 124 | 125 | // you can override active criteria once by using its key 126 | $repository->pushCriteriaOnce(new SomeOtherCriteria(), 'KeyForCriteria'); 127 | 128 | // you can remove Criteria *only* for the next retrieval, by key 129 | $repository->removeCriteriaOnce('KeyForCriteria'); 130 | ``` 131 | 132 | Note that this means that *only* Criteria that have keys can be removed or overridden this way. 133 | A `CriteriaKey` Enum is provided to more easily refer to the standard keys used in the `ExtendedRepository`, such as 'active', 'cache' and 'scope'. 134 | 135 | 136 | ## Configuration 137 | No configuration is required to start using the repository. You use it by extending an abstract repository class of your choice. 138 | 139 | ### Extending the classes 140 | Some properties and methods may be extended for tweaking the way things work. 141 | For now there is no documentation about this (I will add some later), but the repository classes contain many comments to help you find your way (mainly check the `ExtendedRepository` class). 142 | 143 | ### Traits 144 | Additionally, there are some traits that may be used to extend the functionality of the repositories, see `Czim\Repository\Traits`: 145 | 146 | * `FindsModelsByTranslationTrait` (only useful in combination with the [dimsav/laravel-translatable](https://github.com/dimsav/laravel-translatable) package) 147 | * `HandlesEloquentRelationManipulationTrait` 148 | * `HandlesEloquentSavingTrait` 149 | * `HandlesListifyModelsTrait` (only useful in combination with the [lookitsatravis/listify](https://github.com/lookitsatravis/listify) package) 150 | 151 | I've added these mainly because they may help in using the repository pattern as a means to make unit testing possible without having to mock Eloquent models. 152 | 153 | 154 | ## Contributing 155 | 156 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 157 | 158 | 159 | ## Credits 160 | 161 | - [Coen Zimmerman][link-author] 162 | - [All Contributors][link-contributors] 163 | 164 | ## License 165 | 166 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 167 | 168 | [ico-version]: https://img.shields.io/packagist/v/czim/laravel-repository.svg?style=flat-square 169 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 170 | [ico-downloads]: https://img.shields.io/packagist/dt/czim/laravel-repository.svg?style=flat-square 171 | 172 | [link-packagist]: https://packagist.org/packages/czim/laravel-repository 173 | [link-downloads]: https://packagist.org/packages/czim/laravel-repository 174 | [link-author]: https://github.com/czim 175 | [link-contributors]: ../../contributors 176 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "czim/laravel-repository", 3 | "description": "Repository for Laravel (inspired by and indebted to Bosnadev/Repositories)", 4 | "keywords": [ 5 | "laravel", 6 | "repository", 7 | "repositories", 8 | "eloquent", 9 | "database", 10 | "criteria" 11 | ], 12 | "homepage": "https://github.com/czim/laravel-repository", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Coen Zimmerman", 17 | "email": "coen.zimmerman@endeavour.nl", 18 | "homepage": "https://endeavour.nl", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.2", 24 | "illuminate/support": "^11.0 || ^12.0", 25 | "illuminate/database": "^11.0 || ^12.0", 26 | "myclabs/php-enum": "^1.7" 27 | }, 28 | "require-dev": { 29 | "astrotomic/laravel-translatable": "^11.10", 30 | "czim/laravel-listify": "^2.0", 31 | "orchestra/testbench": "^8.0 || ^9.0", 32 | "larastan/larastan": "^2.10 || 3.3", 33 | "phpstan/phpstan-mockery": "^2.0", 34 | "phpstan/phpstan-phpunit": "^2.0", 35 | "phpunit/phpunit": "^10 || ^11.0", 36 | "watson/rememberable": "^6.0" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Czim\\Repository\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Czim\\Repository\\Test\\": "tests" 46 | } 47 | }, 48 | "scripts": { 49 | "test": "phpunit" 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "Czim\\Repository\\RepositoryServiceProvider" 55 | ] 56 | } 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /config/repository.php: -------------------------------------------------------------------------------- 1 | 1, 6 | ]; 7 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - ./vendor/phpstan/phpstan-phpunit/extension.neon 4 | - ./vendor/phpstan/phpstan-mockery/extension.neon 5 | 6 | parameters: 7 | level: 0 8 | paths: 9 | - src 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | ./tests/TestCase.php 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/BaseRepository.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | abstract class BaseRepository implements BaseRepositoryInterface 39 | { 40 | protected ContainerInterface $app; 41 | 42 | /** 43 | * @var TModel|EloquentBuilder|BaseBuilder 44 | */ 45 | protected Model|EloquentBuilder|BaseBuilder $modelOrQuery; 46 | 47 | /** 48 | * Criteria to keep and use for all coming queries 49 | * 50 | * @var Collection> 51 | */ 52 | protected Collection $criteria; 53 | 54 | /** 55 | * The Criteria to only apply to the next query 56 | * 57 | * @var Collection> 58 | */ 59 | protected Collection $onceCriteria; 60 | 61 | /** 62 | * List of criteria that are currently active (updates when criteria are stripped) 63 | * So this is a dynamic list that can change during calls of various repository 64 | * methods that alter the active criteria. 65 | * 66 | * @var Collection> 67 | */ 68 | protected Collection $activeCriteria; 69 | 70 | /** 71 | * Whether to skip ALL criteria. 72 | * 73 | * @var bool 74 | */ 75 | protected bool $ignoreCriteria = false; 76 | 77 | /** 78 | * Default number of paginated items 79 | * 80 | * @var int 81 | */ 82 | protected int $perPage = 1; 83 | 84 | 85 | /** 86 | * @param ContainerInterface $container 87 | * @param Collection> $initialCriteria 88 | * @throws RepositoryException 89 | */ 90 | public function __construct(ContainerInterface $container, Collection $initialCriteria) 91 | { 92 | if ($initialCriteria->isEmpty()) { 93 | $initialCriteria = $this->defaultCriteria(); 94 | } 95 | 96 | $this->app = $container; 97 | $this->criteria = $initialCriteria; 98 | $this->onceCriteria = new Collection(); 99 | $this->activeCriteria = new Collection(); 100 | 101 | $this->makeModel(); 102 | } 103 | 104 | 105 | /** 106 | * Returns specified model class name. 107 | * 108 | * @return class-string 109 | */ 110 | abstract public function model(): string; 111 | 112 | 113 | /** 114 | * Creates instance of model to start building query for 115 | * 116 | * @param bool $storeModel if true, this becomes a fresh $this->model property 117 | * @return TModel 118 | * @throws RepositoryException 119 | */ 120 | public function makeModel(bool $storeModel = true): Model 121 | { 122 | try { 123 | $model = $this->app->get($this->model()); 124 | } catch (NotFoundExceptionInterface|ContainerExceptionInterface $exception) { 125 | throw new RepositoryException( 126 | "Class {$this->model()} could not be instantiated through the container", 127 | $exception->getCode(), 128 | $exception 129 | ); 130 | } 131 | 132 | if (! $model instanceof Model) { 133 | throw new RepositoryException( 134 | "Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model" 135 | ); 136 | } 137 | 138 | if ($storeModel) { 139 | $this->modelOrQuery = $model; 140 | } 141 | 142 | return $model; 143 | } 144 | 145 | 146 | // ------------------------------------------------------------------------- 147 | // Retrieval methods 148 | // ------------------------------------------------------------------------- 149 | 150 | /** 151 | * Give unexecuted (fresh) query wioth the current applied criteria. 152 | * 153 | * @return EloquentBuilder|BaseBuilder 154 | * @throws RepositoryException 155 | */ 156 | public function query(): EloquentBuilder|BaseBuilder 157 | { 158 | $this->applyCriteria(); 159 | 160 | if ($this->modelOrQuery instanceof Model) { 161 | return $this->modelOrQuery->newQuery(); 162 | } 163 | 164 | return clone $this->modelOrQuery; 165 | } 166 | 167 | public function count(): int 168 | { 169 | return $this->query()->count(); 170 | } 171 | 172 | /** 173 | * @param string[] $columns 174 | * @return TModel|null 175 | */ 176 | public function first(array $columns = ['*']): ?Model 177 | { 178 | return $this->query()->first($columns); 179 | } 180 | 181 | /** 182 | * @param string[] $columns 183 | * @return TModel|null 184 | * @throws ModelNotFoundException 185 | */ 186 | public function firstOrFail(array $columns = ['*']): ?Model 187 | { 188 | $result = $this->query()->first($columns); 189 | 190 | if (! empty($result)) { 191 | return $result; 192 | } 193 | 194 | throw (new ModelNotFoundException())->setModel($this->model()); 195 | } 196 | 197 | /** 198 | * @param string[] $columns 199 | * @return EloquentCollection 200 | */ 201 | public function all(array $columns = ['*']): EloquentCollection 202 | { 203 | return $this->query()->get($columns); 204 | } 205 | 206 | /** 207 | * @param string $value 208 | * @param string|null $key 209 | * @return Collection 210 | * @throws RepositoryException 211 | */ 212 | public function pluck(string $value, ?string $key = null): Collection 213 | { 214 | $this->applyCriteria(); 215 | 216 | return $this->query()->pluck($value, $key); 217 | } 218 | 219 | /** 220 | * @param int|null $perPage 221 | * @param string[] $columns 222 | * @param string $pageName 223 | * @param int|null $page 224 | * @return LengthAwarePaginator&iterable 225 | */ 226 | public function paginate( 227 | ?int $perPage = null, 228 | array $columns = ['*'], 229 | string $pageName = 'page', 230 | ?int $page = null, 231 | ): LengthAwarePaginator { 232 | $perPage ??= $this->getDefaultPerPage(); 233 | 234 | return $this->query() 235 | ->paginate($perPage, $columns, $pageName, $page); 236 | } 237 | 238 | /** 239 | * @param int|string $id 240 | * @param string[] $columns 241 | * @param string|null $attribute 242 | * @return TModel|null 243 | */ 244 | public function find(int|string $id, array $columns = ['*'], ?string $attribute = null): ?Model 245 | { 246 | $query = $this->query(); 247 | 248 | if ($attribute !== null && $attribute !== $query->getModel()->getKeyName()) { 249 | return $query->where($attribute, $id)->first($columns); 250 | } 251 | 252 | return $query->find($id, $columns); 253 | } 254 | 255 | /** 256 | * @param int|string $id 257 | * @param string[] $columns 258 | * @return TModel 259 | * @throws ModelNotFoundException 260 | */ 261 | public function findOrFail(int|string $id, array $columns = ['*']): Model 262 | { 263 | $result = $this->query()->find($id, $columns); 264 | 265 | if (! empty($result)) { 266 | return $result; 267 | } 268 | 269 | throw (new ModelNotFoundException())->setModel($this->model(), $id); 270 | } 271 | 272 | /** 273 | * @param string $attribute 274 | * @param mixed $value 275 | * @param string[] $columns 276 | * @return TModel|null 277 | */ 278 | public function findBy(string $attribute, mixed $value, array $columns = ['*']): ?Model 279 | { 280 | return $this->query() 281 | ->where($attribute, $value) 282 | ->first($columns); 283 | } 284 | 285 | /** 286 | * @param string $attribute 287 | * @param mixed $value 288 | * @param string[] $columns 289 | * @return EloquentCollection 290 | */ 291 | public function findAllBy(string $attribute, mixed $value, $columns = ['*']): EloquentCollection 292 | { 293 | return $this->query() 294 | ->where($attribute, $value) 295 | ->get($columns); 296 | } 297 | 298 | /** 299 | * Find a collection of models by the given query conditions. 300 | * 301 | * @param array|mixed> $where 302 | * @param string[] $columns 303 | * @param bool $or 304 | * @return EloquentCollection 305 | */ 306 | public function findWhere(array $where, array $columns = ['*'], bool $or = false): EloquentCollection 307 | { 308 | /** @var EloquentBuilder $model */ 309 | $model = $this->query(); 310 | 311 | foreach ($where as $field => $value) { 312 | if ($value instanceof Closure) { 313 | $model = (! $or) 314 | ? $model->where($value) 315 | : $model->orWhere($value); 316 | } elseif (is_array($value)) { 317 | if (count($value) === 3) { 318 | [$field, $operator, $search] = $value; 319 | 320 | $model = (! $or) 321 | ? $model->where($field, $operator, $search) 322 | : $model->orWhere($field, $operator, $search); 323 | } elseif (count($value) === 2) { 324 | [$field, $search] = $value; 325 | 326 | $model = (! $or) 327 | ? $model->where($field, $search) 328 | : $model->orWhere($field, $search); 329 | } 330 | } else { 331 | $model = (! $or) 332 | ? $model->where($field, $value) 333 | : $model->orWhere($field, $value); 334 | } 335 | } 336 | 337 | return $model->get($columns); 338 | } 339 | 340 | 341 | // ------------------------------------------------------------------------- 342 | // Manipulation methods 343 | // ------------------------------------------------------------------------- 344 | 345 | /** 346 | * Makes a new model without persisting it. 347 | * 348 | * @param array $data 349 | * @return TModel 350 | * @throws MassAssignmentException|RepositoryException 351 | */ 352 | public function make(array $data): Model 353 | { 354 | return $this->makeModel(false)->fill($data); 355 | } 356 | 357 | /** 358 | * Creates a model and returns it 359 | * 360 | * @param array $data 361 | * @return TModel|null 362 | * @throws RepositoryException 363 | */ 364 | public function create(array $data): ?Model 365 | { 366 | return $this->makeModel(false)->create($data); 367 | } 368 | 369 | /** 370 | * @param array $data 371 | * @param int|string $id 372 | * @param string|null $attribute 373 | * @return bool 374 | */ 375 | public function update(array $data, int|string $id, ?string $attribute = null): bool 376 | { 377 | $model = $this->find($id, ['*'], $attribute); 378 | 379 | if (! $model) { 380 | return false; 381 | } 382 | 383 | return $model->update($data); 384 | } 385 | 386 | /** 387 | * Finds and fills a model by id, without persisting changes. 388 | * 389 | * @param array $data 390 | * @param int|string $id 391 | * @param string|null $attribute 392 | * @return TModel|false 393 | * @throws MassAssignmentException|ModelNotFoundException 394 | */ 395 | public function fill(array $data, int|string $id, ?string $attribute = null): Model|false 396 | { 397 | $model = $this->find($id, ['*'], $attribute); 398 | 399 | if (! $model) { 400 | throw (new ModelNotFoundException())->setModel($this->model()); 401 | } 402 | 403 | return $model->fill($data); 404 | } 405 | 406 | /** 407 | * @param int|string $id 408 | * @return int 409 | * @throws RepositoryException 410 | */ 411 | public function delete(int|string $id): int 412 | { 413 | return $this->makeModel(false)->destroy($id); 414 | } 415 | 416 | 417 | // ------------------------------------------------------------------------- 418 | // With custom callback 419 | // ------------------------------------------------------------------------- 420 | 421 | /** 422 | * Applies callback to query for easier elaborate custom queries on all() calls. 423 | * 424 | * @param Closure $callback must return query/builder compatible 425 | * @param string[] $columns 426 | * @return EloquentCollection 427 | * @throws RepositoryException 428 | */ 429 | public function allCallback(Closure $callback, array $columns = ['*']): EloquentCollection 430 | { 431 | $result = $callback($this->query()); 432 | 433 | $this->assertValidCustomCallback($result); 434 | 435 | /** @var EloquentBuilder|BaseBuilder $result */ 436 | return $result->get($columns); 437 | } 438 | 439 | /** 440 | * Applies callback to query for easier elaborate custom queries on find (actually: ->first()) calls. 441 | * 442 | * @param Closure $callback must return query/builder compatible 443 | * @param string[] $columns 444 | * @return TModel|null 445 | * @throws RepositoryException 446 | */ 447 | public function findCallback(Closure $callback, array $columns = ['*']): ?Model 448 | { 449 | $result = $callback($this->query()); 450 | 451 | $this->assertValidCustomCallback($result); 452 | 453 | /** @var EloquentBuilder|BaseBuilder $result */ 454 | return $result->first($columns); 455 | } 456 | 457 | 458 | // ------------------------------------------------------------------------- 459 | // Criteria 460 | // ------------------------------------------------------------------------- 461 | 462 | /** 463 | * Returns a collection with the default criteria for the repository. 464 | * 465 | * These should be the criteria that apply for (almost) all calls. 466 | * 467 | * Default set of criteria to apply to this repository 468 | * Note that this also needs all the parameters to send to the constructor 469 | * of each (and this CANNOT be solved by using the classname of as key, 470 | * since the same Criteria may be applied more than once). 471 | * 472 | * Override with your own defaults (check ExtendedRepository's refreshed, 473 | * named Criteria for examples). 474 | * 475 | * @return Collection> 476 | */ 477 | public function defaultCriteria(): Collection 478 | { 479 | return new Collection(); 480 | } 481 | 482 | /** 483 | * Builds the default criteria and replaces the criteria stack to apply with the default collection. 484 | */ 485 | public function restoreDefaultCriteria(): void 486 | { 487 | $this->criteria = $this->defaultCriteria(); 488 | } 489 | 490 | public function clearCriteria(): void 491 | { 492 | $this->criteria = new Collection(); 493 | } 494 | 495 | /** 496 | * Sets or unsets ignoreCriteria flag. If it is set, all criteria (even 497 | * those set to apply once!) will be ignored. 498 | * 499 | * @param bool $ignore 500 | */ 501 | public function ignoreCriteria(bool $ignore = true): void 502 | { 503 | $this->ignoreCriteria = $ignore; 504 | } 505 | 506 | /** 507 | * Returns a cloned set of all currently set criteria (not including 508 | * those to be applied once). 509 | * 510 | * @return Collection> 511 | */ 512 | public function getCriteria(): Collection 513 | { 514 | return clone $this->criteria; 515 | } 516 | 517 | /** 518 | * Returns a cloned set of all currently set once criteria. 519 | * 520 | * @return Collection> 521 | */ 522 | public function getOnceCriteria(): Collection 523 | { 524 | return clone $this->onceCriteria; 525 | } 526 | 527 | /** 528 | * Returns a cloned set of all currently set criteria (not including those to be applied once). 529 | * 530 | * @return Collection> 531 | */ 532 | public function getAllCriteria(): Collection 533 | { 534 | return $this->getCriteria() 535 | ->merge($this->getOnceCriteria()); 536 | } 537 | 538 | /** 539 | * Applies Criteria to the model for the upcoming query. 540 | * 541 | * This takes the default/standard Criteria, then overrides them with whatever is found in the onceCriteria list. 542 | * 543 | * @throws RepositoryException 544 | */ 545 | public function applyCriteria(): void 546 | { 547 | // If we're ignoring criteria, the model must be remade without criteria ... 548 | if ($this->ignoreCriteria === true) { 549 | // ... and make sure that they are re-applied when we stop ignoring. 550 | if (! $this->activeCriteria->isEmpty()) { 551 | $this->makeModel(); 552 | $this->activeCriteria = new Collection(); 553 | } 554 | return; 555 | } 556 | 557 | if ($this->areActiveCriteriaUnchanged()) { 558 | return; 559 | } 560 | 561 | // If the new Criteria are different, clear the model and apply the new Criteria. 562 | $this->makeModel(); 563 | 564 | $this->markAppliedCriteriaAsActive(); 565 | 566 | 567 | // Apply the collected criteria to the query. 568 | foreach ($this->getCriteriaToApply() as $criteria) { 569 | $this->modelOrQuery = $criteria->apply($this->modelOrQuery, $this); 570 | } 571 | 572 | $this->clearOnceCriteria(); 573 | } 574 | 575 | /** 576 | * Pushes Criteria, optionally by identifying key. 577 | * 578 | * If a criteria already exists for the key, it is overridden. 579 | * Note that this does NOT overrule any onceCriteria, even if set by key! 580 | * 581 | * @param CriteriaInterface $criteria 582 | * @param string|null $key Unique identifier, may be used to remove and overwrite criteria 583 | */ 584 | public function pushCriteria(CriteriaInterface $criteria, ?string $key = null): void 585 | { 586 | // Standard bosnadev behavior. 587 | if ($key === null) { 588 | $this->criteria->push($criteria); 589 | return; 590 | } 591 | 592 | // Set/override by key. 593 | $this->criteria->put($key, $criteria); 594 | } 595 | 596 | public function removeCriteria(string $key): void 597 | { 598 | $this->criteria->forget($key); 599 | } 600 | 601 | /** 602 | * Pushes Criteria, but only for the next call, resets to default afterwards. 603 | * 604 | * Note that this does NOT work for specific criteria exclusively, it resets to default for ALL Criteria. 605 | * 606 | * @param CriteriaInterface $criteria 607 | * @param string|null $key 608 | * @return $this 609 | */ 610 | public function pushCriteriaOnce(CriteriaInterface $criteria, ?string $key = null): static 611 | { 612 | if ($key === null) { 613 | $this->onceCriteria->push($criteria); 614 | return $this; 615 | } 616 | 617 | // Set/override by key. 618 | $this->onceCriteria->put($key, $criteria); 619 | return $this; 620 | } 621 | 622 | /** 623 | * Removes Criteria, but only for the next call, resets to default afterwards. 624 | * 625 | * Note that this does NOT work for specific criteria exclusively, it resets 626 | * to default for ALL Criteria. 627 | * 628 | * In effect, this adds a NullCriteria to onceCriteria by key, disabling any criteria 629 | * by that key in the normal criteria list. 630 | * 631 | * @param string $key 632 | * @return $this 633 | */ 634 | public function removeCriteriaOnce(string $key): static 635 | { 636 | // If not present in normal list, there is nothing to override. 637 | if (! $this->criteria->has($key)) { 638 | return $this; 639 | } 640 | 641 | // Override by key with null-value. 642 | /** @var NullCriteria $nullCriterion */ 643 | $nullCriterion = new NullCriteria(); 644 | 645 | $this->onceCriteria->put($key, $nullCriterion); 646 | 647 | return $this; 648 | } 649 | 650 | 651 | /** 652 | * Returns the criteria that must be applied for the next query. 653 | * 654 | * @return Collection> 655 | */ 656 | protected function getCriteriaToApply(): Collection 657 | { 658 | // get the standard criteria 659 | $criteriaToApply = $this->getCriteria(); 660 | 661 | // overrule them with criteria to be applied once 662 | if (! $this->onceCriteria->isEmpty()) { 663 | foreach ($this->onceCriteria as $onceKey => $onceCriteria) { 664 | // If there is no key, we can only add the criteria. 665 | if (is_numeric($onceKey)) { 666 | $criteriaToApply->push($onceCriteria); 667 | continue; 668 | } 669 | 670 | // If there is a key, override or remove; if Null, remove criterion. 671 | if ($onceCriteria instanceof NullCriteria) { 672 | $criteriaToApply->forget($onceKey); 673 | continue; 674 | } 675 | 676 | // Otherwise, overide the criteria. 677 | $criteriaToApply->put($onceKey, $onceCriteria); 678 | } 679 | } 680 | 681 | return $criteriaToApply; 682 | } 683 | 684 | /** 685 | * Checks whether the criteria that are currently pushed are the same as the ones that were previously applied. 686 | * 687 | * @return bool 688 | */ 689 | protected function areActiveCriteriaUnchanged(): bool 690 | { 691 | return ($this->onceCriteria->isEmpty() 692 | && $this->criteria == $this->activeCriteria 693 | ); 694 | } 695 | 696 | /** 697 | * Marks the active criteria, so we can later check what is currently active. 698 | */ 699 | protected function markAppliedCriteriaAsActive(): void 700 | { 701 | $this->activeCriteria = $this->getCriteriaToApply(); 702 | } 703 | 704 | /** 705 | * After applying, removes the criteria that should only have applied once 706 | */ 707 | protected function clearOnceCriteria(): void 708 | { 709 | if ($this->onceCriteria->isEmpty()) { 710 | return; 711 | } 712 | 713 | $this->onceCriteria = new Collection(); 714 | } 715 | 716 | protected function assertValidCustomCallback(mixed $result): void 717 | { 718 | if ( 719 | ! $result instanceof Model 720 | && ! $result instanceof EloquentBuilder 721 | && ! $result instanceof BaseBuilder 722 | ) { 723 | throw new InvalidArgumentException( 724 | 'Incorrect allCustom call in repository. ' 725 | . 'The callback must return a QueryBuilder/EloquentBuilder or Model object.' 726 | ); 727 | } 728 | } 729 | 730 | /** 731 | * Returns default per page count. 732 | * 733 | * @return int 734 | */ 735 | protected function getDefaultPerPage(): int 736 | { 737 | try { 738 | $perPage = $this->perPage ?: $this->makeModel(false)->getPerPage(); 739 | } catch (RepositoryException) { 740 | $perPage = 50; 741 | } 742 | 743 | return config('repository.perPage', $perPage); 744 | } 745 | } 746 | -------------------------------------------------------------------------------- /src/Contracts/BaseRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function model(): string; 27 | 28 | 29 | /** 30 | * Creates instance of model to start building query for 31 | * 32 | * @param bool $storeModel if true, this becomes a fresh $this->model property 33 | * @return TModel 34 | * @throws RepositoryException 35 | */ 36 | public function makeModel(bool $storeModel = true): Model; 37 | 38 | /** 39 | * Give unexecuted (fresh) query wioth the current applied criteria. 40 | * 41 | * @return EloquentBuilder|BaseBuilder 42 | * @throws RepositoryException 43 | */ 44 | public function query(): EloquentBuilder|BaseBuilder; 45 | 46 | public function count(): int; 47 | 48 | /** 49 | * @param string[] $columns 50 | * @return TModel|null 51 | */ 52 | public function first(array $columns = ['*']): ?Model; 53 | 54 | /** 55 | * @param string[] $columns 56 | * @return TModel|null 57 | * @throws ModelNotFoundException 58 | */ 59 | public function firstOrFail(array $columns = ['*']): ?Model; 60 | 61 | /** 62 | * @param string[] $columns 63 | * @return EloquentCollection 64 | */ 65 | public function all(array $columns = ['*']): EloquentCollection; 66 | 67 | /** 68 | * @param string $value 69 | * @param string|null $key 70 | * @return Collection 71 | * @throws RepositoryException 72 | */ 73 | public function pluck(string $value, ?string $key = null): Collection; 74 | 75 | /** 76 | * @param int|null $perPage 77 | * @param string[] $columns 78 | * @param string $pageName 79 | * @param int|null $page 80 | * @return LengthAwarePaginator&iterable 81 | */ 82 | public function paginate( 83 | ?int $perPage = null, 84 | array $columns = ['*'], 85 | string $pageName = 'page', 86 | ?int $page = null, 87 | ): LengthAwarePaginator; 88 | 89 | /** 90 | * @param int|string $id 91 | * @param string[] $columns 92 | * @param string|null $attribute 93 | * @return TModel|null 94 | */ 95 | public function find(int|string $id, array $columns = ['*'], ?string $attribute = null): ?Model; 96 | 97 | /** 98 | * @param int|string $id 99 | * @param string[] $columns 100 | * @return TModel 101 | * @throws ModelNotFoundException 102 | */ 103 | public function findOrFail(int|string $id, array $columns = ['*']): Model; 104 | 105 | /** 106 | * @param string $attribute 107 | * @param mixed $value 108 | * @param string[] $columns 109 | * @return TModel|null 110 | */ 111 | public function findBy(string $attribute, mixed $value, array $columns = ['*']): ?Model; 112 | 113 | /** 114 | * @param string $attribute 115 | * @param mixed $value 116 | * @param string[] $columns 117 | * @return EloquentCollection 118 | */ 119 | public function findAllBy(string $attribute, mixed $value, $columns = ['*']): EloquentCollection; 120 | 121 | /** 122 | * Find a collection of models by the given query conditions. 123 | * 124 | * @param array|mixed> $where 125 | * @param string[] $columns 126 | * @param bool $or 127 | * @return EloquentCollection 128 | */ 129 | public function findWhere(array $where, array $columns = ['*'], bool $or = false): EloquentCollection; 130 | 131 | /** 132 | * Makes a new model without persisting it. 133 | * 134 | * @param array $data 135 | * @return TModel 136 | * @throws MassAssignmentException|RepositoryException 137 | */ 138 | public function make(array $data): Model; 139 | 140 | /** 141 | * Creates a model and returns it 142 | * 143 | * @param array $data 144 | * @return TModel|null 145 | * @throws RepositoryException 146 | */ 147 | public function create(array $data): ?Model; 148 | 149 | /** 150 | * @param array $data 151 | * @param int|string $id 152 | * @param string|null $attribute 153 | * @return bool 154 | */ 155 | public function update(array $data, int|string $id, ?string $attribute = null): bool; 156 | 157 | /** 158 | * Finds and fills a model by id, without persisting changes. 159 | * 160 | * @param array $data 161 | * @param int|string $id 162 | * @param string|null $attribute 163 | * @return TModel|false 164 | * @throws MassAssignmentException|ModelNotFoundException 165 | */ 166 | public function fill(array $data, int|string $id, ?string $attribute = null): Model|false; 167 | 168 | /** 169 | * Deletes a model by id. 170 | * 171 | * @param int|string $id 172 | * @return int 173 | * @throws RepositoryException 174 | */ 175 | public function delete(int|string $id): int; 176 | 177 | /** 178 | * Applies callback to query for easier elaborate custom queries 179 | * on all() calls. 180 | * 181 | * @param Closure $callback must return query/builder compatible 182 | * @param string[] $columns 183 | * @return EloquentCollection 184 | * @throws RepositoryException 185 | */ 186 | public function allCallback(Closure $callback, array $columns = ['*']): EloquentCollection; 187 | 188 | /** 189 | * Applies callback to query for easier elaborate custom queries 190 | * on find (actually: ->first()) calls. 191 | * 192 | * @param Closure $callback must return query/builder compatible 193 | * @param string[] $columns 194 | * @return TModel|null 195 | * @throws RepositoryException 196 | */ 197 | public function findCallback(Closure $callback, array $columns = ['*']): ?Model; 198 | 199 | /** 200 | * Returns a collection with the default criteria for the repository. 201 | * These should be the criteria that apply for (almost) all calls 202 | * 203 | * Default set of criteria to apply to this repository 204 | * Note that this also needs all the parameters to send to the constructor 205 | * of each (and this CANNOT be solved by using the classname of as key, 206 | * since the same Criteria may be applied more than once). 207 | * 208 | * Override with your own defaults (check ExtendedRepository's refreshed, 209 | * named Criteria for examples). 210 | * 211 | * @return Collection> 212 | */ 213 | public function defaultCriteria(): Collection; 214 | 215 | /** 216 | * Builds the default criteria and replaces the criteria stack to apply with 217 | * the default collection. 218 | */ 219 | public function restoreDefaultCriteria(): void; 220 | 221 | public function clearCriteria(): void; 222 | 223 | /** 224 | * Sets or unsets ignoreCriteria flag. If it is set, all criteria (even 225 | * those set to apply once!) will be ignored. 226 | * 227 | * @param bool $ignore 228 | */ 229 | public function ignoreCriteria(bool $ignore = true): void; 230 | 231 | /** 232 | * Returns a cloned set of all currently set criteria (not including 233 | * those to be applied once). 234 | * 235 | * @return Collection> 236 | */ 237 | public function getCriteria(): Collection; 238 | 239 | /** 240 | * Returns a cloned set of all currently set once criteria. 241 | * 242 | * @return Collection> 243 | */ 244 | public function getOnceCriteria(): Collection; 245 | 246 | /** 247 | * Returns a cloned set of all currently set criteria (not including 248 | * those to be applied once). 249 | * 250 | * @return Collection> 251 | */ 252 | public function getAllCriteria(): Collection; 253 | 254 | /** 255 | * Applies Criteria to the model for the upcoming query 256 | * 257 | * This takes the default/standard Criteria, then overrides 258 | * them with whatever is found in the onceCriteria list 259 | * 260 | * @throws RepositoryException 261 | */ 262 | public function applyCriteria(): void; 263 | 264 | /** 265 | * Pushes Criteria, optionally by identifying key. 266 | * 267 | * If a criteria already exists for the key, it is overridden 268 | * Note that this does NOT overrule any onceCriteria, even if set by key! 269 | * 270 | * @param CriteriaInterface $criteria 271 | * @param string|null $key Unique identifier, may be used to remove and overwrite criteria 272 | */ 273 | public function pushCriteria(CriteriaInterface $criteria, ?string $key = null): void; 274 | 275 | /** 276 | * @param string $key 277 | */ 278 | public function removeCriteria(string $key): void; 279 | 280 | /** 281 | * Pushes Criteria, but only for the next call, resets to default afterwards. 282 | * 283 | * Note that this does NOT work for specific criteria exclusively, it resets 284 | * to default for ALL Criteria. 285 | * 286 | * @param CriteriaInterface $criteria 287 | * @param string|null $key 288 | * @return $this 289 | */ 290 | public function pushCriteriaOnce(CriteriaInterface $criteria, ?string $key = null): static; 291 | 292 | /** 293 | * Removes Criteria, but only for the next call, resets to default afterwards. 294 | * 295 | * Note that this does NOT work for specific criteria exclusively, it resets 296 | * to default for ALL Criteria. 297 | * 298 | * In effect, this adds a NullCriteria to onceCriteria by key, disabling any criteria 299 | * by that key in the normal criteria list. 300 | * 301 | * @param string $key 302 | * @return $this 303 | */ 304 | public function removeCriteriaOnce(string $key): static; 305 | } 306 | -------------------------------------------------------------------------------- /src/Contracts/CriteriaInterface.php: -------------------------------------------------------------------------------- 1 | |DatabaseBuilder|EloquentBuilder $model 18 | * @param BaseRepositoryInterface $repository 19 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 20 | */ 21 | public function apply( 22 | Model|Relation|DatabaseBuilder|EloquentBuilder $model, 23 | BaseRepositoryInterface $repository, 24 | ): Model|Relation|DatabaseBuilder|EloquentBuilder; 25 | } 26 | -------------------------------------------------------------------------------- /src/Contracts/ExtendedRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | $parameters 20 | */ 21 | public function addScope(string $scope, array $parameters = []): void; 22 | 23 | public function removeScope(string $scope): void; 24 | 25 | public function clearScopes(): void; 26 | 27 | /** 28 | * Enables maintenance mode, ignoring standard limitations on model availability 29 | * 30 | * @param bool $enable 31 | * @return $this 32 | */ 33 | public function maintenance(bool $enable = true): static; 34 | 35 | /** 36 | * Prepares repository to include inactive entries 37 | * (entries with the $this->activeColumn set to false) 38 | * 39 | * @param bool $enable 40 | */ 41 | public function includeInactive(bool $enable = true): void; 42 | 43 | /** 44 | * Prepares repository to exclude inactive entries. 45 | */ 46 | public function excludeInactive(): void; 47 | 48 | /** 49 | * Enables using the cache for retrieval. 50 | * 51 | * @param bool $enable 52 | */ 53 | public function enableCache(bool $enable = true): void; 54 | 55 | /** 56 | * Disables using the cache for retrieval. 57 | */ 58 | public function disableCache(): void; 59 | 60 | /** 61 | * Returns whether inactive records are included. 62 | * 63 | * @return bool 64 | */ 65 | public function isInactiveIncluded(): bool; 66 | 67 | /** 68 | * Returns whether cache is currently active. 69 | * 70 | * @return bool 71 | */ 72 | public function isCacheEnabled(): bool; 73 | } 74 | -------------------------------------------------------------------------------- /src/Contracts/FindsModelsByTranslationInterface.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function findAllByTranslation( 39 | string $attribute, 40 | string $value, 41 | string $locale = null, 42 | bool $exact = true, 43 | ): EloquentCollection; 44 | } 45 | -------------------------------------------------------------------------------- /src/Contracts/HandlesEloquentRelationManipulationInterface.php: -------------------------------------------------------------------------------- 1 | $ids list of id's to connect to 16 | * @param bool $detaching 17 | */ 18 | public function sync(Model $model, string $relation, array $ids, bool $detaching = true): void; 19 | 20 | /** 21 | * @param TModel $model 22 | * @param string $relation name of the relation (method name) 23 | * @param int|string $id 24 | * @param array $attributes 25 | * @param bool $touch 26 | */ 27 | public function attach( 28 | Model $model, 29 | string $relation, 30 | int|string $id, 31 | array $attributes = [], 32 | bool $touch = true 33 | ): void; 34 | 35 | /** 36 | * @param TModel $model 37 | * @param string $relation name of the relation (method name) 38 | * @param array $ids 39 | * @param bool $touch 40 | */ 41 | public function detach(Model $model, string $relation, array $ids = [], bool $touch = true): void; 42 | 43 | /** 44 | * @param TModel $model 45 | * @param string $relation name of the relation (method name) 46 | * @param TModel|int|string $with 47 | */ 48 | public function associate(Model $model, string $relation, Model|int|string $with): void; 49 | 50 | /** 51 | * Excecutes a dissociate on the model model provided. 52 | * 53 | * @param TModel $model 54 | * @param string $relation name of the relation (method name) 55 | */ 56 | public function dissociate(Model $model, string $relation): void; 57 | } 58 | -------------------------------------------------------------------------------- /src/Contracts/HandlesEloquentSavingInterface.php: -------------------------------------------------------------------------------- 1 | $options 19 | * @return bool 20 | */ 21 | public function save(Model $model, array $options = []): bool; 22 | } 23 | -------------------------------------------------------------------------------- /src/Contracts/HandlesListifyModelsInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | abstract class AbstractCriteria implements CriteriaInterface 21 | { 22 | /** 23 | * @var BaseRepositoryInterface 24 | */ 25 | protected BaseRepositoryInterface $repository; 26 | 27 | /** 28 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 29 | * @param BaseRepositoryInterface $repository 30 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 31 | */ 32 | public function apply( 33 | Model|Relation|DatabaseBuilder|EloquentBuilder $model, 34 | BaseRepositoryInterface $repository, 35 | ): Model|Relation|EloquentBuilder|DatabaseBuilder { 36 | $this->repository = $repository; 37 | 38 | return $this->applyToQuery($model); 39 | } 40 | 41 | /** 42 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 43 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 44 | */ 45 | abstract protected function applyToQuery( 46 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 47 | ): Model|Relation|DatabaseBuilder|EloquentBuilder; 48 | } 49 | -------------------------------------------------------------------------------- /src/Criteria/Common/Custom.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class Custom extends AbstractCriteria 21 | { 22 | public function __construct(protected Closure $query) 23 | { 24 | } 25 | 26 | /** 27 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 28 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 29 | */ 30 | protected function applyToQuery( 31 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 32 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 33 | $callable = $this->query; 34 | 35 | return $callable($model); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Criteria/Common/FieldIsValue.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class FieldIsValue extends AbstractCriteria 20 | { 21 | public function __construct( 22 | protected string $field, 23 | protected mixed $value = true, 24 | ) { 25 | } 26 | 27 | /** 28 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 29 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 30 | */ 31 | protected function applyToQuery( 32 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 33 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 34 | return $model->where($this->field, $this->value); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Criteria/Common/Has.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class Has extends AbstractCriteria 21 | { 22 | public function __construct( 23 | protected string $relation, 24 | protected string $operator = '>=', 25 | protected int $count = 1, 26 | protected string $boolean = 'and', 27 | protected ?Closure $callback = null, 28 | ) { 29 | } 30 | 31 | /** 32 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 33 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 34 | */ 35 | protected function applyToQuery( 36 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 37 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 38 | return $model->has($this->relation, $this->operator, $this->count, $this->boolean, $this->callback); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Criteria/Common/IsActive.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class IsActive extends AbstractCriteria 20 | { 21 | public function __construct(protected string $column = 'active') 22 | { 23 | } 24 | 25 | /** 26 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 27 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 28 | */ 29 | protected function applyToQuery( 30 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 31 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 32 | return $model->where($this->column, true); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Criteria/Common/OrderBy.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class OrderBy extends AbstractCriteria 20 | { 21 | private const DEFAULT_DIRECTION = 'asc'; 22 | 23 | /** 24 | * @var array column => direction 25 | */ 26 | protected array $orderClauses = []; 27 | 28 | /** 29 | * @param string|string[] $columnOrArray may be either a single column, in which the second parameter 30 | * is used for direction, or an array of 'column' => 'direction' values 31 | * @param string $direction 'asc'/'desc' 32 | */ 33 | public function __construct( 34 | string|array $columnOrArray, 35 | string $direction = self::DEFAULT_DIRECTION, 36 | ) { 37 | $this->orderClauses = $this->normalizeOrderClauses($columnOrArray, $direction); 38 | } 39 | 40 | /** 41 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 42 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 43 | */ 44 | protected function applyToQuery( 45 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 46 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 47 | foreach ($this->orderClauses as $column => $direction) { 48 | $model = $model->orderBy($column, $direction); 49 | } 50 | 51 | return $model; 52 | } 53 | 54 | /** 55 | * @param string|string[] $columnOrArray 56 | * @param string $direction 57 | * @return array 58 | */ 59 | protected function normalizeOrderClauses(string|array $columnOrArray, string $direction): array 60 | { 61 | if (is_string($columnOrArray)) { 62 | return [ 63 | $columnOrArray => $direction, 64 | ]; 65 | } 66 | 67 | $newColumns = []; 68 | 69 | foreach ($columnOrArray as $column => $direction) { 70 | if (is_numeric($column)) { 71 | $column = $direction; 72 | $direction = self::DEFAULT_DIRECTION; 73 | } 74 | 75 | $newColumns[$column] = $direction; 76 | } 77 | 78 | return $newColumns; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Criteria/Common/Scope.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class Scope extends AbstractCriteria 22 | { 23 | /** 24 | * @param string $scope 25 | * @param array $parameters 26 | */ 27 | public function __construct( 28 | protected string $scope, 29 | protected array $parameters = [], 30 | ) { 31 | } 32 | 33 | /** 34 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 35 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 36 | */ 37 | protected function applyToQuery( 38 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 39 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 40 | return call_user_func_array( 41 | [$model, $this->scope], 42 | $this->parameters 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Criteria/Common/Scopes.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class Scopes extends AbstractCriteria 24 | { 25 | /** 26 | * Has the following format: 27 | * [ 28 | * [ scope, parameters[] ] 29 | * ] 30 | * 31 | * @var array 32 | */ 33 | protected array $scopes; 34 | 35 | 36 | /** 37 | * Scopes may be passed as a set of scopesets [ [ scope, parameters ], ... ] 38 | * may also be formatted as key-value pairs [ scope => parameters, ... ] 39 | * or as a list of scope names (no parameters) [ scope, scope, ... ] 40 | * 41 | * @param string[]|array|array> $scopes 42 | */ 43 | public function __construct(array $scopes) 44 | { 45 | foreach ($scopes as $scopeName => &$scopeSet) { 46 | // Normalize each scopeset to: [ name, [ parameters ] ]. 47 | 48 | // If a key is given, $scopeSet = parameters (and must be made an array). 49 | if (! is_numeric($scopeName)) { 50 | if (! is_array($scopeSet)) { 51 | $scopeSet = [$scopeSet]; 52 | } 53 | 54 | $scopeSet = [$scopeName, $scopeSet]; 55 | } else { 56 | // $scopeName is not set, so the $scopeSet must contain at least the scope name. 57 | // Allow strings to be passed, assuming no parameters. 58 | if (! is_array($scopeSet)) { 59 | $scopeSet = [$scopeSet, []]; 60 | } 61 | } 62 | 63 | // Problems if the first param is not a string. 64 | if (! is_string(Arr::get($scopeSet, '0'))) { 65 | throw new InvalidArgumentException('First parameter of scopeset must be a string (the scope name)!'); 66 | } 67 | 68 | // Make sure second parameter is an array. 69 | if (empty($scopeSet[1])) { 70 | $scopeSet[1] = []; 71 | } elseif (! is_array($scopeSet[1])) { 72 | $scopeSet[1] = [$scopeSet[1]]; 73 | } 74 | } 75 | 76 | unset($scopeSet); 77 | 78 | $this->scopes = $scopes; 79 | } 80 | 81 | /** 82 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 83 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 84 | */ 85 | protected function applyToQuery( 86 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 87 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 88 | foreach ($this->scopes as $scopeSet) { 89 | $model = call_user_func_array([$model, $scopeSet[0]], $scopeSet[1]); 90 | } 91 | 92 | return $model; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Criteria/Common/Take.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class Take extends AbstractCriteria 20 | { 21 | public function __construct(protected int $quantity) 22 | { 23 | } 24 | 25 | /** 26 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 27 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 28 | */ 29 | protected function applyToQuery( 30 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 31 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 32 | return $model->take($this->quantity); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Criteria/Common/UseCache.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class UseCache extends AbstractCriteria 23 | { 24 | protected const CACHE_DEFAULT_TTL = 15 * 60; 25 | protected const CONFIG_TTL_KEY = 'cache.ttl'; 26 | 27 | /** 28 | * @var int|null in seconds 29 | */ 30 | protected ?int $timeToLive; 31 | 32 | /** 33 | * @param null|int $timeToLive in seconds 34 | */ 35 | public function __construct(?int $timeToLive = null) 36 | { 37 | if (empty($timeToLive)) { 38 | $timeToLive = config(static::CONFIG_TTL_KEY) ?: static::CACHE_DEFAULT_TTL; 39 | } 40 | 41 | $this->timeToLive = $timeToLive; 42 | } 43 | 44 | /** 45 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 46 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 47 | */ 48 | protected function applyToQuery( 49 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 50 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 51 | /** @var RememberableBuilder $model */ 52 | return $model->remember($this->timeToLive); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Criteria/Common/WhereHas.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class WhereHas extends AbstractCriteria 21 | { 22 | public function __construct( 23 | protected string $relation, 24 | protected Closure $callback, 25 | protected string $operator = '>=', 26 | protected int $count = 1, 27 | ) { 28 | } 29 | 30 | /** 31 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 32 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 33 | */ 34 | protected function applyToQuery( 35 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 36 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 37 | return $model->whereHas($this->relation, $this->callback, $this->operator, $this->count); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Criteria/Common/WithRelations.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class WithRelations extends AbstractCriteria 20 | { 21 | /** 22 | * @param array $withStatements 23 | */ 24 | public function __construct(protected array $withStatements) 25 | { 26 | } 27 | 28 | /** 29 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 30 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 31 | */ 32 | protected function applyToQuery( 33 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 34 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 35 | return $model->with($this->withStatements); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Criteria/NullCriteria.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class NullCriteria extends AbstractCriteria 23 | { 24 | /** 25 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 26 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 27 | */ 28 | protected function applyToQuery( 29 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 30 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 31 | return $model; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Criteria/Translatable/WhereHasTranslation.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class WhereHasTranslation extends AbstractCriteria 20 | { 21 | protected string $locale; 22 | protected string $attribute; 23 | protected string $value; 24 | protected bool $exact; 25 | protected string $operator; 26 | 27 | /** 28 | * @param string $attribute 29 | * @param string $value 30 | * @param string|null $locale 31 | * @param bool $exact if false, looks up as 'like' (adds %) 32 | */ 33 | public function __construct( 34 | string $attribute, 35 | string $value, 36 | string $locale = null, 37 | bool $exact = true, 38 | ) { 39 | $locale ?: app()->getLocale(); 40 | 41 | if (! $exact && ! preg_match('#^%(.+)%$#', $value)) { 42 | $value = '%' . $value . '%'; 43 | } 44 | 45 | $this->locale = $locale; 46 | $this->attribute = $attribute; 47 | $this->value = $value; 48 | $this->operator = $exact ? '=' : 'LIKE'; 49 | } 50 | 51 | /** 52 | * @param TModel|Relation|DatabaseBuilder|EloquentBuilder $model 53 | * @return TModel|Relation|DatabaseBuilder|EloquentBuilder 54 | */ 55 | protected function applyToQuery( 56 | Model|Relation|DatabaseBuilder|EloquentBuilder $model 57 | ): Model|Relation|DatabaseBuilder|EloquentBuilder { 58 | return $model->whereHas( 59 | 'translations', 60 | fn (EloquentBuilder|Relation $query) => $query 61 | ->where($this->attribute, $this->operator, $this->value) 62 | ->where('locale', $this->locale) 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Enums/CriteriaKey.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class CriteriaKey extends Enum 21 | { 22 | public const ACTIVE = 'active'; // whether to check for 'active' = 1 23 | public const CACHE = 'cache'; // for rememberable() 24 | public const ORDER = 'order'; // for order by (multiple in one optionally) 25 | public const SCOPE = 'scope'; // for scopes applied (multiple in one optionally) 26 | public const WITH = 'with'; // for eager loading 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/RepositoryException.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | abstract class ExtendedRepository extends BaseRepository implements ExtendedRepositoryInterface 23 | { 24 | /** 25 | * Override if model has a basic 'active' field. 26 | * 27 | * @var bool 28 | */ 29 | protected bool $hasActive = false; 30 | 31 | /** 32 | * The column to check for if hasActive is true 33 | * 34 | * @var string 35 | */ 36 | protected string $activeColumn = 'active'; 37 | 38 | /** 39 | * Setting: enables (remember) cache 40 | * 41 | * @var bool 42 | */ 43 | protected bool $enableCache = false; 44 | 45 | /** 46 | * Setting: disables the active=1 check (if hasActive is true for repo) 47 | * 48 | * @var bool 49 | */ 50 | protected bool $includeInactive = false; 51 | 52 | /** 53 | * Scopes to apply to queries. 54 | * Must be supported by model used! 55 | * 56 | * @var string[] 57 | */ 58 | protected array $scopes = []; 59 | 60 | /** 61 | * Parameters for a given scope. 62 | * Note that you can only use each scope once, since parameters will be set by scope name as key. 63 | * 64 | * @var array 65 | */ 66 | protected array $scopeParameters = []; 67 | 68 | 69 | /** 70 | * {@inheritDoc} 71 | */ 72 | public function __construct(ContainerInterface $container, Collection $initialCriteria) 73 | { 74 | parent::__construct($container, $initialCriteria); 75 | 76 | $this->refreshSettingDependentCriteria(); 77 | } 78 | 79 | 80 | // ------------------------------------------------------------------------- 81 | // Criteria 82 | // ------------------------------------------------------------------------- 83 | 84 | /** 85 | * Builds the default criteria and replaces the criteria stack to apply with 86 | * the default collection. 87 | * 88 | * Override to also refresh the default criteria for extended functionality. 89 | */ 90 | public function restoreDefaultCriteria(): void 91 | { 92 | parent::restoreDefaultCriteria(); 93 | 94 | $this->refreshSettingDependentCriteria(); 95 | } 96 | 97 | /** 98 | * Refreshes named criteria, so that they reflect the current repository settings 99 | * (for instance for updating the Active check, when includeActive has changed) 100 | * This also makes sure the named criteria exist at all, if they are required and were never added. 101 | */ 102 | public function refreshSettingDependentCriteria(): void 103 | { 104 | if ($this->hasActive) { 105 | if (! $this->includeInactive) { 106 | $this->criteria->put(CriteriaKey::ACTIVE, $this->getActiveCriteriaInstance()); 107 | } else { 108 | $this->criteria->forget(CriteriaKey::ACTIVE); 109 | } 110 | } 111 | 112 | if ($this->enableCache) { 113 | $this->criteria->put(CriteriaKey::CACHE, $this->getCacheCriteriaInstance()); 114 | } else { 115 | $this->criteria->forget(CriteriaKey::CACHE); 116 | } 117 | 118 | if (! empty($this->scopes)) { 119 | $this->criteria->put(CriteriaKey::SCOPE, $this->getScopesCriteriaInstance()); 120 | } else { 121 | $this->criteria->forget(CriteriaKey::SCOPE); 122 | } 123 | } 124 | 125 | 126 | // ------------------------------------------------------------------------- 127 | // Scopes 128 | // ------------------------------------------------------------------------- 129 | 130 | /** 131 | * Adds a scope to enforce, overwrites with new parameters if it already exists. 132 | * 133 | * @param string $scope 134 | * @param array $parameters 135 | */ 136 | public function addScope(string $scope, array $parameters = []): void 137 | { 138 | if (! in_array($scope, $this->scopes)) { 139 | $this->scopes[] = $scope; 140 | } 141 | 142 | $this->scopeParameters[ $scope ] = $parameters; 143 | 144 | $this->refreshSettingDependentCriteria(); 145 | } 146 | 147 | public function removeScope(string $scope): void 148 | { 149 | $this->scopes = array_diff($this->scopes, [$scope]); 150 | 151 | unset($this->scopeParameters[ $scope ]); 152 | 153 | $this->refreshSettingDependentCriteria(); 154 | } 155 | 156 | public function clearScopes(): void 157 | { 158 | $this->scopes = []; 159 | $this->scopeParameters = []; 160 | 161 | $this->refreshSettingDependentCriteria(); 162 | } 163 | 164 | // ------------------------------------------------------------------------- 165 | // Maintenance mode / settings 166 | // ------------------------------------------------------------------------- 167 | 168 | /** 169 | * Enables maintenance mode, ignoring standard limitations on model availability 170 | * and disables caching (if it was enabled). 171 | * 172 | * @param bool $enable 173 | * @return $this 174 | */ 175 | public function maintenance(bool $enable = true): static 176 | { 177 | $this->includeInactive($enable); 178 | $this->enableCache(! $enable); 179 | 180 | return $this; 181 | } 182 | 183 | public function includeInactive(bool $enable = true): void 184 | { 185 | $this->includeInactive = $enable; 186 | 187 | $this->refreshSettingDependentCriteria(); 188 | } 189 | 190 | public function excludeInactive(): void 191 | { 192 | $this->includeInactive(false); 193 | } 194 | 195 | /** 196 | * Returns whether inactive records are included. 197 | * 198 | * @return bool 199 | */ 200 | public function isInactiveIncluded(): bool 201 | { 202 | return $this->includeInactive; 203 | } 204 | 205 | public function enableCache(bool $enable = true): void 206 | { 207 | $this->enableCache = $enable; 208 | 209 | $this->refreshSettingDependentCriteria(); 210 | } 211 | 212 | public function disableCache(): void 213 | { 214 | $this->enableCache(false); 215 | } 216 | 217 | public function isCacheEnabled(): bool 218 | { 219 | return $this->enableCache; 220 | } 221 | 222 | public function activateRecord(int|string $id, bool $active = true): bool 223 | { 224 | if (! $this->hasActive) { 225 | return false; 226 | } 227 | 228 | $model = $this->find($id); 229 | 230 | if (! $model) { 231 | return false; 232 | } 233 | 234 | $model->{$this->activeColumn} = $active; 235 | 236 | return $model->save(); 237 | } 238 | 239 | 240 | /** 241 | * Converts the tracked scopes to an array that the Scopes Common Criteria will eat. 242 | * 243 | * @return array 244 | */ 245 | protected function convertScopesToCriteriaArray(): array 246 | { 247 | $scopes = []; 248 | 249 | foreach ($this->scopes as $scope) { 250 | if (array_key_exists($scope, $this->scopeParameters) && ! empty($this->scopeParameters[ $scope ])) { 251 | $scopes[] = [$scope, $this->scopeParameters[ $scope ]]; 252 | continue; 253 | } 254 | 255 | $scopes[] = [$scope, []]; 256 | } 257 | 258 | return $scopes; 259 | } 260 | 261 | /** 262 | * Returns Criteria to use for is-active check. 263 | * 264 | * @return CriteriaInterface 265 | */ 266 | protected function getActiveCriteriaInstance(): CriteriaInterface 267 | { 268 | /** @var IsActive $criterion */ 269 | $criterion = new IsActive($this->activeColumn); 270 | return $criterion; 271 | } 272 | 273 | /** 274 | * Returns Criteria to use for caching. Override to replace with something other 275 | * than Rememberable (which is used by the default Common\UseCache Criteria); 276 | * 277 | * @return CriteriaInterface 278 | */ 279 | protected function getCacheCriteriaInstance(): CriteriaInterface 280 | { 281 | /** @var UseCache $criterion */ 282 | $criterion = new UseCache(); 283 | return $criterion; 284 | } 285 | 286 | /** 287 | * Returns Criteria to use for applying scopes. Override to replace with something 288 | * other the default Common\Scopes Criteria. 289 | * 290 | * @return CriteriaInterface 291 | */ 292 | protected function getScopesCriteriaInstance(): CriteriaInterface 293 | { 294 | /** @var Scopes $criterion */ 295 | $criterion = new Scopes( 296 | $this->convertScopesToCriteriaArray() 297 | ); 298 | return $criterion; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/RepositoryServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes( 14 | [ 15 | dirname(__DIR__) . '/config/repository.php' => config_path('repository.php'), 16 | ], 17 | 'repository' 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Traits/FindsModelsByTranslationTrait.php: -------------------------------------------------------------------------------- 1 | pushCriteriaOnce( 35 | new WhereHasTranslation($attribute, $value, $locale, $exact) 36 | ); 37 | 38 | return $this->first(); 39 | } 40 | 41 | /** 42 | * Finds models by a given translated property. 43 | * 44 | * @param string $attribute must be translated property! 45 | * @param string $value 46 | * @param string|null $locale 47 | * @param bool $exact = or LIKE match 48 | * @return EloquentCollection 49 | */ 50 | public function findAllByTranslation( 51 | string $attribute, 52 | string $value, 53 | string $locale = null, 54 | bool $exact = true, 55 | ): EloquentCollection { 56 | $this->pushCriteriaOnce( 57 | new WhereHasTranslation($attribute, $value, $locale, $exact) 58 | ); 59 | 60 | return $this->all(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Traits/HandlesEloquentRelationManipulationTrait.php: -------------------------------------------------------------------------------- 1 | $ids list of id's to connect to 25 | * @param bool $detaching 26 | */ 27 | public function sync(Model $model, string $relation, array $ids, bool $detaching = true): void 28 | { 29 | $model->{$relation}()->sync($ids, $detaching); 30 | } 31 | 32 | /** 33 | * @param TModel $model 34 | * @param string $relation name of the relation (method name) 35 | * @param int|string $id 36 | * @param array $attributes 37 | * @param bool $touch 38 | */ 39 | public function attach( 40 | Model $model, 41 | string $relation, 42 | int|string $id, 43 | array $attributes = [], 44 | bool $touch = true, 45 | ): void { 46 | $model->{$relation}()->attach($id, $attributes, $touch); 47 | } 48 | 49 | /** 50 | * @param TModel $model 51 | * @param string $relation name of the relation (method name) 52 | * @param array $ids 53 | * @param bool $touch 54 | */ 55 | public function detach(Model $model, string $relation, array $ids = [], bool $touch = true): void 56 | { 57 | $model->{$relation}()->detach($ids, $touch); 58 | } 59 | 60 | /** 61 | * @param TModel $model 62 | * @param string $relation name of the relation (method name) 63 | * @param TModel|int|string $with 64 | */ 65 | public function associate(Model $model, string $relation, Model|int|string $with): void 66 | { 67 | $model->{$relation}()->associate($with); 68 | } 69 | 70 | /** 71 | * Excecutes a dissociate on the model model provided. 72 | * 73 | * @param TModel $model 74 | * @param string $relation name of the relation (method name) 75 | */ 76 | public function dissociate(Model $model, string $relation): void 77 | { 78 | $model->{$relation}()->dissociate(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Traits/HandlesEloquentSavingTrait.php: -------------------------------------------------------------------------------- 1 | $options 24 | * @return bool 25 | */ 26 | public function save(Model $model, array $options = []): bool 27 | { 28 | return $model->save($options); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Traits/HandlesListifyModelsTrait.php: -------------------------------------------------------------------------------- 1 | makeModel(false); 29 | 30 | $model = $model->find($id); 31 | 32 | if (! $model) { 33 | return false; 34 | } 35 | 36 | $this->assertModelHasListify($model); 37 | 38 | /** @var ListifyInterface $model */ 39 | $model->setListPosition($newPosition); 40 | 41 | return $model; 42 | } 43 | 44 | protected function assertModelHasListify(Model $model): void 45 | { 46 | if (! method_exists($model, 'setListPosition')) { 47 | throw new InvalidArgumentException('Method can only be used on Models with the Listify trait'); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/BaseRepositoryTest.php: -------------------------------------------------------------------------------- 1 | repository = $this->app->make(Helpers\TestBaseRepository::class); 27 | } 28 | 29 | protected function seedDatabase(): void 30 | { 31 | TestSimpleModel::create([ 32 | 'unique_field' => '999', 33 | 'second_field' => null, 34 | 'name' => 'unchanged', 35 | 'active' => true, 36 | ]); 37 | 38 | TestSimpleModel::create([ 39 | 'unique_field' => '1234567', 40 | 'second_field' => '434', 41 | 'name' => 'random name', 42 | 'active' => false, 43 | ]); 44 | 45 | TestSimpleModel::create([ 46 | 'unique_field' => '1337', 47 | 'second_field' => '12345', 48 | 'name' => 'special name', 49 | 'active' => true, 50 | ]); 51 | } 52 | 53 | 54 | // -------------------------------------------- 55 | // Retrieval 56 | // -------------------------------------------- 57 | 58 | /** 59 | * @test 60 | */ 61 | public function it_handles_basic_retrieval_operations(): void 62 | { 63 | // all 64 | $result = $this->repository->all(); 65 | static::assertInstanceOf(Collection::class, $result, 'Did not get Collection for all()'); 66 | static::assertCount(3, $result, 'Did not get correct count for all()'); 67 | 68 | // get an id that we can use find on 69 | $someId = $result->first()->id; 70 | static::assertNotEmpty($someId, "Did not get a valid Model's id from the all() result"); 71 | 72 | // find 73 | static::assertInstanceOf(Model::class, $this->repository->find($someId), 'Did not get Model for find()'); 74 | 75 | // count 76 | static::assertEquals(3, $this->repository->count(), 'Did not get correct result for count()'); 77 | 78 | // first 79 | static::assertInstanceOf(Model::class, $this->repository->first(), 'Did not get Model for first() on all'); 80 | 81 | // findBy 82 | static::assertInstanceOf( 83 | Model::class, 84 | $this->repository->findBy(self::UNIQUE_FIELD, '1337'), 85 | 'Did not get Model for findBy() for unique field value' 86 | ); 87 | 88 | // findAllBy 89 | static::assertCount( 90 | 2, 91 | $this->repository->findAllBy('active', true), 92 | 'Did not get correct count for result for findAllBy(active = true)' 93 | ); 94 | 95 | // paginate 96 | static::assertCount(2, $this->repository->paginate(2), 'Did not get correct count for paginate()'); 97 | 98 | // pluck 99 | $list = $this->repository->pluck(self::UNIQUE_FIELD); 100 | static::assertCount(3, $list, 'Did not get correct array count for lists()'); 101 | static::assertContains('1337', $list, 'Did not get correct array content for lists()'); 102 | } 103 | 104 | /** 105 | * @test 106 | */ 107 | public function it_creates_a_new_instance_and_fills_attributes_with_data(): void 108 | { 109 | $attributes = [ 110 | self::UNIQUE_FIELD => 'unique_field_value', 111 | self::SECOND_FIELD => 'second_field_value', 112 | ]; 113 | 114 | $model = $this->repository->make($attributes); 115 | 116 | // Asserting that only the desired attributes got filled and are the same. 117 | static::assertEquals($attributes, $model->getDirty()); 118 | 119 | // Asserting the the model had its attributes filled without being persisted. 120 | static::assertEquals(0, $this->repository->findWhere($attributes)->count()); 121 | } 122 | 123 | /** 124 | * @test 125 | */ 126 | public function it_throws_an_exception_when_findorfail_does_not_find_anything(): void 127 | { 128 | $this->expectException(ModelNotFoundException::class); 129 | 130 | $this->repository->findOrFail(895476); 131 | } 132 | 133 | /** 134 | * @test 135 | */ 136 | public function it_throws_an_exception_when_firstorfail_does_not_find_anything(): void 137 | { 138 | $this->expectException(ModelNotFoundException::class); 139 | 140 | // Make sure we won't find anything. 141 | $mockCriteria = $this->makeMockCriteria( 142 | 'once', 143 | fn ($query) => $query->where('name', 'some name that certainly does not exist') 144 | ); 145 | $this->repository->pushCriteria($mockCriteria); 146 | 147 | $this->repository->firstOrFail(); 148 | } 149 | 150 | /** 151 | * Bosnadev's findWhere() method. 152 | * 153 | * @test 154 | */ 155 | public function it_can_perform_a_findwhere_with_custom_parameters(): void 156 | { 157 | // Simple field/value combo's by key 158 | static::assertCount( 159 | 1, 160 | $this->repository->findWhere([ 161 | self::UNIQUE_FIELD => '1234567', 162 | self::SECOND_FIELD => '434', 163 | ]), 164 | 'findWhere() with field/value combo failed (incorrect match count)' 165 | ); 166 | 167 | // Arrays with field/value sets 168 | static::assertCount( 169 | 1, 170 | $this->repository->findWhere([ 171 | [self::UNIQUE_FIELD, '1234567'], 172 | [self::SECOND_FIELD, '434'], 173 | ]), 174 | 'findWhere() with field/value sets failed (incorrect match count)' 175 | ); 176 | 177 | // Arrays with field/operator/value sets 178 | static::assertCount( 179 | 1, 180 | $this->repository->findWhere([ 181 | [self::UNIQUE_FIELD, 'LIKE', '%234567'], 182 | [self::SECOND_FIELD, 'LIKE', '43%'], 183 | ]), 184 | 'findWhere() with field/operator/value sets failed (incorrect match count)' 185 | ); 186 | 187 | // Closure send directly to the model's where() method 188 | static::assertCount( 189 | 1, 190 | $this->repository->findWhere([ 191 | function ($query) { 192 | return $query->where(self::UNIQUE_FIELD, 'LIKE', '%234567'); 193 | }, 194 | ]), 195 | 'findWhere() with Closure callback failed (incorrect match count)' 196 | ); 197 | } 198 | 199 | /** 200 | * @test 201 | */ 202 | public function it_can_perform_find_and_all_lookups_with_a_callback_for_custom_queries(): void 203 | { 204 | // allCallback 205 | $result = $this->repository->allCallback(function ($query) { 206 | return $query->where(self::UNIQUE_FIELD, '1337'); 207 | }); 208 | static::assertCount(1, $result, 'Wrong count for allCallback()'); 209 | 210 | 211 | // findCallback 212 | $result = $this->repository->findCallback(function ($query) { 213 | return $query->where(self::UNIQUE_FIELD, '1337'); 214 | }); 215 | static::assertEquals('1337', $result->{self::UNIQUE_FIELD}, 'Wrong result for findCallback()'); 216 | } 217 | 218 | /** 219 | * @test 220 | */ 221 | public function it_throw_an_exception_if_the_callback_for_custom_queries_is_incorrect(): void 222 | { 223 | $this->expectException(\InvalidArgumentException::class); 224 | 225 | $this->repository->allCallback(function () { 226 | return 'incorrect return value'; 227 | }); 228 | } 229 | 230 | 231 | // -------------------------------------------- 232 | // Manipulation 233 | // -------------------------------------------- 234 | 235 | /** 236 | * @test 237 | * @depends it_handles_basic_retrieval_operations 238 | */ 239 | public function it_handles_basic_manipulation_operations(): void 240 | { 241 | // Update existing 242 | $someId = $this->repository->findBy(self::UNIQUE_FIELD, '999')->id; 243 | static::assertNotEmpty($someId, "Did not get a valid Model's id from the findBy(unique_field) result"); 244 | $this->repository->update(['name' => 'changed it!'], $someId); 245 | static::assertEquals( 246 | 'changed it!', 247 | $this->repository->findBy(self::UNIQUE_FIELD, '999')->name, 248 | 'Change did not apply after update()' 249 | ); 250 | 251 | // Create new 252 | $model = $this->repository->create([ 253 | self::UNIQUE_FIELD => '313', 254 | 'name' => 'New Model', 255 | ]); 256 | static::assertInstanceOf(Model::class, $model, 'Create() response is not a Model'); 257 | static::assertNotEmpty($model->id, 'Model does not have an id (likely story)'); 258 | static::assertDatabaseHas(static::TABLE_NAME, ['id' => $model->id, self::UNIQUE_FIELD => '313', 259 | 'name' => 'New Model', 260 | ]); 261 | static::assertEquals(4, $this->repository->count(), 'Total count after creating new does not match'); 262 | 263 | // Delete 264 | static::assertEquals(1, $this->repository->delete($model->id), 'Delete() call did not return succesful count'); 265 | static::assertEquals(3, $this->repository->count(), 'Total count after deleting does not match'); 266 | static::assertDatabaseMissing(static::TABLE_NAME, ['id' => $model->id]); 267 | unset($model); 268 | } 269 | 270 | /** 271 | * @test 272 | */ 273 | public function it_fills_a_retrieved_model_attributes_without_persisting_it(): void 274 | { 275 | $persistedModel = $this->repository->all()->first(); 276 | 277 | $attributes = [ 278 | self::UNIQUE_FIELD => 'unique_field_value', 279 | self::SECOND_FIELD => 'second_field_value', 280 | ]; 281 | 282 | $filledModel = $this->repository->fill($attributes, $persistedModel->id); 283 | 284 | static::assertEquals($filledModel->getDirty(), $attributes); 285 | static::assertDatabaseMissing(static::TABLE_NAME, $attributes); 286 | } 287 | 288 | 289 | // -------------------------------------------- 290 | // Criteria 291 | // -------------------------------------------- 292 | 293 | /** 294 | * @test 295 | */ 296 | public function it_returns_and_can_restore_default_criteria(): void 297 | { 298 | static::assertTrue($this->repository->defaultCriteria()->isEmpty(), 'Defaultcriteria is not empty'); 299 | 300 | $this->repository->pushCriteria($this->makeMockCriteria('never')); 301 | static::assertCount( 302 | 1, 303 | $this->repository->getCriteria(), 304 | 'getCriteria() count incorrect after pushing new Criteria' 305 | ); 306 | 307 | $this->repository->restoreDefaultCriteria(); 308 | static::assertTrue( 309 | $this->repository->getCriteria()->isEmpty(), 310 | 'getCriteria() not empty after restoring default Criteria()' 311 | ); 312 | } 313 | 314 | /** 315 | * @test 316 | * @depends it_handles_basic_retrieval_operations 317 | */ 318 | public function it_takes_criteria_and_handles_basic_criteria_manipulation(): void 319 | { 320 | // Clear all criteria, see if none are applied. 321 | $this->repository->clearCriteria(); 322 | static::assertTrue( 323 | $this->repository->getCriteria()->isEmpty(), 324 | 'getCriteria() not empty after clearCriteria()' 325 | ); 326 | static::assertMatchesRegularExpression( 327 | "#^select \* from [`\"]" . static::TABLE_NAME . '[`\"]$#i', 328 | $this->repository->query()->toSql(), 329 | 'Query SQL should be totally basic after clearCriteria()' 330 | ); 331 | 332 | 333 | // Add new criteria, see if it is applied. 334 | $criteria = $this->makeMockCriteria('twice', fn ($query) => $query->where(self::UNIQUE_FIELD, '1337')); 335 | $this->repository->pushCriteria($criteria, 'TemporaryCriteria'); 336 | static::assertCount( 337 | 1, 338 | $this->repository->getCriteria(), 339 | 'getCriteria() count incorrect after pushing new Criteria' 340 | ); 341 | 342 | static::assertMatchesRegularExpression( 343 | '#where [`"]' . self::UNIQUE_FIELD . '[`"] =#i', 344 | $this->repository->query()->toSql(), 345 | 'Query SQL should be altered by pushing Criteria' 346 | ); 347 | 348 | // Set repository to ignore criteria, see if they do not get applied. 349 | $this->repository->ignoreCriteria(); 350 | 351 | static::assertDoesNotMatchRegularExpression( 352 | '#where [`\"]' . self::UNIQUE_FIELD . '[`\"] =#i', 353 | $this->repository->query()->toSql(), 354 | 'Query SQL should be altered by pushing Criteria' 355 | ); 356 | 357 | $this->repository->ignoreCriteria(false); 358 | 359 | 360 | // Remove criteria once, see if it is not applied. 361 | $this->repository->removeCriteriaOnce('TemporaryCriteria'); 362 | static::assertCount( 363 | 1, 364 | $this->repository->getCriteria(), 365 | 'getCriteria() should still have a count of one if only removing temporarily' 366 | ); 367 | static::assertMatchesRegularExpression( 368 | "#^select \* from [`\"]" . static::TABLE_NAME . '[`\"]$#i', 369 | $this->repository->query()->toSql(), 370 | 'Query SQL should be totally basic while removing Criteria once' 371 | ); 372 | static::assertMatchesRegularExpression( 373 | '#where [`\"]' . self::UNIQUE_FIELD . '[`\"] =#i', 374 | $this->repository->query()->toSql(), 375 | 'Query SQL should be altered again on next call after removing Criteria once' 376 | ); 377 | 378 | 379 | // override criteria once, see if it is overridden succesfully and not called 380 | $secondCriteria = $this->makeMockCriteria('once', fn ($query) => $query->where(self::SECOND_FIELD, '12345')); 381 | $this->repository->pushCriteriaOnce($secondCriteria, 'TemporaryCriteria'); 382 | $sql = $this->repository->query()->toSql(); 383 | static::assertDoesNotMatchRegularExpression( 384 | '#where [`\"]' . self::UNIQUE_FIELD . '[`\"] =#i', 385 | $sql, 386 | 'Query SQL should not be built using first TemporaryCriteria' 387 | ); 388 | static::assertMatchesRegularExpression( 389 | '#where [`\"]' . self::SECOND_FIELD . '[`\"] =#i', 390 | $sql, 391 | 'Query SQL should be built using the overriding Criteria' 392 | ); 393 | 394 | 395 | // remove specific criteria, see if it is not applied 396 | $this->repository->removeCriteria('TemporaryCriteria'); 397 | static::assertTrue( 398 | $this->repository->getCriteria()->isEmpty(), 399 | 'getCriteria() not empty after removing Criteria' 400 | ); 401 | static::assertMatchesRegularExpression( 402 | "#^select \* from [`\"]" . static::TABLE_NAME . '[`\"]$#i', 403 | $this->repository->query()->toSql(), 404 | 'Query SQL should be totally basic after removing Criteria' 405 | ); 406 | 407 | 408 | // override criteria once, see if it is changed 409 | $criteria = $this->makeMockCriteria('once', fn ($query) => $query->where(self::UNIQUE_FIELD, '1337')); 410 | $this->repository->pushCriteriaOnce($criteria); 411 | static::assertTrue( 412 | $this->repository->getCriteria()->isEmpty(), 413 | 'getCriteria() not empty with only once Criteria pushed' 414 | ); 415 | static::assertMatchesRegularExpression( 416 | '#where [`\"]' . self::UNIQUE_FIELD . '[`\"] =#i', 417 | $this->repository->query()->toSql(), 418 | 'Query SQL should be altered by pushing Criteria once' 419 | ); 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /tests/CommonCriteriaTest.php: -------------------------------------------------------------------------------- 1 | repository = $this->app->make(Helpers\TestExtendedRepository::class); 34 | 35 | $this->repository->maintenance(); 36 | } 37 | 38 | protected function seedDatabase(): void 39 | { 40 | TestExtendedModel::create([ 41 | 'unique_field' => '999', 42 | 'second_field' => null, 43 | 'name' => 'unchanged', 44 | 'active' => true, 45 | ]); 46 | 47 | TestExtendedModel::create([ 48 | 'unique_field' => '1234567', 49 | 'second_field' => '434', 50 | 'name' => 'random name', 51 | 'active' => false, 52 | ]); 53 | 54 | $testModel = TestExtendedModel::create([ 55 | 'unique_field' => '1337', 56 | 'second_field' => '12345', 57 | 'name' => 'special name', 58 | 'active' => true, 59 | ]); 60 | 61 | // Set some translations. 62 | $testModel->translateOrNew('nl')->translated_string = 'vertaalde_attribuutwaarde hoepla'; 63 | $testModel->translateOrNew('en')->translated_string = 'translated_attribute_value hoopla'; 64 | $testModel->save(); 65 | } 66 | 67 | 68 | /** 69 | * @test 70 | */ 71 | public function field_is_value_criteria_works(): void 72 | { 73 | $this->repository->pushCriteria(new FieldIsValue('name', 'special name')); 74 | 75 | static::assertCount(1, $this->repository->all(), "FieldIsValue Criteria doesn't work"); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function has_criteria_works(): void 82 | { 83 | $this->repository->pushCriteria(new Has('translations', '>', 1)); 84 | 85 | static::assertCount(1, $this->repository->all(), 'Has Criteria simple use fails'); 86 | 87 | $this->repository->pushCriteria( 88 | new Has( 89 | 'translations', 90 | '=', 91 | 1, 92 | 'and', 93 | fn ($query) => $query->where('translated_string', 'vertaalde_attribuutwaarde hoepla') 94 | ) 95 | ); 96 | 97 | static::assertCount(1, $this->repository->all(), 'Has Criteria use with callback fails'); 98 | } 99 | 100 | /** 101 | * @test 102 | */ 103 | public function is_active_criteria_works(): void 104 | { 105 | $this->repository->pushCriteria(new IsActive('active')); 106 | 107 | static::assertCount(2, $this->repository->all(), "IsActive Criteria doesn't work"); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | public function order_by_criteria_works(): void 114 | { 115 | $this->repository->pushCriteria(new OrderBy('position', 'desc')); 116 | 117 | static::assertEquals([3, 2, 1], $this->repository->pluck('position')->all(), "OrderBy Criteria doesn't work"); 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function scope_criteria_works(): void 124 | { 125 | $this->repository->pushCriteria(new Scope('testing'), CriteriaKey::SCOPE); 126 | 127 | static::assertCount(2, $this->repository->all(), "Scope Criteria without parameters doesn't work"); 128 | 129 | $this->repository->pushCriteria(new Scope('moreTesting', [self::SECOND_FIELD, '434']), CriteriaKey::SCOPE); 130 | 131 | static::assertCount(1, $this->repository->all(), "Scope Criteria with parameter doesn't work"); 132 | } 133 | 134 | /** 135 | * @test 136 | */ 137 | public function scopes_criteria_works(): void 138 | { 139 | $this->repository->pushCriteria(new Scopes([ 140 | 'testing', 141 | 'moreTesting' => ['active', false], 142 | ]), CriteriaKey::SCOPE); 143 | 144 | static::assertCount( 145 | 1, 146 | $this->repository->all(), 147 | "Multiple Scopes Criteria doesn't work (value & key => value)" 148 | ); 149 | 150 | $this->repository->pushCriteria(new Scopes([ 151 | ['testing'], 152 | ['moreTesting', ['active', false]], 153 | ]), CriteriaKey::SCOPE); 154 | 155 | static::assertCount( 156 | 1, 157 | $this->repository->all(), 158 | "Multiple Scopes Criteria doesn't work (array sets, no keys)" 159 | ); 160 | } 161 | 162 | /** 163 | * @test 164 | */ 165 | public function where_has_criteria_works(): void 166 | { 167 | $this->repository->pushCriteria( 168 | new WhereHas( 169 | 'translations', 170 | fn ($query) => $query->where('translated_string', 'vertaalde_attribuutwaarde hoepla') 171 | ) 172 | ); 173 | 174 | $result = $this->repository->all(); 175 | static::assertCount(1, $result, "WhereHas Criteria doesn't work (wrong count)"); 176 | static::assertEquals( 177 | '1337', 178 | $result->first()->{self::UNIQUE_FIELD}, 179 | "WhereHas Criteria doesn't work (wrong model)" 180 | ); 181 | } 182 | 183 | /** 184 | * @test 185 | */ 186 | public function with_relations_criteria_works(): void 187 | { 188 | static::assertEmpty( 189 | $this->repository->findBy(self::UNIQUE_FIELD, '1337')->getRelations(), 190 | 'Model already includes translations relation without WithRelations Criteria' 191 | ); 192 | 193 | $this->repository->pushCriteria(new WithRelations(['translations'])); 194 | 195 | static::assertNotEmpty( 196 | $this->repository->findBy(self::UNIQUE_FIELD, '1337')->getRelations(), 197 | 'Model does not include translations relation with WithRelations Criteria' 198 | ); 199 | } 200 | 201 | /** 202 | * @test 203 | */ 204 | public function take_criteria_works(): void 205 | { 206 | $this->repository->pushCriteria(new Take(2)); 207 | static::assertCount(2, $this->repository->all(), "Take Criteria doesn't work"); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /tests/ExtendedRepositoryTest.php: -------------------------------------------------------------------------------- 1 | repository = $this->app->make(Helpers\TestExtendedRepository::class); 24 | } 25 | 26 | protected function seedDatabase(): void 27 | { 28 | TestExtendedModel::create([ 29 | 'unique_field' => '999', 30 | 'second_field' => null, 31 | 'name' => 'unchanged', 32 | 'active' => true, 33 | 'hidden' => 'invisible', 34 | ]); 35 | 36 | TestExtendedModel::create([ 37 | 'unique_field' => '1234567', 38 | 'second_field' => '434', 39 | 'name' => 'random name', 40 | 'active' => false, 41 | 'hidden' => 'cannot see me', 42 | ]); 43 | 44 | TestExtendedModel::create([ 45 | 'unique_field' => '1337', 46 | 'second_field' => '12345', 47 | 'name' => 'special name', 48 | 'active' => true, 49 | 'hidden' => 'where has it gone?', 50 | ]); 51 | } 52 | 53 | 54 | // -------------------------------------------- 55 | // Settings / caching / scopes 56 | // -------------------------------------------- 57 | 58 | /** 59 | * @test 60 | */ 61 | public function it_does_not_retrieve_inactive_files_and_uses_cache_by_default(): void 62 | { 63 | static::assertTrue($this->repository->isCacheEnabled(), 'Cache marked disabled'); 64 | static::assertFalse($this->repository->isInactiveIncluded(), 'Inactive marked as included'); 65 | 66 | // Test if without maintenance mode, only active records are returned. 67 | static::assertCount(2, $this->repository->all()); 68 | static::assertEquals(2, $this->repository->count(), 'count() value does not match all() count!'); 69 | 70 | // Set cache by looking up a record. 71 | $this->repository->findBy(self::UNIQUE_FIELD, '999'); 72 | 73 | // Change the record without busting the cache. 74 | $this->app['db'] 75 | ->table(static::TABLE_NAME) 76 | ->where(self::UNIQUE_FIELD, '999') 77 | ->update([ 'name' => 'changed!' ]); 78 | 79 | // If the change registered, the cache didn't work. 80 | $check = $this->repository->findBy(self::UNIQUE_FIELD, '999'); 81 | static::assertEquals('unchanged', $check->name, 'Cache did not apply, changes are seen instantly'); 82 | } 83 | 84 | /** 85 | * @test 86 | * @depends it_does_not_retrieve_inactive_files_and_uses_cache_by_default 87 | */ 88 | public function it_retrieves_inactive_files_and_does_not_cache_in_maintenance_mode(): void 89 | { 90 | $this->repository->maintenance(); 91 | 92 | static::assertFalse($this->repository->isCacheEnabled(), 'Cache not marked disabled'); 93 | static::assertTrue($this->repository->isInactiveIncluded(), 'Inactive not marked as included'); 94 | 95 | // Test if now inactive records are returned. 96 | static::assertCount(3, $this->repository->all(), 'Incorrect count for total in maintenance mode'); 97 | 98 | // Set cache by looking up a record. 99 | $this->repository->findBy(self::UNIQUE_FIELD, '999'); 100 | 101 | // Change the record without busting the cache. 102 | $this->app['db']->table(static::TABLE_NAME) 103 | ->where(self::UNIQUE_FIELD, '999') 104 | ->update([ 'name' => 'changed!' ]); 105 | 106 | // If the change registered, the cache didn't work. 107 | $check = $this->repository->findBy(self::UNIQUE_FIELD, '999'); 108 | static::assertEquals('changed!', $check->name, 'Result was still cached, could not see change'); 109 | } 110 | 111 | /** 112 | * @test 113 | */ 114 | public function it_can_apply_and_remove_scopes_and_uses_any_set_scopes_on_queries(): void 115 | { 116 | // Add a scope that will limit the result to 1 record. 117 | // The Supplier model has a test-scope especially for this. 118 | $this->repository->addScope('moreTesting', [self::UNIQUE_FIELD, '1337']); 119 | static::assertEquals(1, $this->repository->count(), 'Wrong result count after setting scope'); 120 | static::assertCount(1, $this->repository->all()); 121 | 122 | // Remove scope by name and check count. 123 | $this->repository->removeScope('moreTesting'); 124 | static::assertEquals(2, $this->repository->count(), 'Wrong result count after removing scope by name'); 125 | 126 | // Set single result scope again, see if it still works. 127 | $this->repository->addScope('moreTesting', [self::UNIQUE_FIELD, '1337']); 128 | static::assertEquals(1, $this->repository->count()); 129 | 130 | // Clear all scopes and check total again. 131 | $this->repository->clearScopes(); 132 | static::assertEquals(2, $this->repository->count(), 'Wrong result count after clearing all scopes'); 133 | } 134 | 135 | // -------------------------------------------- 136 | // Criteria for extended 137 | // -------------------------------------------- 138 | 139 | /** 140 | * @test 141 | */ 142 | public function it_uses_default_criteria_when_not_configured_not_to(): void 143 | { 144 | // By default, the defaultCriteria() should be loaded. 145 | static::assertTrue( 146 | $this->repository->defaultCriteria()->has('TestDefault'), 147 | 'Default Criteria should include TestDefault' 148 | ); 149 | 150 | static::assertTrue( 151 | $this->repository->getCriteria()->has('TestDefault'), 152 | 'Default Criteria should be in loaded getCriteria() list' 153 | ); 154 | } 155 | 156 | /** 157 | * @test 158 | * @depends it_uses_default_criteria_when_not_configured_not_to 159 | */ 160 | public function it_reapplies_criteria_only_when_changes_to_criteria_are_made(): void 161 | { 162 | // The idea is that a repository efficiently applies criteria, leaving a query state behind that 163 | // it can re-use without rebuilding it, unless it MUST be rebuilt. 164 | 165 | // It must be rebuilt when ... 166 | // ... the first call the the repository is made. 167 | // ... new criteria are pushed, criteria are removed or cleared. 168 | // ... when criteria are pushed or removed 'once'. 169 | // ... when settings have changed on the repository (cache, active, scopes). 170 | 171 | // Create spy Criteria to check how many times we apply the criteria. 172 | $mockCriteria = $this->makeMockCriteria(6); 173 | $this->repository->pushCriteria($mockCriteria); 174 | 175 | // First call, should apply +1. 176 | $this->repository->count(); 177 | 178 | // Call without changes, should not apply. 179 | $this->repository->count(); 180 | 181 | // Call after changing setting +1. 182 | $this->repository->disableCache(); 183 | $this->repository->count(); 184 | 185 | // Call after changing setting +1. 186 | $this->repository->clearScopes(); 187 | $this->repository->count(); 188 | 189 | // Call after pushing new criteria +1. 190 | $mockCriteriaTwo = $this->makeMockCriteria('twice'); 191 | $this->repository->pushCriteria($mockCriteriaTwo, 'MockTwo'); 192 | $this->repository->count(); 193 | 194 | // Call with once-criteria set +1 (and +1 for mock Two). 195 | $mockOnce = $this->makeMockCriteria('once'); 196 | $this->repository->pushCriteriaOnce($mockOnce); 197 | $this->repository->count(); 198 | 199 | // Call with criteria removed set +1, but the oncemock is not-re-applied, so that's still only called 1 time! 200 | $this->repository->removeCriteria('MockTwo'); 201 | $this->repository->count(); 202 | 203 | // Call with once-criteria removed if it does not exist should not make a difference. 204 | $this->repository->removeCriteriaOnce('KeyDoesNotExist'); 205 | $this->repository->count(); 206 | } 207 | 208 | 209 | // -------------------------------------------- 210 | // Manipulation 211 | // -------------------------------------------- 212 | 213 | /** 214 | * @test 215 | */ 216 | public function it_updates_the_active_state_of_a_record(): void 217 | { 218 | $this->repository->maintenance(); 219 | 220 | $modelId = $this->repository->findBy(self::UNIQUE_FIELD, '1337')->id; 221 | static::assertNotEmpty($modelId, 'Test Model not found'); 222 | 223 | // Set to inactive. 224 | $this->repository->activateRecord($modelId, false); 225 | static::assertFalse( 226 | $this->repository->findBy(self::UNIQUE_FIELD, '1337')->active, 227 | "Model deactivation didn't persist" 228 | ); 229 | 230 | // Set to active again. 231 | $this->repository->activateRecord($modelId); 232 | static::assertTrue( 233 | $this->repository->findBy(self::UNIQUE_FIELD, '1337')->active, 234 | "Model re-activation didn't persist" 235 | ); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /tests/ExtendedRepositoryTraitsTest.php: -------------------------------------------------------------------------------- 1 | repository = $this->app->make(Helpers\TestExtendedRepository::class); 25 | } 26 | 27 | protected function seedDatabase(): void 28 | { 29 | TestExtendedModel::create([ 30 | 'unique_field' => '999', 31 | 'second_field' => null, 32 | 'name' => 'unchanged', 33 | 'active' => true, 34 | 'hidden' => 'invisible', 35 | ]); 36 | 37 | TestExtendedModel::create([ 38 | 'unique_field' => '1234567', 39 | 'second_field' => '434', 40 | 'name' => 'random name', 41 | 'active' => false, 42 | 'hidden' => 'cannot see me', 43 | ]); 44 | 45 | $testModel = TestExtendedModel::create([ 46 | 'unique_field' => '1337', 47 | 'second_field' => '12345', 48 | 'name' => 'special name', 49 | 'active' => true, 50 | 'hidden' => 'where has it gone?', 51 | ]); 52 | 53 | // Set some translations. 54 | $testModel->translateOrNew('nl')->translated_string = 'vertaalde_attribuutwaarde hoepla'; 55 | $testModel->translateOrNew('en')->translated_string = 'translated_attribute_value hoopla'; 56 | $testModel->save(); 57 | } 58 | 59 | 60 | // -------------------------------------------- 61 | // Translatable 62 | // -------------------------------------------- 63 | 64 | /** 65 | * @test 66 | */ 67 | public function it_finds_records_by_translated_attribute_value(): void 68 | { 69 | // Finds by translation exact. 70 | static::assertInstanceOf( 71 | TestExtendedModel::class, 72 | $this->repository->findByTranslation(self::TRANSLATED_FIELD, 'vertaalde_attribuutwaarde hoepla', 'nl'), 73 | 'Did not find exact match for find' 74 | ); 75 | static::assertNotInstanceOf( 76 | TestExtendedModel::class, 77 | $this->repository->findByTranslation(self::TRANSLATED_FIELD, 'vertaalde_attribuutwaarde hoepla', 'en'), 78 | 'Should not have found match for different locale' 79 | ); 80 | 81 | // Finds by translation LIKE. 82 | static::assertInstanceOf( 83 | TestExtendedModel::class, 84 | $this->repository->findByTranslation(self::TRANSLATED_FIELD, '%attribuutwaarde hoe%', 'nl', false), 85 | 'Did not find loosy match for find' 86 | ); 87 | 88 | // Finds ALL by translation exact. 89 | static::assertCount( 90 | 1, 91 | $this->repository->findAllByTranslation(self::TRANSLATED_FIELD, 'vertaalde_attribuutwaarde hoepla', 'nl'), 92 | 'Incorrect count with exact match for all' 93 | ); 94 | 95 | // Finds ALL by translation LIKE. Also check if we don't get duplicates for multiple hits. 96 | static::assertCount( 97 | 1, 98 | $this->repository->findAllByTranslation(self::TRANSLATED_FIELD, '%vertaalde_attribuutwaarde%', 'nl', false), 99 | 'Incorrect count with loosy match for all' 100 | ); 101 | } 102 | 103 | 104 | // -------------------------------------------- 105 | // Compatibility with Listify 106 | // -------------------------------------------- 107 | 108 | /** 109 | * @test 110 | */ 111 | public function it_creates_new_records_with_position_handled_by_listify(): void 112 | { 113 | // The Supplier model must have Listify set for this. 114 | $this->repository->maintenance(); 115 | 116 | // Get the highest position value in the database. 117 | $highestPosition = $this->app['db']->table(static::TABLE_NAME)->max('position'); 118 | static::assertGreaterThan( 119 | 0, 120 | $highestPosition, 121 | 'Position value before testing not usable. Is Listify working/used?' 122 | ); 123 | 124 | $newModel = $this->repository->create([ 125 | 'unique_field' => 'NEWPOSITION', 126 | 'name' => 'TestNew', 127 | ]); 128 | 129 | static::assertEquals( 130 | $highestPosition + 1, 131 | $newModel->position, 132 | 'New position should be highest position before + 1' 133 | ); 134 | } 135 | 136 | /** 137 | * @test 138 | * @todo rewrite this so that it uses listify method instead 139 | * @todo and add other useful listify methods? 140 | */ 141 | public function it_updates_the_list_position_of_a_record(): void 142 | { 143 | $this->repository->maintenance(); 144 | 145 | // Check starting situation. 146 | $changeModel = $this->repository->findBy(self::UNIQUE_FIELD, '1337'); 147 | static::assertEquals( 148 | 1, 149 | $this->repository->findBy(self::UNIQUE_FIELD, '999')->position, 150 | 'Starting position for record (999) is incorrect' 151 | ); 152 | static::assertEquals( 153 | 3, 154 | $changeModel->position, 155 | 'Starting position for record (1337) is incorrect' 156 | ); 157 | 158 | // Update the position of the last added entry. 159 | $this->repository->updatePosition($changeModel->id, 1); 160 | 161 | // Check final positions after update. 162 | static::assertEquals( 163 | 2, 164 | $this->repository->findBy(self::UNIQUE_FIELD, '999')->position, 165 | 'Final position for record (999) is incorrect' 166 | ); 167 | static::assertEquals( 168 | 1, 169 | $this->repository->findBy(self::UNIQUE_FIELD, '1337')->position, 170 | 'Final position for moved record (1337) is incorrect' 171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /tests/Helpers/TestBaseRepository.php: -------------------------------------------------------------------------------- 1 | 43 | */ 44 | protected $casts = [ 45 | 'position' => 'integer', 46 | 'active' => 'boolean', 47 | ]; 48 | 49 | /** 50 | * @var string[] 51 | */ 52 | protected array $translatedAttributes = [ 53 | 'translated_string', 54 | ]; 55 | 56 | /** 57 | * @param self|EloquentBuilder|BaseBuilder $query 58 | * @return EloquentBuilder|BaseBuilder 59 | */ 60 | public function scopeTesting(self|EloquentBuilder|BaseBuilder $query): EloquentBuilder|BaseBuilder 61 | { 62 | return $query->whereNotNull('second_field'); 63 | } 64 | 65 | /** 66 | * @param self|EloquentBuilder|BaseBuilder $query 67 | * @param string $field 68 | * @param mixed $value 69 | * @return EloquentBuilder|BaseBuilder 70 | */ 71 | public function scopeMoreTesting( 72 | self|EloquentBuilder|BaseBuilder $query, 73 | string $field, 74 | mixed $value, 75 | ): EloquentBuilder|BaseBuilder { 76 | return $query->where($field, $value); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/Helpers/TestExtendedModelTranslation.php: -------------------------------------------------------------------------------- 1 | new NullCriteria(), 52 | ]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Helpers/TestSimpleModel.php: -------------------------------------------------------------------------------- 1 | |BaseBuilder $query 33 | * @return EloquentBuilder|BaseBuilder 34 | */ 35 | public function scopeTesting(TestSimpleModel|EloquentBuilder|BaseBuilder $query): EloquentBuilder|BaseBuilder 36 | { 37 | return $query->whereNotNull('second_field'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Helpers/TranslatableConfig.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getConfig(): array 13 | { 14 | return [ 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Application Locales 19 | |-------------------------------------------------------------------------- 20 | | 21 | | Contains an array with the applications available locales. 22 | | 23 | */ 24 | 'locales' => ['en', 'nl'], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Use fallback 29 | |-------------------------------------------------------------------------- 30 | | 31 | | Determine if fallback locales are returned by default or not. To add 32 | | more flexibility and configure this option per "translatable" 33 | | instance, this value will be overridden by the property 34 | | $useTranslationFallback when defined 35 | */ 36 | 'use_fallback' => true, 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Fallback Locale 41 | |-------------------------------------------------------------------------- 42 | | 43 | | A fallback locale is the locale being used to return a translation 44 | | when the requested translation is not existing. To disable it 45 | | set it to false. 46 | | 47 | */ 48 | 'fallback_locale' => 'nl', 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Translation Suffix 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Defines the default 'Translation' class suffix. For example, if 56 | | you want to use CountryTrans instead of CountryTranslation 57 | | application, set this to 'Trans'. 58 | | 59 | */ 60 | 'translation_suffix' => 'Translation', 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Locale key 65 | |-------------------------------------------------------------------------- 66 | | 67 | | Defines the 'locale' field name, which is used by the 68 | | translation model. 69 | | 70 | */ 71 | 'locale_key' => 'locale', 72 | 73 | /* 74 | |-------------------------------------------------------------------------- 75 | | Make translated attributes always fillable 76 | |-------------------------------------------------------------------------- 77 | | 78 | | If true, translatable automatically sets 79 | | translated attributes as fillable. 80 | | 81 | | WARNING! 82 | | Set this to true only if you understand the security risks. 83 | | 84 | */ 85 | 'always_fillable' => false, 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 35 | $app['config']->set('database.connections.testbench', [ 36 | 'driver' => 'sqlite', 37 | 'database' => ':memory:', 38 | 'prefix' => '', 39 | ]); 40 | 41 | // Set minutes for cache to live. 42 | $app['config']->set('cache.ttl', 60); 43 | 44 | $app['config']->set('translatable', (new TranslatableConfig())->getConfig()); 45 | } 46 | 47 | 48 | public function setUp(): void 49 | { 50 | parent::setUp(); 51 | 52 | $this->migrateDatabase(); 53 | $this->seedDatabase(); 54 | } 55 | 56 | protected function migrateDatabase(): void 57 | { 58 | // Model we can test anything but translations with. 59 | Schema::create(self::TABLE_NAME_SIMPLE, function (Blueprint $table): void { 60 | $table->increments('id'); 61 | $table->string('unique_field', 20); 62 | $table->integer('second_field')->unsigned()->nullable(); 63 | $table->string('name', 255)->nullable(); 64 | $table->integer('position')->nullable(); 65 | $table->boolean('active')->nullable()->default(false); 66 | $table->timestamps(); 67 | }); 68 | 69 | // Model we can also test translations with. 70 | Schema::create(self::TABLE_NAME_EXTENDED, function (Blueprint $table): void { 71 | $table->increments('id'); 72 | $table->string('unique_field', 20); 73 | $table->integer('second_field')->unsigned()->nullable(); 74 | $table->string('name', 255)->nullable(); 75 | $table->integer('position')->nullable(); 76 | $table->boolean('active')->nullable()->default(false); 77 | $table->string('hidden', 30)->nullable(); 78 | $table->timestamps(); 79 | }); 80 | 81 | Schema::create(self::TABLE_NAME_EXTENDED_TRANSLATIONS, function (Blueprint $table): void { 82 | $table->increments('id'); 83 | $table->integer('test_extended_model_id')->unsigned(); 84 | $table->string('locale', 12); 85 | $table->string('translated_string', 255); 86 | $table->timestamps(); 87 | }); 88 | } 89 | 90 | abstract protected function seedDatabase(): void; 91 | 92 | 93 | /** 94 | * Makes a mock Criteria object for simple custom Criteria testing. 95 | * 96 | * If no callback is given, it will simply return the model/query unaltered (and have no effect). 97 | * 98 | * @param int|string|null $expects 99 | * @param Closure|null $callback the callback for the apply() method on the Criteria 100 | * @return CriteriaInterface&MockInterface 101 | */ 102 | protected function makeMockCriteria( 103 | string|int|null $expects = null, 104 | Closure $callback = null, 105 | ): MockInterface { 106 | $mock = Mockery::mock(CriteriaInterface::class); 107 | 108 | if ($callback === null) { 109 | $callback = fn ($model) => $model; 110 | } 111 | 112 | if (! $expects) { 113 | $mock->shouldReceive('apply')->andReturnUsing($callback); 114 | return $mock; 115 | } 116 | 117 | if (is_integer($expects)) { 118 | $mock->shouldReceive('apply') 119 | ->times($expects) 120 | ->andReturnUsing($callback); 121 | } else { 122 | $mock->shouldReceive('apply') 123 | ->{$expects}() 124 | ->andReturnUsing($callback); 125 | } 126 | 127 | return $mock; 128 | } 129 | } 130 | --------------------------------------------------------------------------------