├── .gitignore
├── config
└── repository.php
├── src
├── Exceptions
│ └── RepositoryException.php
├── RepositoryServiceProvider.php
├── Contracts
│ ├── HandlesListifyModelsInterface.php
│ ├── HandlesEloquentSavingInterface.php
│ ├── CriteriaInterface.php
│ ├── FindsModelsByTranslationInterface.php
│ ├── HandlesEloquentRelationManipulationInterface.php
│ ├── ExtendedRepositoryInterface.php
│ └── BaseRepositoryInterface.php
├── Traits
│ ├── HandlesEloquentSavingTrait.php
│ ├── HandlesListifyModelsTrait.php
│ ├── FindsModelsByTranslationTrait.php
│ └── HandlesEloquentRelationManipulationTrait.php
├── Enums
│ └── CriteriaKey.php
├── Criteria
│ ├── Common
│ │ ├── Take.php
│ │ ├── IsActive.php
│ │ ├── Custom.php
│ │ ├── FieldIsValue.php
│ │ ├── WithRelations.php
│ │ ├── WhereHas.php
│ │ ├── Has.php
│ │ ├── Scope.php
│ │ ├── UseCache.php
│ │ ├── OrderBy.php
│ │ └── Scopes.php
│ ├── NullCriteria.php
│ ├── AbstractCriteria.php
│ └── Translatable
│ │ └── WhereHasTranslation.php
├── ExtendedRepository.php
└── BaseRepository.php
├── phpstan.neon.dist
├── .editorconfig
├── tests
├── Helpers
│ ├── TestBaseRepository.php
│ ├── TestExtendedModelTranslation.php
│ ├── TestSimpleModel.php
│ ├── TestExtendedRepository.php
│ ├── TestExtendedModel.php
│ └── TranslatableConfig.php
├── TestCase.php
├── ExtendedRepositoryTraitsTest.php
├── CommonCriteriaTest.php
├── ExtendedRepositoryTest.php
└── BaseRepositoryTest.php
├── CHANGELOG.md
├── CONTRIBUTING.md
├── phpunit.xml
├── LICENSE.md
├── .github
└── workflows
│ └── PHPUnit.yml
├── composer.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | composer.lock
3 | .phpunit.result.cache
4 |
--------------------------------------------------------------------------------
/config/repository.php:
--------------------------------------------------------------------------------
1 | 1,
6 | ];
7 |
--------------------------------------------------------------------------------
/src/Exceptions/RepositoryException.php:
--------------------------------------------------------------------------------
1 | publishes(
14 | [
15 | dirname(__DIR__) . '/config/repository.php' => config_path('repository.php'),
16 | ],
17 | 'repository'
18 | );
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Contracts/HandlesListifyModelsInterface.php:
--------------------------------------------------------------------------------
1 | $options
19 | * @return bool
20 | */
21 | public function save(Model $model, array $options = []): bool;
22 | }
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/Helpers/TestExtendedRepository.php:
--------------------------------------------------------------------------------
1 | new NullCriteria(),
52 | ]);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/Criteria/AbstractCriteria.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/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/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 |
--------------------------------------------------------------------------------
/tests/Helpers/TestExtendedModel.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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Repository
2 |
3 | [![Latest Version on Packagist][ico-version]][link-packagist]
4 | [![Software License][ico-license]](LICENSE.md)
5 | [](https://packagist.org/packages/czim/laravel-repository)
6 | [](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 |
--------------------------------------------------------------------------------
/src/ExtendedRepository.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------