├── .github └── workflows │ └── tests.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Contracts ├── Countable.php ├── Driver.php ├── EagerLoadableField.php ├── Fillable.php ├── FillableToMany.php ├── FillableToOne.php ├── Filter.php ├── IsReadOnly.php ├── Paginator.php ├── Parser.php ├── Proxy.php ├── Selectable.php ├── SortField.php └── Sortable.php ├── Drivers ├── SoftDeleteDriver.php └── StandardDriver.php ├── Fields ├── ArrayHash.php ├── ArrayList.php ├── Attribute.php ├── Boolean.php ├── Concerns │ ├── Countable.php │ ├── Hideable.php │ ├── IsReadOnly.php │ └── OnRelated.php ├── DateTime.php ├── ID.php ├── Map.php ├── Number.php ├── Relations │ ├── BelongsTo.php │ ├── BelongsToMany.php │ ├── HasMany.php │ ├── HasManyThrough.php │ ├── HasOne.php │ ├── HasOneThrough.php │ ├── MorphTo.php │ ├── MorphToMany.php │ ├── Polymorphic.php │ ├── Relation.php │ ├── ToMany.php │ └── ToOne.php ├── SoftDelete.php └── Str.php ├── Filters ├── Concerns │ ├── DeserializesToArray.php │ ├── DeserializesValue.php │ ├── HasColumn.php │ ├── HasColumns.php │ ├── HasDelimiter.php │ ├── HasOperator.php │ ├── HasRelation.php │ └── IsSingular.php ├── Has.php ├── OnlyTrashed.php ├── Scope.php ├── Where.php ├── WhereAll.php ├── WhereAny.php ├── WhereDoesntHave.php ├── WhereHas.php ├── WhereIdIn.php ├── WhereIdNotIn.php ├── WhereIn.php ├── WhereNotIn.php ├── WhereNotNull.php ├── WhereNull.php ├── WherePivot.php ├── WherePivotIn.php ├── WherePivotNotIn.php └── WithTrashed.php ├── HasQueryParameters.php ├── Hydrators ├── ModelHydrator.php ├── ToManyHydrator.php └── ToOneHydrator.php ├── Pagination ├── Cursor │ ├── Cursor.php │ ├── CursorBuilder.php │ ├── CursorPage.php │ ├── CursorPaginator.php │ └── CursorParser.php ├── CursorPagination.php ├── MultiPagination.php ├── PagePagination.php └── ProxyPage.php ├── Parsers ├── ProxyParser.php └── StandardParser.php ├── Polymorphism ├── MorphMany.php └── MorphValue.php ├── Proxy.php ├── ProxySchema.php ├── QueryAll.php ├── QueryBuilder ├── Aggregates │ └── CountableLoader.php ├── Applicators │ ├── FilterApplicator.php │ └── SortApplicator.php ├── EagerLoading │ ├── EagerLoadIterator.php │ ├── EagerLoadMorphs.php │ ├── EagerLoadPath.php │ ├── EagerLoadPathList.php │ └── EagerLoader.php ├── JsonApiBuilder.php └── ModelLoader.php ├── QueryMorphTo.php ├── QueryMorphToMany.php ├── QueryOne.php ├── QueryToMany.php ├── QueryToOne.php ├── Repository.php ├── Resources └── Relation.php ├── Schema.php ├── SoftDeletes.php └── Sorting ├── SortColumn.php ├── SortCountable.php └── SortWithCount.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | fail-fast: true 20 | matrix: 21 | php: [ 8.2, 8.3, 8.4 ] 22 | core: [ '4.3.2', '5.2' ] 23 | laravel: [ 11, 12 ] 24 | exclude: 25 | - laravel: 12 26 | core: '4.3.2' 27 | 28 | steps: 29 | - name: Checkout Code 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd 37 | tools: composer:v2 38 | coverage: none 39 | ini-values: error_reporting=E_ALL 40 | 41 | - name: Set Laravel JSON:API Core Version 42 | run: composer require "laravel-json-api/core:^${{ matrix.core }}" --no-update 43 | 44 | - name: Set Laravel Version 45 | run: composer require "illuminate/database:^${{ matrix.laravel }}" --no-update 46 | 47 | - name: Install dependencies 48 | uses: nick-fields/retry@v3 49 | with: 50 | timeout_minutes: 5 51 | max_attempts: 5 52 | command: composer update --prefer-dist --no-interaction --no-progress 53 | 54 | - name: Execute tests 55 | run: vendor/bin/phpunit 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Cloud Creativity Ltd 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-json-api/eloquent 2 | 3 | Eloquent support for [Laravel JSON:API](https://laraveljsonapi.io) packages. 4 | 5 | ## Installation 6 | 7 | Install using [Composer](https://getcomposer.org) 8 | 9 | ```bash 10 | composer require laravel-json-api/eloquent 11 | ``` 12 | 13 | ## License 14 | 15 | Laravel JSON:API is open-sourced software licensed under the [MIT License](./LICENSE). 16 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-json-api/eloquent", 3 | "description": "Serialize Eloquent models as JSON:API resources.", 4 | "keywords": [ 5 | "jsonapi.org", 6 | "json-api", 7 | "jsonapi", 8 | "laravel" 9 | ], 10 | "homepage": "https://github.com/laravel-json-api/eloquent", 11 | "support": { 12 | "issues": "https://github.com/laravel-json-api/eloquent/issues" 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Cloud Creativity Ltd", 18 | "email": "info@cloudcreativity.co.uk" 19 | }, 20 | { 21 | "name": "Christopher Gammie", 22 | "email": "contact@gammie.co.uk" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.2", 27 | "ext-json": "*", 28 | "illuminate/database": "^11.0|^12.0", 29 | "illuminate/support": "^11.0|^12.0", 30 | "laravel-json-api/core": "^4.3.2|^5.2" 31 | }, 32 | "require-dev": { 33 | "orchestra/testbench": "^9.0|^10.0", 34 | "phpunit/phpunit": "^10.5|^11.5.3" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "LaravelJsonApi\\Eloquent\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "App\\": "tests/app", 44 | "Database\\Factories\\": "tests/database/factories", 45 | "LaravelJsonApi\\Eloquent\\Tests\\": "tests/lib" 46 | } 47 | }, 48 | "extra": { 49 | "branch-alias": { 50 | "dev-develop": "4.x-dev" 51 | } 52 | }, 53 | "minimum-stability": "stable", 54 | "prefer-stable": true, 55 | "config": { 56 | "sort-packages": true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Contracts/Countable.php: -------------------------------------------------------------------------------- 1 | withTrashed(); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function persist(Model $model): bool 53 | { 54 | /** 55 | * If the model is being restored, the Laravel restore method executes a 56 | * save on the model. So we only need to run the restore method and all 57 | * dirty attributes will be saved. 58 | */ 59 | if ($this->willRestore($model)) { 60 | return $this->restore($model); 61 | } 62 | 63 | /** 64 | * To ensure Laravel still executes its soft-delete logic (e.g. firing events) 65 | * we need to delete before a save when we are soft-deleting. Although this 66 | * may result in two database calls in this scenario, it means we can guarantee 67 | * that standard Laravel soft-delete logic is executed. 68 | * 69 | * When executing the soft delete, Laravel will apply a fresh timestamp to the 70 | * model's deleted at column. As the JSON:API client may have provided a different 71 | * timestamp, we back up that value first, execute the soft delete, then reapply 72 | * the timestamp. 73 | * 74 | * @see https://github.com/cloudcreativity/laravel-json-api/issues/371 75 | */ 76 | if ($this->willSoftDelete($model)) { 77 | assert(method_exists($model, 'getDeletedAtColumn')); 78 | $column = $model->getDeletedAtColumn(); 79 | // save the original date so we can put it back later on. 80 | $deletedAt = $model->{$column}; 81 | // delete the record so that deleting and deleted events get fired. 82 | $response = $model->delete(); // capture the response 83 | 84 | // if a listener prevented the delete from happening, we need to throw as we are in an invalid state. 85 | // developers should prevent this scenario from happening either through authorization or validation. 86 | if ($response === false) { 87 | throw new RuntimeException(sprintf( 88 | 'Failed to soft delete model - %s:%s', 89 | $model::class, 90 | $model->getKey(), 91 | )); 92 | } 93 | 94 | // apply the original date back before saving, so that we keep date provided by the client. 95 | $model->{$column} = $deletedAt; 96 | } 97 | 98 | return (bool) $model->save(); 99 | } 100 | 101 | /** 102 | * @inheritDoc 103 | */ 104 | public function destroy(Model $model): bool 105 | { 106 | return (bool) $model->forceDelete(); 107 | } 108 | 109 | /** 110 | * @param Model|SoftDeletes $model 111 | * @return bool 112 | */ 113 | private function restore(Model $model): bool 114 | { 115 | return (bool) $model->restore(); 116 | } 117 | 118 | /** 119 | * Will the hydration operation restore the model? 120 | * 121 | * @param Model|SoftDeletes $model 122 | * @return bool 123 | */ 124 | private function willRestore(Model $model): bool 125 | { 126 | if (!$model->exists) { 127 | return false; 128 | } 129 | 130 | $column = $model->getDeletedAtColumn(); 131 | 132 | return null !== $model->getOriginal($column) && null === $model->{$column}; 133 | } 134 | 135 | /** 136 | * Will the hydration operation result in the model being soft deleted? 137 | * 138 | * @param Model|SoftDeletes $model 139 | * @return bool 140 | */ 141 | private function willSoftDelete(Model $model): bool 142 | { 143 | if (!$model->exists) { 144 | return false; 145 | } 146 | 147 | $column = $model->getDeletedAtColumn(); 148 | 149 | return null === $model->getOriginal($column) && null !== $model->{$column}; 150 | } 151 | 152 | } 153 | -------------------------------------------------------------------------------- /src/Drivers/StandardDriver.php: -------------------------------------------------------------------------------- 1 | model = $model; 34 | } 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | public function query(): Builder 40 | { 41 | return $this->model->newQuery(); 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function queryAll(): Builder 48 | { 49 | return $this->model->newQuery(); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public function newInstance(): Model 56 | { 57 | return $this->model->newInstance(); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function persist(Model $model): bool 64 | { 65 | return (bool) $model->save(); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function destroy(Model $model): bool 72 | { 73 | return (bool) $model->delete(); 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Fields/ArrayList.php: -------------------------------------------------------------------------------- 1 | sorted = true; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public function serialize(object $model) 54 | { 55 | $value = parent::serialize($model); 56 | 57 | if ($value && $this->sorted) { 58 | sort($value); 59 | } 60 | 61 | return $value ? array_values($value) : $value; 62 | } 63 | 64 | /** 65 | * @inheritDoc 66 | */ 67 | protected function deserialize($value) 68 | { 69 | $value = parent::deserialize($value); 70 | 71 | if ($value && $this->sorted) { 72 | sort($value); 73 | } 74 | 75 | return $value; 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | protected function assertValue($value): void 82 | { 83 | if ((!is_null($value) && !is_array($value)) || (!empty($value) && Arr::isAssoc($value))) { 84 | throw new \UnexpectedValueException(sprintf( 85 | 'Expecting the value of attribute %s to be an array list.', 86 | $this->name() 87 | )); 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Fields/Boolean.php: -------------------------------------------------------------------------------- 1 | name() 38 | )); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Fields/Concerns/Countable.php: -------------------------------------------------------------------------------- 1 | countable = $bool; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * @return $this 51 | */ 52 | public function canCount(): self 53 | { 54 | return $this->countable(true); 55 | } 56 | 57 | /** 58 | * @return $this 59 | */ 60 | public function cannotCount(): self 61 | { 62 | return $this->countable(false); 63 | } 64 | 65 | /** 66 | * Mark the relation as always having a "count" in the top-level meta of a relationship endpoint. 67 | * 68 | * @return $this 69 | */ 70 | public function alwaysCountInRelationship(): self 71 | { 72 | $this->countableInRelationship = true; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Mark the relation as never having a "count" in the top-level meta of a relationship endpoint. 79 | * 80 | * @return $this 81 | */ 82 | public function dontCountInRelationship(): self 83 | { 84 | $this->countableInRelationship = false; 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * Set an alias for the relationship count. 91 | * 92 | * @param string $name 93 | * @return $this 94 | */ 95 | public function countAs(string $name): self 96 | { 97 | if (empty($name)) { 98 | throw new InvalidArgumentException('Expecting a non-empty string.'); 99 | } 100 | 101 | $this->countAs = $name; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * @inheritDoc 108 | */ 109 | public function isCountable(): bool 110 | { 111 | return $this->countable; 112 | } 113 | 114 | /** 115 | * @inheritDoc 116 | */ 117 | public function isCountableInRelationship(): bool 118 | { 119 | if (!$this->isCountable()) { 120 | return false; 121 | } 122 | 123 | if (is_bool($this->countableInRelationship)) { 124 | return $this->countableInRelationship; 125 | } 126 | 127 | return true; 128 | } 129 | 130 | /** 131 | * @inheritDoc 132 | */ 133 | public function withCountName(): string 134 | { 135 | if ($this->countAs) { 136 | return "{$this->relationName()} as {$this->countAs}"; 137 | } 138 | 139 | return $this->relationName(); 140 | } 141 | 142 | /** 143 | * @inheritDoc 144 | */ 145 | public function keyForCount(): string 146 | { 147 | if ($this->countAs) { 148 | return $this->countAs; 149 | } 150 | 151 | return Str::snake($this->relationName()) . '_count'; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Fields/Concerns/Hideable.php: -------------------------------------------------------------------------------- 1 | hidden = $callback; 39 | 40 | return $this; 41 | } 42 | 43 | /** 44 | * @inheritDoc 45 | */ 46 | public function isHidden(?Request $request): bool 47 | { 48 | if (is_callable($this->hidden)) { 49 | return true === ($this->hidden)($request); 50 | } 51 | 52 | return true === $this->hidden; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function isNotHidden(?Request $request): bool 59 | { 60 | return !$this->isHidden($request); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Fields/Concerns/IsReadOnly.php: -------------------------------------------------------------------------------- 1 | readOnly = $callback; 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * Mark the field as read only when the resource is being created. 47 | * 48 | * @return $this 49 | */ 50 | public function readOnlyOnCreate(): self 51 | { 52 | $this->readOnly(static fn($request) => $request && $request->isMethod('POST')); 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Mark the field as read only when the resource is being updated. 59 | * 60 | * @return $this 61 | */ 62 | public function readOnlyOnUpdate(): self 63 | { 64 | $this->readOnly(static fn($request) => $request && $request->isMethod('PATCH')); 65 | 66 | return $this; 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function isReadOnly(?Request $request): bool 73 | { 74 | if ($this->readOnly instanceof Closure) { 75 | return true === ($this->readOnly)($request); 76 | } 77 | 78 | return true === $this->readOnly; 79 | } 80 | 81 | /** 82 | * @inheritDoc 83 | */ 84 | public function isNotReadOnly(?Request $request): bool 85 | { 86 | return !$this->isReadOnly($request); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /src/Fields/Concerns/OnRelated.php: -------------------------------------------------------------------------------- 1 | related; 34 | } 35 | 36 | /** 37 | * Set the attribute as existing on a related model. 38 | * 39 | * @param string $related 40 | * @return $this 41 | */ 42 | public function on(string $related): self 43 | { 44 | $this->related = $related; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Must the model exist in the database before the attribute is filled? 51 | * 52 | * @return bool 53 | */ 54 | public function mustExist(): bool 55 | { 56 | return !is_null($this->related); 57 | } 58 | 59 | /** 60 | * Get the model that the attribute exists on (the "owner" of the attribute). 61 | * 62 | * @param Model $model 63 | * @return Model 64 | */ 65 | protected function owner(Model $model): Model 66 | { 67 | if ($this->related && $related = $model->{$this->related}) { 68 | return $related; 69 | } 70 | 71 | if ($this->related) { 72 | throw new LogicException(sprintf( 73 | 'Expecting relationship %s on %s to use `withDefault()` to ensure there is always a related model.', 74 | $this->related, 75 | get_class($model), 76 | )); 77 | } 78 | 79 | return $model; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Fields/DateTime.php: -------------------------------------------------------------------------------- 1 | tz = $tz; 54 | $this->useTz = true; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Retain the timezone provided in the JSON value. 61 | * 62 | * @return $this 63 | */ 64 | public function retainTimezone(): self 65 | { 66 | $this->useTz = false; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Get the server-side timezone. 73 | * 74 | * @return string 75 | */ 76 | public function timezone(): string 77 | { 78 | if ($this->tz) { 79 | return $this->tz; 80 | } 81 | 82 | return $this->tz = config('app.timezone'); 83 | } 84 | 85 | /** 86 | * @inheritDoc 87 | */ 88 | protected function deserialize($value) 89 | { 90 | $value = parent::deserialize($value); 91 | 92 | return $this->parse($value); 93 | } 94 | 95 | /** 96 | * Parse a date time value. 97 | * 98 | * @param CarbonInterface|string|null $value 99 | * @return CarbonInterface|null 100 | */ 101 | protected function parse($value): ?CarbonInterface 102 | { 103 | if (is_null($value)) { 104 | return null; 105 | } 106 | 107 | $value = is_string($value) ? Date::parse($value) : Date::instance($value); 108 | 109 | if (true === $this->useTz) { 110 | return $value->setTimezone($this->timezone()); 111 | } 112 | 113 | return $value; 114 | } 115 | 116 | /** 117 | * @inheritDoc 118 | */ 119 | protected function assertValue($value): void 120 | { 121 | if (!is_null($value) && (!is_string($value) || empty($value))) { 122 | throw new \UnexpectedValueException(sprintf( 123 | 'Expecting the value of attribute %s to be a string (datetime).', 124 | $this->name() 125 | )); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Fields/ID.php: -------------------------------------------------------------------------------- 1 | column = $column ?: null; 52 | $this->sortable(); 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function name(): string 59 | { 60 | return 'id'; 61 | } 62 | 63 | /** 64 | * @return string|null 65 | */ 66 | public function column(): ?string 67 | { 68 | return $this->column; 69 | } 70 | 71 | /** 72 | * @inheritDoc 73 | */ 74 | public function key(): ?string 75 | { 76 | return $this->column(); 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | public function isSparseField(): bool 83 | { 84 | return false; 85 | } 86 | 87 | /** 88 | * @inheritDoc 89 | */ 90 | public function isReadOnly($request): bool 91 | { 92 | if ($this->acceptsClientIds()) { 93 | return !$request->isMethod('POST'); 94 | } 95 | 96 | return true; 97 | } 98 | 99 | /** 100 | * @inheritDoc 101 | */ 102 | public function isNotReadOnly($request): bool 103 | { 104 | return !$this->isReadOnly($request); 105 | } 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public function mustExist(): bool 111 | { 112 | return false; 113 | } 114 | 115 | /** 116 | * @inheritDoc 117 | */ 118 | public function fill(Model $model, $value, array $validatedData): void 119 | { 120 | $column = $this->column() ?: $model->getRouteKeyName(); 121 | 122 | $model->{$column} = $value; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Fields/Number.php: -------------------------------------------------------------------------------- 1 | acceptStrings = true; 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * @inheritDoc 47 | */ 48 | protected function assertValue($value): void 49 | { 50 | if (!$this->isNumeric($value)) { 51 | $expected = $this->acceptStrings ? 52 | 'an integer, float or numeric string.' : 53 | 'an integer or float.'; 54 | 55 | throw new UnexpectedValueException(sprintf( 56 | 'Expecting the value of attribute %s to be ' . $expected, 57 | $this->name(), 58 | )); 59 | } 60 | } 61 | 62 | /** 63 | * Is the value a numeric value that this field accepts? 64 | * 65 | * @param mixed $value 66 | * @return bool 67 | */ 68 | private function isNumeric($value): bool 69 | { 70 | if (is_null($value) || is_int($value) || is_float($value)) { 71 | return true; 72 | } 73 | 74 | if ($this->acceptStrings && is_string($value) && is_numeric($value)) { 75 | return true; 76 | } 77 | 78 | return false; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Fields/Relations/BelongsTo.php: -------------------------------------------------------------------------------- 1 | mustValidate(); 44 | } 45 | 46 | /** 47 | * @inheritDoc 48 | */ 49 | public function mustExist(): bool 50 | { 51 | return false; 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public function fill(Model $model, ?array $identifier): void 58 | { 59 | $name = $this->relationName(); 60 | 61 | assert(method_exists($model, $name) || $model->relationResolver($model::class, $name), sprintf( 62 | 'Expecting method %s to exist on model %s.', 63 | $name, 64 | $model::class, 65 | )); 66 | 67 | $relation = $model->{$name}(); 68 | 69 | if ($related = $this->find($identifier)) { 70 | assert(method_exists($relation, 'associate'), sprintf( 71 | 'Expecting relation class %s to have an "associate" method.', 72 | $relation::class, 73 | )); 74 | $relation->associate($related); 75 | return; 76 | } 77 | 78 | assert(method_exists($relation, 'disassociate'), sprintf( 79 | 'Expecting relation class %s to have a "disassociate" method.', 80 | $relation::class, 81 | )); 82 | 83 | $relation->disassociate(); 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | */ 89 | public function associate(Model $model, ?array $identifier): ?Model 90 | { 91 | $this->fill($model, $identifier); 92 | $model->save(); 93 | 94 | return $model->getRelation($this->relationName()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Fields/Relations/HasManyThrough.php: -------------------------------------------------------------------------------- 1 | count($types)) { 39 | throw new InvalidArgumentException('Expecting at least two resource types.'); 40 | } 41 | 42 | $this->types = $types; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public function inverseTypes(): array 51 | { 52 | if (empty($this->types)) { 53 | throw new LogicException(sprintf( 54 | 'No inverse resource types have been set on morph-to relation %s.', 55 | $this->name() 56 | )); 57 | } 58 | 59 | return $this->types; 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function parse(?Model $model): ?object 66 | { 67 | if ($model) { 68 | return $this->schemaFor($model)->parser()->parseOne( 69 | $model 70 | ); 71 | } 72 | 73 | return null; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Fields/Relations/Polymorphic.php: -------------------------------------------------------------------------------- 1 | allSchemas() as $schema) { 37 | if ($schema->isModel($model)) { 38 | return $schema; 39 | } 40 | } 41 | 42 | throw new UnexpectedValueException(sprintf( 43 | 'Model %s is not valid for polymorphic relation %s.', 44 | $class, 45 | $this->name() 46 | )); 47 | } 48 | 49 | /** 50 | * @return Generator 51 | */ 52 | public function allSchemas(): Generator 53 | { 54 | foreach ($this->inverseTypes() as $type) { 55 | $schema = $this->schemas()->schemaFor($type); 56 | 57 | if ($schema instanceof Schema) { 58 | yield $type => $schema; 59 | continue; 60 | } 61 | 62 | throw new LogicException(sprintf( 63 | 'Expecting schema for resource type %s to be an Eloquent schema.', 64 | $type 65 | )); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Fields/Relations/ToMany.php: -------------------------------------------------------------------------------- 1 | defaultPagination = false; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Get the default pagination for the relation. 56 | * 57 | * @return array|null 58 | */ 59 | public function defaultPagination(): ?array 60 | { 61 | if (true === $this->defaultPagination) { 62 | return $this->schema()->defaultPagination(); 63 | } 64 | 65 | return null; 66 | } 67 | 68 | /** 69 | * Parse models for the relationship. 70 | * 71 | * @param mixed $models 72 | * @return iterable 73 | */ 74 | public function parse($models): iterable 75 | { 76 | return $this->schema()->parser()->parseMany( 77 | $models 78 | ); 79 | } 80 | 81 | /** 82 | * Parse a page for the relationship. 83 | * 84 | * @param Page $page 85 | * @return Page 86 | */ 87 | public function parsePage(Page $page): Page 88 | { 89 | return $this->schema()->parser()->parsePage( 90 | $page 91 | ); 92 | } 93 | 94 | /** 95 | * Find many models using the provided JSON:API identifiers. 96 | * 97 | * @param array $identifiers 98 | * @return EloquentCollection 99 | */ 100 | protected function findMany(array $identifiers): EloquentCollection 101 | { 102 | $items = collect($identifiers)->groupBy('type')->map(function(Collection $ids, $type) { 103 | return collect($this->findManyByType($type, $ids)) 104 | ->map(fn($model) => ($model instanceof Proxy) ? $model->toBase() : $model); 105 | })->flatten(); 106 | 107 | return new EloquentCollection($items); 108 | } 109 | 110 | /** 111 | * @inheritDoc 112 | */ 113 | protected function guessInverse(): string 114 | { 115 | return Str::dasherize($this->relationName()); 116 | } 117 | 118 | /** 119 | * @param string $type 120 | * @param Collection $identifiers 121 | * @return iterable 122 | */ 123 | private function findManyByType(string $type, Collection $identifiers): iterable 124 | { 125 | $this->assertInverseType($type); 126 | 127 | return $this->schemas()->schemaFor($type)->repository()->findMany( 128 | $identifiers->pluck('id')->unique()->all() 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Fields/Relations/ToOne.php: -------------------------------------------------------------------------------- 1 | schema()->parser()->parseOne($model); 40 | } 41 | 42 | return null; 43 | } 44 | 45 | /** 46 | * @inheritDoc 47 | */ 48 | protected function guessInverse(): string 49 | { 50 | return Str::dasherize( 51 | Str::plural($this->relationName()) 52 | ); 53 | } 54 | 55 | /** 56 | * @param array|null $value 57 | * @return Model|null 58 | */ 59 | protected function find(?array $value): ?Model 60 | { 61 | if (is_null($value)) { 62 | return null; 63 | } 64 | 65 | $identifier = ResourceIdentifier::fromArray($value); 66 | 67 | $this->assertInverseType($identifier->type()); 68 | 69 | $model = $this 70 | ->schemas() 71 | ->schemaFor($identifier->type()) 72 | ->repository() 73 | ->findOrFail($identifier->id()); 74 | 75 | if ($model instanceof Proxy) { 76 | return $model->toBase(); 77 | } 78 | 79 | return $model; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Fields/SoftDelete.php: -------------------------------------------------------------------------------- 1 | unguarded(); 39 | } 40 | 41 | /** 42 | * @return $this 43 | */ 44 | public function asBoolean(): self 45 | { 46 | $this->boolean = true; 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public function serialize(object $model) 55 | { 56 | if (true === $this->boolean) { 57 | return boolval($model->{$this->column()}); 58 | } 59 | 60 | return parent::serialize($model); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | protected function deserialize($value) 67 | { 68 | if (true === $this->boolean && (is_bool($value) || is_null($value))) { 69 | return $this->parse($value ? Date::now() : null); 70 | } 71 | 72 | if (true === $this->boolean) { 73 | throw new UnexpectedValueException(sprintf( 74 | 'Expecting the value of attribute %s to be a boolean.', 75 | $this->name() 76 | )); 77 | } 78 | 79 | return parent::deserialize($value); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Fields/Str.php: -------------------------------------------------------------------------------- 1 | name() 38 | )); 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/Filters/Concerns/DeserializesToArray.php: -------------------------------------------------------------------------------- 1 | deserialize($value); 29 | 30 | if ($values instanceof Enumerable) { 31 | return $values->all(); 32 | } 33 | 34 | if (is_array($values) || null === $values) { 35 | return $values ?? []; 36 | } 37 | 38 | throw new UnexpectedValueException('Expecting filter value to deserialize to an array.'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Filters/Concerns/DeserializesValue.php: -------------------------------------------------------------------------------- 1 | deserializer = $deserializer; 34 | 35 | return $this; 36 | } 37 | 38 | /** 39 | * Deserialize value as a boolean. 40 | * 41 | * @return $this 42 | */ 43 | public function asBoolean(): self 44 | { 45 | $this->deserializeUsing( 46 | static fn($value) => filter_var($value, FILTER_VALIDATE_BOOL) 47 | ); 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Deserialize the value. 54 | * 55 | * @param mixed $value 56 | * @return mixed 57 | */ 58 | protected function deserialize($value) 59 | { 60 | if ($this->deserializer) { 61 | return ($this->deserializer)($value); 62 | } 63 | 64 | return $value; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Filters/Concerns/HasColumn.php: -------------------------------------------------------------------------------- 1 | column; 33 | } 34 | 35 | /** 36 | * Force the table name when qualifying the column. 37 | * 38 | * This allows the developer to force the table that the column is qualified as. 39 | * 40 | * @param string $table 41 | * @return $this 42 | */ 43 | public function qualifyAs(string $table): self 44 | { 45 | $this->table = $table; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Get the qualified column. 52 | * 53 | * @return string 54 | */ 55 | protected function qualifiedColumn(): string 56 | { 57 | if ($this->table) { 58 | return $this->table . '.' . $this->column; 59 | } 60 | 61 | return $this->column; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Filters/Concerns/HasColumns.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private array $columns = []; 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function columns(): array 32 | { 33 | return $this->columns; 34 | } 35 | 36 | /** 37 | * Add a column to the filter. 38 | * 39 | * @param string $column 40 | * @return $this 41 | */ 42 | public function withColumn(string $column): static 43 | { 44 | $this->columns[] = $column; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Add columns to the filter. 51 | * 52 | * @param string ...$columns 53 | * @return $this 54 | */ 55 | public function withColumns(string ...$columns): static 56 | { 57 | $this->columns = [ 58 | ...$this->columns, 59 | ...$columns, 60 | ]; 61 | 62 | return $this; 63 | } 64 | 65 | /** 66 | * Force the table name when qualifying the columns. 67 | * 68 | * This allows the developer to force the table that the columns are qualified with. 69 | * 70 | * @param string $table 71 | * @return $this 72 | */ 73 | public function qualifyAs(string $table): static 74 | { 75 | $this->table = $table; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Get qualified columns. 82 | * 83 | * @return array 84 | */ 85 | protected function qualifiedColumns(?Model $model = null): array 86 | { 87 | if ($this->table) { 88 | return array_map( 89 | fn($column) => $this->table . '.' . $column, 90 | $this->columns, 91 | ); 92 | } 93 | 94 | if ($model) { 95 | return array_map( 96 | static fn($column) => $model->qualifyColumn($column), 97 | $this->columns, 98 | ); 99 | } 100 | 101 | return $this->columns; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Filters/Concerns/HasDelimiter.php: -------------------------------------------------------------------------------- 1 | delimiter = $delimiter; 41 | 42 | return $this; 43 | } 44 | 45 | /** 46 | * Convert the provided value to an array. 47 | * 48 | * @param string|array|null $value 49 | * @return array 50 | */ 51 | protected function toArray($value): array 52 | { 53 | if ($this->delimiter && is_string($value)) { 54 | return ('' !== $value) ? explode($this->delimiter, $value) : []; 55 | } 56 | 57 | if (is_array($value) || null === $value) { 58 | return $value ?? []; 59 | } 60 | 61 | throw new LogicException('Expecting filter value to be an array, or a string when a string delimiter is set.'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Filters/Concerns/HasOperator.php: -------------------------------------------------------------------------------- 1 | using('='); 28 | } 29 | 30 | /** 31 | * @return $this 32 | */ 33 | public function gt(): self 34 | { 35 | return $this->using('>'); 36 | } 37 | 38 | /** 39 | * @return $this 40 | */ 41 | public function gte(): self 42 | { 43 | return $this->using('>='); 44 | } 45 | 46 | /** 47 | * @return $this 48 | */ 49 | public function lt(): self 50 | { 51 | return $this->using('<'); 52 | } 53 | 54 | /** 55 | * @return $this 56 | */ 57 | public function lte(): self 58 | { 59 | return $this->using('<='); 60 | } 61 | 62 | /** 63 | * Use the provided operator for the filter. 64 | * 65 | * @param string $operator 66 | * @return $this 67 | */ 68 | public function using(string $operator): self 69 | { 70 | $this->operator = $operator; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * @return string 77 | */ 78 | public function operator(): string 79 | { 80 | return $this->operator; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Filters/Concerns/HasRelation.php: -------------------------------------------------------------------------------- 1 | key ?? $this->fieldName(); 51 | } 52 | 53 | /** 54 | * Get the JSON:API relationship field name. 55 | * 56 | * @return string 57 | */ 58 | public function fieldName(): string 59 | { 60 | return $this->fieldName; 61 | } 62 | 63 | /** 64 | * Get the Eloquent relation name. 65 | * 66 | * @return string 67 | */ 68 | public function relationName(): string 69 | { 70 | return $this->relation()->relationName(); 71 | } 72 | 73 | /** 74 | * Get the relationship used for this filter. 75 | * 76 | * @return Relation 77 | */ 78 | protected function relation(): Relation 79 | { 80 | if ($this->relation) { 81 | return $this->relation; 82 | } 83 | 84 | return $this->relation = $this->schema->relationship($this->fieldName); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Filters/Concerns/IsSingular.php: -------------------------------------------------------------------------------- 1 | singular = true; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @return bool 34 | */ 35 | public function isSingular(): bool 36 | { 37 | return $this->singular; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Filters/Has.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 48 | $this->fieldName = $fieldName; 49 | $this->key = $key; 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public function apply($query, $value) 56 | { 57 | $value = $this->deserialize($value); 58 | $relationName = $this->relationName(); 59 | 60 | if (true === $value) { 61 | return $query->has($relationName); 62 | } 63 | 64 | return $query->doesntHave($relationName); 65 | } 66 | 67 | /** 68 | * Deserialize the value. 69 | * 70 | * @param mixed $value 71 | * @return bool 72 | */ 73 | protected function deserialize($value): bool 74 | { 75 | return filter_var($value, FILTER_VALIDATE_BOOL); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Filters/OnlyTrashed.php: -------------------------------------------------------------------------------- 1 | deserialize($value)) { 25 | return $query; 26 | } 27 | 28 | if (is_callable([$query, 'onlyTrashed'])) { 29 | return $query->onlyTrashed(); 30 | } 31 | 32 | throw new LogicException("Filter {$this->key()} expects query builder to have a `withTrashed` method."); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Filters/Scope.php: -------------------------------------------------------------------------------- 1 | name = $name; 54 | $this->scope = $scope ?: $this->guessScope(); 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function key(): string 61 | { 62 | return $this->name; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function apply($query, $value) 69 | { 70 | return $query->{$this->scope}( 71 | $this->deserialize($value) 72 | ); 73 | } 74 | 75 | /** 76 | * @return string 77 | */ 78 | private function guessScope(): string 79 | { 80 | return Str::camel($this->name); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Filters/Where.php: -------------------------------------------------------------------------------- 1 | name = $name; 51 | $this->column = $column ?: $this->guessColumn(); 52 | $this->operator = '='; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function key(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function apply($query, $value) 67 | { 68 | return $query->where( 69 | $query->getModel()->qualifyColumn($this->column()), 70 | $this->operator(), 71 | $this->deserialize($value) 72 | ); 73 | } 74 | 75 | /** 76 | * @return string 77 | */ 78 | private function guessColumn(): string 79 | { 80 | return Str::underscore($this->name); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Filters/WhereAll.php: -------------------------------------------------------------------------------- 1 | |null $columns 35 | * @return static 36 | */ 37 | public static function make(string $name, ?array $columns = null): static 38 | { 39 | return new static($name, $columns); 40 | } 41 | 42 | /** 43 | * WhereAll constructor. 44 | * 45 | * @param string $name 46 | * @param array|null $columns 47 | */ 48 | public function __construct(string $name, ?array $columns = null) 49 | { 50 | $this->name = $name; 51 | $this->columns = $columns ?? []; 52 | $this->operator = '='; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function key(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function apply($query, $value) 67 | { 68 | return $query->whereAll( 69 | $this->qualifiedColumns($query->getModel()), 70 | $this->operator(), 71 | $this->deserialize($value) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Filters/WhereAny.php: -------------------------------------------------------------------------------- 1 | |null $columns 35 | * @return static 36 | */ 37 | public static function make(string $name, ?array $columns = null): static 38 | { 39 | return new static($name, $columns); 40 | } 41 | 42 | /** 43 | * WhereAny constructor. 44 | * 45 | * @param string $name 46 | * @param array|null $columns 47 | */ 48 | public function __construct(string $name, ?array $columns = null) 49 | { 50 | $this->name = $name; 51 | $this->columns = $columns ?? []; 52 | $this->operator = '='; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function key(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function apply($query, $value) 67 | { 68 | return $query->whereAny( 69 | $this->qualifiedColumns($query->getModel()), 70 | $this->operator(), 71 | $this->deserialize($value) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Filters/WhereDoesntHave.php: -------------------------------------------------------------------------------- 1 | whereDoesntHave( 22 | $this->relationName(), 23 | $this->callback($value), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Filters/WhereHas.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 51 | $this->fieldName = $fieldName; 52 | $this->key = $key; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function apply($query, $value) 59 | { 60 | return $query->whereHas( 61 | $this->relationName(), 62 | $this->callback($value), 63 | ); 64 | } 65 | 66 | /** 67 | * Get the relation query callback. 68 | * 69 | * @param mixed $value 70 | * @return Closure 71 | */ 72 | protected function callback($value): Closure 73 | { 74 | return function($query) use ($value) { 75 | $relation = $this->relation(); 76 | FilterApplicator::make($relation->schema(), $relation) 77 | ->apply($query, $this->toArray($value)); 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Filters/WhereIdIn.php: -------------------------------------------------------------------------------- 1 | id(), 53 | $schema->idColumn(), 54 | $key, 55 | ); 56 | } 57 | 58 | return new static( 59 | $schema->id(), 60 | $schema->idKeyName(), 61 | $key, 62 | ); 63 | } 64 | 65 | /** 66 | * WhereIdIn constructor. 67 | * 68 | * @param ID $field 69 | * @param string|null $column 70 | * @param string|null $key 71 | */ 72 | private function __construct(ID $field, ?string $column, ?string $key) 73 | { 74 | $this->field = $field; 75 | $this->column = $column; 76 | $this->key = $key ?: 'id'; 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | public function key(): string 83 | { 84 | return $this->key; 85 | } 86 | 87 | /** 88 | * @inheritDoc 89 | */ 90 | public function apply($query, $value) 91 | { 92 | return $query->whereIn( 93 | $this->qualifyColumn($query->getModel()), 94 | $this->deserialize($value), 95 | ); 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | */ 101 | public function isSingular(): bool 102 | { 103 | return false; 104 | } 105 | 106 | /** 107 | * Get the column for the ID. 108 | * 109 | * @return string|null 110 | */ 111 | protected function column(): ?string 112 | { 113 | return $this->column; 114 | } 115 | 116 | /** 117 | * Get the qualified column for the supplied model. 118 | * 119 | * @param Model $model 120 | * @return string 121 | */ 122 | protected function qualifyColumn(Model $model): string 123 | { 124 | if ($column = $this->column()) { 125 | return $model->qualifyColumn($column); 126 | } 127 | 128 | return $model->qualifyColumn( 129 | $model->getRouteKeyName(), 130 | ); 131 | } 132 | 133 | /** 134 | * Deserialize the resource ids. 135 | * 136 | * @param $value 137 | * @return array 138 | */ 139 | protected function deserialize($value): array 140 | { 141 | return IdParser::make($this->field)->decodeIds( 142 | $this->toArray($value), 143 | ); 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/Filters/WhereIdNotIn.php: -------------------------------------------------------------------------------- 1 | whereNotIn( 23 | $this->qualifyColumn($query->getModel()), 24 | $this->deserialize($value), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Filters/WhereIn.php: -------------------------------------------------------------------------------- 1 | name = $name; 50 | $this->column = $column ?: $this->guessColumn(); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | public function key(): string 57 | { 58 | return $this->name; 59 | } 60 | 61 | /** 62 | * @inheritDoc 63 | */ 64 | public function isSingular(): bool 65 | { 66 | return false; 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function apply($query, $value) 73 | { 74 | return $query->whereIn( 75 | $query->getModel()->qualifyColumn($this->column()), 76 | $this->deserialize($value) 77 | ); 78 | } 79 | 80 | /** 81 | * Deserialize the fitler value. 82 | * 83 | * @param string|array $value 84 | * @return array 85 | */ 86 | protected function deserialize($value): array 87 | { 88 | if ($this->deserializer) { 89 | return ($this->deserializer)($value); 90 | } 91 | 92 | return $this->toArray($value); 93 | } 94 | 95 | /** 96 | * @return string 97 | */ 98 | private function guessColumn(): string 99 | { 100 | return Str::underscore( 101 | Str::singular($this->name) 102 | ); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/Filters/WhereNotIn.php: -------------------------------------------------------------------------------- 1 | whereNotIn( 23 | $query->getModel()->qualifyColumn($this->column()), 24 | $this->deserialize($value) 25 | ); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Filters/WhereNotNull.php: -------------------------------------------------------------------------------- 1 | name = $name; 48 | $this->column = $column ?: $this->guessColumn(); 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function key(): string 55 | { 56 | return $this->name; 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function apply($query, $value) 63 | { 64 | $value = $this->deserialize($value); 65 | $column = $query->getModel()->qualifyColumn($this->column()); 66 | 67 | if ($this->isWhereNull($value)) { 68 | return $query->whereNull($column); 69 | } 70 | 71 | return $query->whereNotNull($column); 72 | } 73 | 74 | /** 75 | * Should a "where null" query be used? 76 | * 77 | * @param bool $value 78 | * @return bool 79 | */ 80 | protected function isWhereNull(bool $value): bool 81 | { 82 | return $value === true; 83 | } 84 | 85 | /** 86 | * Deserialize the value. 87 | * 88 | * @param mixed $value 89 | * @return bool 90 | */ 91 | private function deserialize($value): bool 92 | { 93 | return filter_var($value, FILTER_VALIDATE_BOOL); 94 | } 95 | 96 | /** 97 | * @return string 98 | */ 99 | private function guessColumn(): string 100 | { 101 | return Str::underscore($this->name); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Filters/WherePivot.php: -------------------------------------------------------------------------------- 1 | wherePivot( 26 | $this->column(), 27 | $this->operator(), 28 | $this->deserialize($value) 29 | ); 30 | } 31 | 32 | /** 33 | * If we haven't got a belongs-to-many, then we'll use a standard `where()` and 34 | * hope that our column is qualified enough to be unique in the query so the 35 | * database knows we mean the pivot table. 36 | */ 37 | return $query->where( 38 | $this->qualifiedColumn(), 39 | $this->operator(), 40 | $this->deserialize($value) 41 | ); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Filters/WherePivotIn.php: -------------------------------------------------------------------------------- 1 | wherePivotIn( 26 | $this->column(), 27 | $this->deserialize($value) 28 | ); 29 | } 30 | 31 | /** 32 | * If we haven't got a belongs-to-many, then we'll use a standard `whereIn()` and 33 | * hope that our column is qualified enough to be unique in the query so the 34 | * database knows we mean the pivot table. 35 | */ 36 | return $query->whereIn( 37 | $this->qualifiedColumn(), 38 | $this->deserialize($value) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Filters/WherePivotNotIn.php: -------------------------------------------------------------------------------- 1 | wherePivotNotIn( 26 | $this->column(), 27 | $this->deserialize($value) 28 | ); 29 | } 30 | 31 | /** 32 | * If we haven't got a belongs-to-many, then we'll use a standard `whereNotIn()` and 33 | * hope that our column is qualified enough to be unique in the query so the 34 | * database knows we mean the pivot table. 35 | */ 36 | return $query->whereNotIn( 37 | $this->qualifiedColumn(), 38 | $this->deserialize($value) 39 | ); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/Filters/WithTrashed.php: -------------------------------------------------------------------------------- 1 | name = $name; 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public function isSingular(): bool 51 | { 52 | return false; 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function apply($query, $value) 59 | { 60 | $value = $this->deserialize($value); 61 | 62 | if (is_callable([$query, 'withTrashed'])) { 63 | return $query->withTrashed($value); 64 | } 65 | 66 | throw new LogicException("Filter {$this->key()} expects query builder to have a `withTrashed` method."); 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public function key(): string 73 | { 74 | return $this->name; 75 | } 76 | 77 | /** 78 | * @param $value 79 | * @return bool 80 | */ 81 | protected function deserialize($value): bool 82 | { 83 | return filter_var($value, FILTER_VALIDATE_BOOL); 84 | } 85 | 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/HasQueryParameters.php: -------------------------------------------------------------------------------- 1 | request = $request; 37 | $this->queryParameters = ExtendedQueryParameters::cast($request); 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function withQuery(QueryParametersContract $query): self 46 | { 47 | $this->queryParameters = ExtendedQueryParameters::cast($query); 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public function with($includePaths): self 56 | { 57 | $this->queryParameters->setIncludePaths($includePaths); 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * @param mixed $countable 64 | * @return $this 65 | */ 66 | public function withCount($countable): self 67 | { 68 | $this->queryParameters->setCountable($countable); 69 | 70 | return $this; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Hydrators/ToManyHydrator.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 63 | $this->model = $model; 64 | $this->relation = $relation; 65 | $this->queryParameters = new ExtendedQueryParameters(); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function sync(array $identifiers): iterable 72 | { 73 | $related = $this->model->getConnection()->transaction( 74 | fn() => $this->relation->sync($this->model, $identifiers) 75 | ); 76 | 77 | $this->prepareModel(); 78 | 79 | return $this->relation->parse( 80 | $this->prepareResult($related) 81 | ); 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function attach(array $identifiers): iterable 88 | { 89 | $related = $this->model->getConnection()->transaction( 90 | fn() => $this->relation->attach($this->model, $identifiers) 91 | ); 92 | 93 | $this->prepareModel(); 94 | 95 | return $this->relation->parse( 96 | $this->prepareResult($related) 97 | ); 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function detach(array $identifiers): iterable 104 | { 105 | $related = $this->model->getConnection()->transaction( 106 | fn() => $this->relation->detach($this->model, $identifiers) 107 | ); 108 | 109 | $this->prepareModel(); 110 | 111 | return $this->relation->parse( 112 | $this->prepareResult($related) 113 | ); 114 | } 115 | 116 | /** 117 | * Prepare the result for returning. 118 | * 119 | * @param EloquentCollection|MorphMany $related 120 | * @return iterable 121 | */ 122 | private function prepareResult(iterable $related): iterable 123 | { 124 | /** Always do eager loading, in case we have default include paths. */ 125 | if ($related instanceof EloquentCollection && $related->isNotEmpty()) { 126 | $this->relation->schema()->loaderFor($related)->loadMissing( 127 | $this->queryParameters->includePaths() 128 | ); 129 | } 130 | 131 | if ($related instanceof MorphMany) { 132 | $related->loadMissing( 133 | $this->queryParameters->includePaths() 134 | ); 135 | } 136 | 137 | return $related; 138 | } 139 | 140 | /** 141 | * @return $this 142 | */ 143 | private function prepareModel(): self 144 | { 145 | if ($this->relation->isCountableInRelationship()) { 146 | $this->schema->loaderFor($this->model)->loadCount( 147 | $this->relation->name(), 148 | ); 149 | } 150 | 151 | return $this; 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /src/Hydrators/ToOneHydrator.php: -------------------------------------------------------------------------------- 1 | model = $model; 55 | $this->relation = $relation; 56 | $this->queryParameters = new ExtendedQueryParameters(); 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function associate(?array $identifier): ?object 63 | { 64 | $related = $this->model->getConnection()->transaction( 65 | fn() => $this->relation->associate($this->model, $identifier) 66 | ); 67 | 68 | return $this->relation->parse( 69 | $this->prepareResult($related) 70 | ); 71 | } 72 | 73 | /** 74 | * Prepare the related model. 75 | * 76 | * We always do eager loading, in case any default eager load paths 77 | * have been set on the schema. 78 | * 79 | * @param Model|null $related 80 | * @return Model|null 81 | */ 82 | private function prepareResult(?Model $related): ?Model 83 | { 84 | if (is_null($related)) { 85 | return null; 86 | } 87 | 88 | $parameters = $this->queryParameters; 89 | 90 | if ($this->relation instanceof MorphTo) { 91 | $schema = $this->relation->schemaFor($related); 92 | $parameters = $parameters->forSchema($schema); 93 | } else { 94 | $schema = $this->relation->schema(); 95 | } 96 | 97 | $schema 98 | ->loaderFor($related) 99 | ->loadMissing($parameters->includePaths()) 100 | ->loadCount($parameters->countable()); 101 | 102 | return $related; 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/Pagination/Cursor/Cursor.php: -------------------------------------------------------------------------------- 1 | limit) && 1 > $this->limit) { 31 | throw new InvalidArgumentException('Expecting a limit that is 1 or greater.'); 32 | } 33 | } 34 | 35 | /** 36 | * @return bool 37 | */ 38 | public function isBefore(): bool 39 | { 40 | return !is_null($this->before); 41 | } 42 | 43 | /** 44 | * @return string|null 45 | */ 46 | public function getBefore(): ?string 47 | { 48 | return $this->before; 49 | } 50 | 51 | /** 52 | * @return bool 53 | */ 54 | public function isAfter(): bool 55 | { 56 | return !is_null($this->after) && !$this->isBefore(); 57 | } 58 | 59 | /** 60 | * @return string|null 61 | */ 62 | public function getAfter(): ?string 63 | { 64 | return $this->after; 65 | } 66 | 67 | /** 68 | * Set a limit, if no limit is set on the cursor. 69 | * 70 | * @param int $limit 71 | * @return Cursor 72 | */ 73 | public function withDefaultLimit(int $limit): self 74 | { 75 | if ($this->limit === null) { 76 | return new self( 77 | before: $this->before, 78 | after: $this->after, 79 | limit: $limit, 80 | ); 81 | } 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @return int|null 88 | */ 89 | public function getLimit(): ?int 90 | { 91 | return $this->limit; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Pagination/Cursor/CursorBuilder.php: -------------------------------------------------------------------------------- 1 | keyName = $key ?: $this->id->key(); 64 | $this->parser = new CursorParser(IdParser::make($this->id), $this->keyName); 65 | } 66 | 67 | /** 68 | * Set the default number of items per-page. 69 | * 70 | * If null, the default from the `Model::getPage()` method will be used. 71 | * 72 | * @return $this 73 | */ 74 | public function withDefaultPerPage(?int $perPage): self 75 | { 76 | $this->defaultPerPage = $perPage; 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * @param bool $keySort 83 | * @return $this 84 | */ 85 | public function withKeySort(bool $keySort = true): self 86 | { 87 | $this->keySort = $keySort; 88 | 89 | return $this; 90 | } 91 | 92 | /** 93 | * Set the query direction. 94 | * 95 | * @return $this 96 | */ 97 | public function withDirection(string $direction): self 98 | { 99 | if (\in_array($direction, ['asc', 'desc'])) { 100 | $this->direction = $direction; 101 | 102 | return $this; 103 | } 104 | 105 | throw new \InvalidArgumentException('Unexpected query direction.'); 106 | } 107 | 108 | /** 109 | * @param bool $withTotal 110 | * @return $this 111 | */ 112 | public function withTotal(bool $withTotal): self 113 | { 114 | $this->withTotal = $withTotal; 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * @param array $columns 121 | */ 122 | public function paginate(Cursor $cursor, array $columns = ['*']): CursorPaginator 123 | { 124 | $cursor = $cursor->withDefaultLimit($this->getDefaultPerPage()); 125 | 126 | $this->applyKeySort(); 127 | 128 | $total = $this->getTotal(); 129 | $laravelPaginator = $this->query->cursorPaginate( 130 | $cursor->getLimit(), 131 | $columns, 132 | 'cursor', 133 | $this->parser->decode($cursor), 134 | ); 135 | $paginator = new CursorPaginator($this->parser, $laravelPaginator, $cursor, $total); 136 | 137 | return $paginator->withCurrentPath(); 138 | } 139 | 140 | /** 141 | * @return void 142 | */ 143 | private function applyKeySort(): void 144 | { 145 | if (!$this->keySort) { 146 | return; 147 | } 148 | 149 | if ( 150 | empty($this->query->getQuery()->orders) 151 | || collect($this->query->getQuery()->orders) 152 | ->whereIn('column', [$this->keyName, $this->query->qualifyColumn($this->keyName)]) 153 | ->isEmpty() 154 | ) { 155 | $this->query->orderBy($this->keyName, $this->direction); 156 | } 157 | } 158 | 159 | /** 160 | * @return int|null 161 | */ 162 | private function getTotal(): ?int 163 | { 164 | return $this->withTotal ? $this->query->count() : null; 165 | } 166 | 167 | /** 168 | * @return int 169 | */ 170 | private function getDefaultPerPage(): int 171 | { 172 | if (is_int($this->defaultPerPage)) { 173 | return $this->defaultPerPage; 174 | } 175 | 176 | return $this->query->getModel()->getPerPage(); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Pagination/Cursor/CursorPage.php: -------------------------------------------------------------------------------- 1 | after = $key; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * Set the "before" parameter. 73 | * 74 | * @return $this 75 | */ 76 | public function withBeforeParam(string $key): self 77 | { 78 | if (empty($key)) { 79 | throw new InvalidArgumentException('Expecting a non-empty string.'); 80 | } 81 | 82 | $this->before = $key; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * Set the "limit" parameter. 89 | * 90 | * @return $this 91 | */ 92 | public function withLimitParam(string $key): self 93 | { 94 | if (empty($key)) { 95 | throw new InvalidArgumentException('Expecting a non-empty string.'); 96 | } 97 | 98 | $this->limit = $key; 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * @return Link|null 105 | */ 106 | public function first(): ?Link 107 | { 108 | return new Link('first', $this->url([ 109 | $this->limit => $this->paginator->getPerPage(), 110 | ])); 111 | } 112 | 113 | /** 114 | * @return Link|null 115 | */ 116 | public function prev(): ?Link 117 | { 118 | if ($this->paginator->isNotEmpty() && $this->paginator->hasPrev()) { 119 | return new Link('prev', $this->url([ 120 | $this->before => $this->paginator->firstItem(), 121 | $this->limit => $this->paginator->getPerPage(), 122 | ])); 123 | } 124 | 125 | return null; 126 | } 127 | 128 | /** 129 | * @return Link|null 130 | */ 131 | public function next(): ?Link 132 | { 133 | if ($this->paginator->isNotEmpty() && $this->paginator->hasNext()) { 134 | return new Link('next', $this->url([ 135 | $this->after => $this->paginator->lastItem(), 136 | $this->limit => $this->paginator->getPerPage(), 137 | ])); 138 | } 139 | 140 | return null; 141 | } 142 | 143 | /** 144 | * @return Link|null 145 | */ 146 | public function last(): ?Link 147 | { 148 | return null; 149 | } 150 | 151 | /** 152 | * @param array $page 153 | */ 154 | public function url(array $page): string 155 | { 156 | return $this->paginator->path() . '?' . $this->stringifyQuery($page); 157 | } 158 | 159 | /** 160 | * @return \Traversable 161 | */ 162 | public function getIterator(): \Traversable 163 | { 164 | yield from $this->paginator; 165 | } 166 | 167 | /** 168 | * @return int 169 | */ 170 | public function count(): int 171 | { 172 | return $this->paginator->count(); 173 | } 174 | 175 | /** 176 | * @return array 177 | */ 178 | protected function metaForPage(): array 179 | { 180 | $meta = [ 181 | 'perPage' => $this->paginator->getPerPage(), 182 | 'from' => $this->paginator->getFrom(), 183 | 'to' => $this->paginator->getTo(), 184 | 'hasMore' => $this->paginator->hasMorePages(), 185 | ]; 186 | $total = $this->paginator->getTotal(); 187 | if ($total !== null) { 188 | $meta['total'] = $total; 189 | } 190 | 191 | return $meta; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Pagination/Cursor/CursorPaginator.php: -------------------------------------------------------------------------------- 1 | items = Collection::make($this->laravelPaginator->items()); 47 | } 48 | 49 | /** 50 | * @return Collection 51 | */ 52 | public function getItems(): Collection 53 | { 54 | return clone $this->items; 55 | } 56 | 57 | /** 58 | * @return string|null 59 | */ 60 | public function firstItem(): ?string 61 | { 62 | 63 | if ($this->laravelPaginator->isEmpty()) { 64 | return null; 65 | } 66 | 67 | return $this->parser->encode($this->laravelPaginator->getCursorForItem($this->items->first(), false)); 68 | } 69 | 70 | /** 71 | * @return string|null 72 | */ 73 | public function lastItem(): ?string 74 | { 75 | if ($this->laravelPaginator->isEmpty()) { 76 | return null; 77 | } 78 | 79 | return $this->parser->encode($this->laravelPaginator->getCursorForItem($this->items->last())); 80 | } 81 | 82 | /** 83 | * @return bool 84 | */ 85 | public function hasMorePages(): bool 86 | { 87 | return ($this->cursor->isBefore() && !$this->laravelPaginator->onFirstPage()) || $this->laravelPaginator->hasMorePages(); 88 | } 89 | 90 | /** 91 | * @return bool 92 | */ 93 | public function hasNext(): bool 94 | { 95 | return ((!$this->cursor->isAfter() && !$this->cursor->isBefore()) || $this->cursor->isAfter()) && $this->hasMorePages(); 96 | } 97 | 98 | /** 99 | * @return bool 100 | */ 101 | public function hasPrev(): bool 102 | { 103 | return ($this->cursor->isBefore() && $this->hasMorePages()) || $this->cursor->isAfter(); 104 | } 105 | 106 | /** 107 | * @return bool 108 | */ 109 | public function hasNoMorePages(): bool 110 | { 111 | return !$this->hasMorePages(); 112 | } 113 | 114 | /** 115 | * @return int 116 | */ 117 | public function getPerPage(): int 118 | { 119 | return $this->laravelPaginator->perPage(); 120 | } 121 | 122 | /** 123 | * @return string|null 124 | */ 125 | public function getFrom(): ?string 126 | { 127 | return $this->firstItem(); 128 | } 129 | 130 | /** 131 | * @return string|null 132 | */ 133 | public function getTo(): ?string 134 | { 135 | return $this->lastItem(); 136 | } 137 | 138 | /** 139 | * @return int|null 140 | */ 141 | public function getTotal(): ?int 142 | { 143 | return $this->total; 144 | } 145 | 146 | /** 147 | * @return \Traversable 148 | */ 149 | public function getIterator(): \Traversable 150 | { 151 | yield from $this->items; 152 | } 153 | 154 | /** 155 | * @return int 156 | */ 157 | public function count(): int 158 | { 159 | return $this->items->count(); 160 | } 161 | 162 | /** 163 | * @return bool 164 | */ 165 | public function isEmpty(): bool 166 | { 167 | return $this->items->isEmpty(); 168 | } 169 | 170 | /** 171 | * @return bool 172 | */ 173 | public function isNotEmpty(): bool 174 | { 175 | return !$this->isEmpty(); 176 | } 177 | 178 | /** 179 | * @return $this 180 | */ 181 | public function withCurrentPath(): self 182 | { 183 | $this->path = Paginator::resolveCurrentPath(); 184 | 185 | return $this; 186 | } 187 | 188 | /** 189 | * @param string $path 190 | * @return $this 191 | */ 192 | public function withPath(string $path): self 193 | { 194 | $this->path = $path; 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * Get the base path for paginator generated URLs. 201 | */ 202 | public function path(): ?string 203 | { 204 | return $this->path; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Pagination/Cursor/CursorParser.php: -------------------------------------------------------------------------------- 1 | parameter($this->keyName); 37 | $parameters = $this->withoutPrivate($cursor->toArray()); 38 | $parameters[$this->keyName] = $this->idParser->encode($key); 39 | $cursor = new LaravelCursor($parameters, $cursor->pointsToNextItems()); 40 | } catch (\UnexpectedValueException $ex) { 41 | // Do nothing as the cursor does not contain the key. 42 | } 43 | 44 | return $cursor->encode(); 45 | } 46 | 47 | /** 48 | * @param Cursor $cursor 49 | * @return LaravelCursor|null 50 | */ 51 | public function decode(Cursor $cursor): ?LaravelCursor 52 | { 53 | $decoded = LaravelCursor::fromEncoded( 54 | $cursor->isBefore() ? $cursor->getBefore() : $cursor->getAfter(), 55 | ); 56 | 57 | if ($decoded === null) { 58 | return null; 59 | } 60 | 61 | $parameters = $this->withoutPrivate($decoded->toArray()); 62 | 63 | if (isset($parameters[$this->keyName])) { 64 | $parameters[$this->keyName] = $this->idParser->decode( 65 | $parameters[$this->keyName], 66 | ); 67 | } 68 | 69 | return new LaravelCursor($parameters, $decoded->pointsToNextItems()); 70 | } 71 | 72 | /** 73 | * @param array $values 74 | * @return array 75 | */ 76 | private function withoutPrivate(array $values): array 77 | { 78 | $result = []; 79 | 80 | foreach ($values as $key => $value) { 81 | if (!str_starts_with($key, '_')) { 82 | $result[$key] = $value; 83 | } 84 | } 85 | 86 | return $result; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Pagination/MultiPagination.php: -------------------------------------------------------------------------------- 1 | paginators = $paginators; 37 | } 38 | 39 | /** 40 | * @inheritDoc 41 | */ 42 | public function withColumns($columns): Paginator 43 | { 44 | foreach ($this->paginators as $paginator) { 45 | $paginator->withColumns($columns); 46 | } 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public function keys(): array 55 | { 56 | if ($this->keys !== null) { 57 | return $this->keys; 58 | } 59 | 60 | $keys = []; 61 | 62 | foreach ($this->paginators as $paginator) { 63 | $keys = [ 64 | ...$keys, 65 | ...$paginator->keys(), 66 | ]; 67 | } 68 | 69 | return $this->keys = array_values(array_unique($keys)); 70 | } 71 | 72 | /** 73 | * @inheritDoc 74 | */ 75 | public function withKeyName(string $column): Paginator 76 | { 77 | foreach ($this->paginators as $paginator) { 78 | $paginator->withKeyName($column); 79 | } 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function paginate($query, array $page): Page 88 | { 89 | $pageKeys = array_keys($page); 90 | $selected = null; 91 | 92 | foreach ($this->paginators as $paginator) { 93 | $keys = $paginator->keys(); 94 | $intersection = array_intersect($keys, $pageKeys); 95 | 96 | /** Exact match for a paginator - immediately use this one. */ 97 | if (!empty($intersection) && empty(array_diff($pageKeys, $keys))) { 98 | $selected = $paginator; 99 | break; 100 | } 101 | 102 | /** 103 | * Does match but has a diff, we'll remember the paginator 104 | * and use it if there are no exact matches. 105 | */ 106 | if ($selected === null && !empty($intersection)) { 107 | $selected = $paginator; 108 | } 109 | } 110 | 111 | if ($selected !== null) { 112 | return $selected->paginate($query, $page); 113 | } 114 | 115 | throw new \LogicException( 116 | 'Could not determine which paginator to use. ' . 117 | 'Use validation to ensure the client provides query parameters that match at least one paginator. ' . 118 | 'Keys received: ' . implode(',', $pageKeys), 119 | ); 120 | } 121 | } -------------------------------------------------------------------------------- /src/Pagination/ProxyPage.php: -------------------------------------------------------------------------------- 1 | page = $page; 41 | $this->proxy = $proxy; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function getIterator(): Traversable 48 | { 49 | yield from $this->proxy->iterator($this->page); 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public function count(): int 56 | { 57 | return $this->page->count(); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function meta(): array 64 | { 65 | return $this->page->meta(); 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function links(): Links 72 | { 73 | return $this->page->links(); 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function withQuery($query): Page 80 | { 81 | $this->page->withQuery($query); 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | */ 89 | public function toResponse($request) 90 | { 91 | return $this->page->toResponse($request); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/Parsers/ProxyParser.php: -------------------------------------------------------------------------------- 1 | proxy = $proxy; 36 | } 37 | 38 | /** 39 | * @inheritDoc 40 | */ 41 | public function parseOne(Model $model): object 42 | { 43 | return $this->proxy->proxyFor($model); 44 | } 45 | 46 | /** 47 | * @inheritDoc 48 | */ 49 | public function parseNullable(?Model $model): ?object 50 | { 51 | return $model ? $this->parseOne($model) : null; 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public function parseMany($models): iterable 58 | { 59 | return $this->proxy->iterator($models); 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function parsePage(Page $page): Page 66 | { 67 | return new ProxyPage($page, $this->proxy); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/Parsers/StandardParser.php: -------------------------------------------------------------------------------- 1 | values = $values; 34 | } 35 | 36 | /** 37 | * @param $relations 38 | * @return $this 39 | */ 40 | public function load($relations): self 41 | { 42 | foreach ($this->values as $value) { 43 | $value->load($relations); 44 | } 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * @param $relations 51 | * @return $this 52 | */ 53 | public function loadMissing($relations): self 54 | { 55 | foreach ($this->values as $value) { 56 | $value->loadMissing($relations); 57 | } 58 | 59 | return $this; 60 | } 61 | 62 | /** 63 | * @param MorphValue $value 64 | * @return $this 65 | */ 66 | public function push(MorphValue $value): self 67 | { 68 | $this->values[] = $value; 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * @return Collection 75 | */ 76 | public function collect(): Collection 77 | { 78 | return collect($this->all()); 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function all(): array 85 | { 86 | return iterator_to_array($this); 87 | } 88 | 89 | /** 90 | * @return bool 91 | */ 92 | public function isEmpty(): bool 93 | { 94 | foreach ($this->values as $value) { 95 | if ($value->isNotEmpty()) { 96 | return false; 97 | } 98 | } 99 | 100 | return true; 101 | } 102 | 103 | /** 104 | * @return bool 105 | */ 106 | public function isNotEmpty(): bool 107 | { 108 | return !$this->isEmpty(); 109 | } 110 | 111 | /** 112 | * @inheritDoc 113 | */ 114 | public function count(): int 115 | { 116 | $count = 0; 117 | 118 | foreach ($this->values as $value) { 119 | $count += $value->count(); 120 | } 121 | 122 | return $count; 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function getIterator(): Traversable 129 | { 130 | foreach ($this->values as $value) { 131 | foreach ($value as $item) { 132 | yield $item; 133 | } 134 | } 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /src/Polymorphism/MorphValue.php: -------------------------------------------------------------------------------- 1 | relation = $relation; 47 | $this->value = $value; 48 | } 49 | 50 | /** 51 | * @param $includePaths 52 | * @return $this 53 | */ 54 | public function load($includePaths): self 55 | { 56 | if ($this->isNotEmpty()) { 57 | $schema = $this->relation->schema(); 58 | $includePaths = IncludePaths::cast($includePaths)->forSchema($schema); 59 | 60 | $schema 61 | ->loaderFor($this->value) 62 | ->load($includePaths); 63 | } 64 | 65 | return $this; 66 | } 67 | 68 | /** 69 | * @param $includePaths 70 | * @return $this 71 | */ 72 | public function loadMissing($includePaths): self 73 | { 74 | if ($this->isNotEmpty()) { 75 | $schema = $this->relation->schema(); 76 | $includePaths = IncludePaths::cast($includePaths)->forSchema($schema); 77 | 78 | $schema 79 | ->loaderFor($this->value) 80 | ->loadMissing($includePaths); 81 | } 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @return bool 88 | */ 89 | public function isEmpty(): bool 90 | { 91 | if ($this->value instanceof Enumerable) { 92 | return $this->value->isEmpty(); 93 | } 94 | 95 | return 0 === $this->count(); 96 | } 97 | 98 | /** 99 | * @return bool 100 | */ 101 | public function isNotEmpty(): bool 102 | { 103 | return !$this->isEmpty(); 104 | } 105 | 106 | /** 107 | * @inheritDoc 108 | */ 109 | public function count(): int 110 | { 111 | if ($this->value instanceof Model) { 112 | return 1; 113 | } 114 | 115 | if ($this->value instanceof Countable) { 116 | return $this->value->count(); 117 | } 118 | 119 | return 0; 120 | } 121 | 122 | /** 123 | * @inheritDoc 124 | */ 125 | public function getIterator(): Traversable 126 | { 127 | if ($this->relation instanceof ToOne && $this->value) { 128 | yield $this->relation->parse($this->value); 129 | return; 130 | } 131 | 132 | if ($this->relation instanceof ToMany) { 133 | yield from $this->relation->parse($this->value); 134 | } 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /src/Proxy.php: -------------------------------------------------------------------------------- 1 | map(static fn($model) => static::proxyFor($model)); 65 | } 66 | 67 | /** 68 | * @inheritDoc 69 | */ 70 | public static function wrap($modelOrModels) 71 | { 72 | if ($modelOrModels instanceof Model) { 73 | return static::proxyFor($modelOrModels); 74 | } 75 | 76 | if (is_null($modelOrModels)) { 77 | return null; 78 | } 79 | 80 | return static::iterator($modelOrModels); 81 | } 82 | 83 | /** 84 | * Proxy constructor. 85 | * 86 | * @param Model $model 87 | */ 88 | public function __construct(Model $model) 89 | { 90 | $this->model = $model; 91 | } 92 | 93 | /** 94 | * @param $key 95 | * @return mixed 96 | */ 97 | public function __get($key) 98 | { 99 | return $this->model->{$key}; 100 | } 101 | 102 | /** 103 | * @param $key 104 | * @param $value 105 | */ 106 | public function __set($key, $value) 107 | { 108 | $this->model->{$key} = $value; 109 | } 110 | 111 | /** 112 | * @param $name 113 | * @param $arguments 114 | * @return $this 115 | */ 116 | public function __call($name, $arguments) 117 | { 118 | $result = $this->forwardCallTo($this->model, $name, $arguments); 119 | 120 | if ($result === $this->model) { 121 | return $this; 122 | } 123 | 124 | return $result; 125 | } 126 | 127 | /** 128 | * @inheritDoc 129 | */ 130 | public function getRouteKey() 131 | { 132 | return $this->model->getRouteKey(); 133 | } 134 | 135 | /** 136 | * @inheritDoc 137 | */ 138 | public function getRouteKeyName() 139 | { 140 | return $this->model->getRouteKeyName(); 141 | } 142 | 143 | /** 144 | * @inheritDoc 145 | */ 146 | public function resolveRouteBinding($value, $field = null) 147 | { 148 | return $this->model->resolveRouteBinding($value, $field); 149 | } 150 | 151 | /** 152 | * @inheritDoc 153 | */ 154 | public function resolveChildRouteBinding($childType, $value, $field) 155 | { 156 | return $this->model->resolveChildRouteBinding($childType, $value, $field); 157 | } 158 | 159 | /** 160 | * @inheritDoc 161 | */ 162 | public function wasCreated(): bool 163 | { 164 | return (bool) $this->model->wasRecentlyCreated; 165 | } 166 | 167 | /** 168 | * @inheritDoc 169 | */ 170 | public function toBase(): Model 171 | { 172 | return $this->model; 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /src/ProxySchema.php: -------------------------------------------------------------------------------- 1 | parser) { 28 | return $this->parser; 29 | } 30 | 31 | return $this->parser = new ProxyParser($this->newProxy()); 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public function newInstance(): Model 38 | { 39 | return $this->newProxy()->toBase(); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function isModel($model): bool 46 | { 47 | $expected = get_class($this->newInstance()); 48 | 49 | return ($model instanceof $expected) || $expected === $model; 50 | } 51 | 52 | /** 53 | * Create a new proxy. 54 | * 55 | * @param Model|null $model 56 | * @return ProxyContract 57 | */ 58 | public function newProxy(?Model $model = null): ProxyContract 59 | { 60 | $proxyClass = $this->model(); 61 | 62 | return new $proxyClass($model); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/QueryBuilder/Aggregates/CountableLoader.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 44 | $this->paths = $paths; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function getRelations(): array 51 | { 52 | $relations = []; 53 | 54 | foreach ($this->paths as $path) { 55 | $relation = $this->schema->relationship($path); 56 | 57 | if ($relation instanceof Countable && $relation->isCountable()) { 58 | foreach ($this->relationsFor($relation) as $name) { 59 | $relations[] = $name; 60 | } 61 | continue; 62 | } 63 | 64 | throw new LogicException(sprintf( 65 | 'Field %s is not a countable Eloquent relation on schema %s.', 66 | $path, 67 | $this->schema->type(), 68 | )); 69 | } 70 | 71 | return $relations; 72 | } 73 | 74 | /** 75 | * Yield the countable relations for the provided relationship. 76 | * 77 | * @param Countable $relation 78 | * @return Generator 79 | */ 80 | private function relationsFor(Countable $relation): Generator 81 | { 82 | if ($relation instanceof MorphToMany) { 83 | foreach ($relation as $child) { 84 | // do not check whether the child is countable because the parent is. 85 | if ($child instanceof Countable) { 86 | yield $child->withCountName(); 87 | } 88 | } 89 | return; 90 | } 91 | 92 | yield $relation->withCountName(); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/QueryBuilder/Applicators/SortApplicator.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 54 | } 55 | 56 | /** 57 | * Apply the JSON:API sort fields to the query builder. 58 | * 59 | * @param Builder|Relation $query 60 | * @param SortFields|SortField|array|string|null $fields 61 | * @return $this 62 | */ 63 | public function apply($query, $fields): self 64 | { 65 | $fields = $this->fields = SortFields::nullable($fields); 66 | 67 | if (null === $fields || $fields->isEmpty()) { 68 | return $this; 69 | } 70 | 71 | /** @var SortField $sort */ 72 | foreach ($fields as $sort) { 73 | if ('id' === $sort->name()) { 74 | $this->orderByResourceId($query, $sort); 75 | continue; 76 | } 77 | 78 | $field = $this->schema->sortField($sort->name()); 79 | 80 | if ($field instanceof Sortable) { 81 | $field->sort($query, $sort->getDirection()); 82 | continue; 83 | } 84 | 85 | throw new LogicException(sprintf( 86 | 'Expecting sort field %s on schema %s to implement the Eloquent sortable interface.', 87 | $sort->name(), 88 | get_class($this->schema), 89 | )); 90 | } 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Get the applied sort fields. 97 | * 98 | * @return SortFields|null 99 | */ 100 | public function applied(): ?SortFields 101 | { 102 | return $this->fields; 103 | } 104 | 105 | /** 106 | * @param Builder|Relation $query 107 | * @param SortField $sort 108 | * @return void 109 | */ 110 | private function orderByResourceId($query, SortField $sort): void 111 | { 112 | $idColumn = $query->getModel()->qualifyColumn( 113 | $this->schema->idColumn() 114 | ); 115 | 116 | $query->orderBy($idColumn, $sort->getDirection()); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/QueryBuilder/EagerLoading/EagerLoadIterator.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 59 | $this->paths = $paths; 60 | } 61 | 62 | /** 63 | * Get the paths as a collection. 64 | * 65 | * Before returning the paths, we filter out any duplicates. For example, if the iterator 66 | * yields `user` and `user.country`, we only want `user.country` to be in the collection. 67 | * 68 | * @return Collection 69 | */ 70 | public function collect(): Collection 71 | { 72 | $values = Collection::make($this); 73 | 74 | return $values 75 | ->reject(static fn(string $path) => $values 76 | ->contains(fn(string $check) => $path !== $check && Str::startsWith($check, $path . '.'))) 77 | ->sort() 78 | ->values(); 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function all(): array 85 | { 86 | return $this->collect()->all(); 87 | } 88 | 89 | /** 90 | * @inheritDoc 91 | */ 92 | public function getIterator(): Traversable 93 | { 94 | /** 95 | * We always need to yield the default paths on the base schema. 96 | */ 97 | foreach ($this->schema->with() as $relation) { 98 | yield $relation; 99 | } 100 | 101 | /** 102 | * Next we iterate over the include paths, using the EagerLoadPathList 103 | * class to work out what the eager load path(s) are for each include 104 | * path. (One JSON:API include path can map to one-to-many Eloquent 105 | * eager load paths.) 106 | */ 107 | foreach ($this->paths as $path) { 108 | foreach (new EagerLoadPathList($this->schema, $path) as $eagerLoadPath) { 109 | yield $eagerLoadPath; 110 | } 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/QueryBuilder/EagerLoading/EagerLoadMorphs.php: -------------------------------------------------------------------------------- 1 | schemas = $schemas; 55 | $this->relation = $relation; 56 | $this->paths = $paths; 57 | } 58 | 59 | /** 60 | * @return string 61 | */ 62 | public function name(): string 63 | { 64 | return $this->relation->relationName(); 65 | } 66 | 67 | /** 68 | * @return array 69 | */ 70 | public function all(): array 71 | { 72 | return array_filter(iterator_to_array($this)); 73 | } 74 | 75 | /** 76 | * @inheritDoc 77 | */ 78 | public function getIterator(): Traversable 79 | { 80 | foreach ($this->relation->allSchemas() as $schema) { 81 | $loader = new EagerLoader($this->schemas, $schema, $this->pathsFor($schema)); 82 | 83 | yield $schema->model() => $loader->getRelations(); 84 | } 85 | } 86 | 87 | /** 88 | * Get the paths that are valid for the provided schema. 89 | * 90 | * Paths are only valid for the provided schema if the first relation in the include 91 | * path exists on the provided schema. Otherwise it needs to be skipped. 92 | * 93 | * @param Schema $schema 94 | * @return IncludePaths 95 | */ 96 | private function pathsFor(Schema $schema): IncludePaths 97 | { 98 | return $this->paths->filter( 99 | fn(RelationshipPath $path) => $schema->isRelationship($path->first()) 100 | ); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/QueryBuilder/EagerLoading/EagerLoadPathList.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 53 | $this->path = $path; 54 | } 55 | 56 | /** 57 | * Get the default eager load paths. 58 | * 59 | * @return iterable 60 | */ 61 | public function defaults(): iterable 62 | { 63 | foreach ($this->cachedPaths() as $path) { 64 | foreach ($path->defaults() as $default) { 65 | yield $default; 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Get the eager load paths for the relationship path. 72 | * 73 | * @return array 74 | */ 75 | public function paths(): iterable 76 | { 77 | foreach ($this->cachedPaths() as $path) { 78 | yield $path->toString(); 79 | } 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | public function getIterator(): Traversable 86 | { 87 | foreach ($this->defaults() as $default) { 88 | yield $default; 89 | } 90 | 91 | foreach ($this->paths() as $path) { 92 | yield $path; 93 | } 94 | } 95 | 96 | /** 97 | * Get the first relationship. 98 | * 99 | * @return Relation 100 | */ 101 | private function relation(): Relation 102 | { 103 | $relation = $this->schema->relationship( 104 | $this->path->first() 105 | ); 106 | 107 | if ($relation instanceof Relation) { 108 | return $relation; 109 | } 110 | 111 | throw new LogicException('Expecting an Eloquent relationship.'); 112 | } 113 | 114 | /** 115 | * @return EagerLoadPath[] 116 | */ 117 | private function cachedPaths(): array 118 | { 119 | if (is_array($this->paths)) { 120 | return $this->paths; 121 | } 122 | 123 | return $this->paths = $this->compute(); 124 | } 125 | 126 | /** 127 | * Calculate the eager load paths for the provided relationship path. 128 | * 129 | * Due to polymorphic to-many relationships, one JSON:API include path 130 | * can be mapped to one or many Eloquent eager load paths. 131 | * 132 | * @return array 133 | */ 134 | private function compute(): array 135 | { 136 | $paths = EagerLoadPath::make($this->relation()); 137 | $terminated = []; 138 | 139 | if ($path = $this->path->skip(1)) { 140 | foreach ($path as $idx => $name) { 141 | $retain = []; 142 | foreach ($paths as $path) { 143 | if (is_array($next = $path->next($name))) { 144 | $retain = array_merge($retain, $next); 145 | continue; 146 | } 147 | 148 | $terminated[] = $path; 149 | } 150 | 151 | $paths = $retain; 152 | } 153 | } 154 | 155 | return array_merge($paths, $terminated); 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/QueryBuilder/EagerLoading/EagerLoader.php: -------------------------------------------------------------------------------- 1 | schemas = $schemas; 53 | $this->schema = $schema; 54 | $this->paths = $paths ?? new IncludePaths(); 55 | } 56 | 57 | /** 58 | * Get the eager load relationship paths. 59 | * 60 | * @return array 61 | */ 62 | public function getRelations(): array 63 | { 64 | return EagerLoadIterator::make($this->schema, $this->paths)->all(); 65 | } 66 | 67 | /** 68 | * Get the morph-to eager load paths. 69 | * 70 | * @return array 71 | */ 72 | public function getMorphs(): array 73 | { 74 | return $this->paths 75 | ->collect() 76 | ->filter(fn($path) => $this->isMorph($path)) 77 | ->groupBy(fn(RelationshipPath $path) => $path->first()) 78 | ->map(fn($paths, $name) => $this->morphs($name, $paths)->all()) 79 | ->all(); 80 | } 81 | 82 | /** 83 | * Does the relationship path need to be treated as a morph map? 84 | * 85 | * We create morph maps for any path where the first item in the path 86 | * is a morph-to relation, and: 87 | * 88 | * 1. there is more than one relation in the path and it is an include 89 | * path; OR 90 | * 2. at least one of the inverse resource types has default eager load 91 | * paths. 92 | * 93 | * @param RelationshipPath $path 94 | * @return bool 95 | */ 96 | private function isMorph(RelationshipPath $path): bool 97 | { 98 | if (!$this->schema->isRelationship($path->first())) { 99 | return false; 100 | } 101 | 102 | $relation = $this->schema->relationship($path->first()); 103 | 104 | if (!$relation instanceof MorphTo) { 105 | return false; 106 | } 107 | 108 | if (1 < $path->count() && $relation->isIncludePath()) { 109 | return true; 110 | } 111 | 112 | /** @var Schema $schema */ 113 | foreach ($relation->allSchemas() as $schema) { 114 | if (!empty($schema->with())) { 115 | return true; 116 | } 117 | } 118 | 119 | return false; 120 | } 121 | 122 | /** 123 | * @param string $fieldName 124 | * @param $paths 125 | * @return EagerLoadMorphs 126 | */ 127 | private function morphs(string $fieldName, $paths): EagerLoadMorphs 128 | { 129 | /** @var MorphTo $relation */ 130 | $relation = $this->schema->relationship($fieldName); 131 | 132 | return new EagerLoadMorphs( 133 | $this->schemas, 134 | $relation, 135 | IncludePaths::cast($paths)->skip(1) 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/QueryBuilder/ModelLoader.php: -------------------------------------------------------------------------------- 1 | schemas = $schemas; 56 | $this->schema = $schema; 57 | $this->target = $target; 58 | } 59 | 60 | /** 61 | * Eager load relations using JSON:API include paths. 62 | * 63 | * @param $includePaths 64 | * @return $this 65 | */ 66 | public function load($includePaths): self 67 | { 68 | $loader = new EagerLoader( 69 | $this->schemas, 70 | $this->schema, 71 | IncludePaths::cast($includePaths), 72 | ); 73 | 74 | $this->target->load( 75 | $loader->getRelations() 76 | ); 77 | 78 | foreach ($loader->getMorphs() as $relation => $map) { 79 | $this->target->loadMorph($relation, $map); 80 | } 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * Eager load relations using JSON:API include paths, if they are not already loaded. 87 | * 88 | * @param $includePaths 89 | * @return $this 90 | */ 91 | public function loadMissing($includePaths): self 92 | { 93 | $loader = new EagerLoader( 94 | $this->schemas, 95 | $this->schema, 96 | IncludePaths::cast($includePaths), 97 | ); 98 | 99 | $this->target->loadMissing( 100 | $loader->getRelations() 101 | ); 102 | 103 | foreach ($loader->getMorphs() as $relation => $map) { 104 | $this->target->loadMorph($relation, $map); 105 | } 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Eager load relation counts. 112 | * 113 | * @param $countable 114 | * @return $this 115 | */ 116 | public function loadCount($countable): self 117 | { 118 | $paths = CountablePaths::cast($countable); 119 | 120 | if ($paths->isNotEmpty()) { 121 | $counter = new CountableLoader($this->schema, $paths); 122 | $this->target->loadCount($counter->getRelations()); 123 | } 124 | 125 | return $this; 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/QueryMorphTo.php: -------------------------------------------------------------------------------- 1 | model = $model; 45 | $this->relation = $relation; 46 | $this->queryParameters = new ExtendedQueryParameters(); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function filter(?array $filters): self 53 | { 54 | $this->queryParameters->setFilters($filters); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | */ 62 | public function first(): ?object 63 | { 64 | /** @var Model|null $related */ 65 | $related = $this->model->{$this->relation->relationName()}; 66 | $filters = $this->queryParameters->filter(); 67 | 68 | /** 69 | * If there are no filters, we can just return the related 70 | * model - loading any missing relations. 71 | */ 72 | if (is_null($related) || empty($filters)) { 73 | return $this->relation->parse( 74 | $this->prepareResult($related) 75 | ); 76 | } 77 | 78 | $schema = $this->relation->schemaFor($related); 79 | 80 | $expected = collect($schema->filters()) 81 | ->map(fn(Filter $filter) => $filter->key()) 82 | ->values(); 83 | 84 | /** 85 | * If there are any filters that are not valid for this schema, 86 | * then we know the related model cannot match the filters. So 87 | * in this scenario, we return `null`. 88 | */ 89 | if (collect($filters)->keys()->diff($expected)->isNotEmpty()) { 90 | return null; 91 | } 92 | 93 | /** 94 | * Otherwise we need to re-query this specific model to see if 95 | * it matches our filters or not. 96 | */ 97 | $result = $schema 98 | ->newQuery($related->newQuery()) 99 | ->whereKey($related->getKey()) 100 | ->filter($filters) 101 | ->first(); 102 | 103 | return $this->relation->parse( 104 | $this->prepareResult($result) 105 | ); 106 | } 107 | 108 | /** 109 | * Prepare the model to be returned as the result of the query. 110 | * 111 | * @param Model|null $related 112 | * @return Model|null 113 | */ 114 | private function prepareResult(?Model $related): ?Model 115 | { 116 | if ($related) { 117 | $schema = $this->relation->schemaFor($related); 118 | $parameters = $this->queryParameters->forSchema($schema); 119 | 120 | $schema 121 | ->loaderFor($related) 122 | ->loadMissing($parameters->includePaths()) 123 | ->loadCount($parameters->countable()); 124 | } 125 | 126 | return $related; 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /src/QueryMorphToMany.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 58 | $this->model = $model; 59 | $this->relation = $relation; 60 | $this->queryParameters = new ExtendedQueryParameters(); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function filter(?array $filters): self 67 | { 68 | $this->queryParameters->setFilters($filters); 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * @inheritDoc 75 | */ 76 | public function sort($fields): self 77 | { 78 | $this->queryParameters->setSortFields($fields); 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | */ 86 | public function get(): Collection 87 | { 88 | return Collection::make($this->values()); 89 | } 90 | 91 | /** 92 | * @return LazyCollection 93 | */ 94 | public function cursor(): LazyCollection 95 | { 96 | return LazyCollection::make(function () { 97 | yield from $this->values(); 98 | }); 99 | } 100 | 101 | /** 102 | * @return Generator 103 | */ 104 | public function getIterator(): Generator 105 | { 106 | foreach ($this->relation as $relation) { 107 | $query = $this->toQuery($relation); 108 | 109 | if ($this->request) { 110 | $query->withRequest($this->request); 111 | } 112 | 113 | yield $query->withQuery( 114 | $this->queryParameters->forSchema($relation->schema()) 115 | ); 116 | } 117 | } 118 | 119 | /** 120 | * @param Relation $relation 121 | * @return QueryMorphTo|QueryToMany|QueryToOne 122 | */ 123 | private function toQuery(Relation $relation) 124 | { 125 | if ($relation instanceof MorphTo) { 126 | return new QueryMorphTo($this->model, $relation); 127 | } 128 | 129 | if ($relation instanceof ToOne) { 130 | return new QueryToOne($this->model, $relation); 131 | } 132 | 133 | if ($relation instanceof ToMany) { 134 | return new QueryToMany($this->schema, $this->model, $relation); 135 | } 136 | 137 | throw new LogicException(sprintf( 138 | 'Unsupported relation for querying morph-to-many: %s', 139 | get_class($relation), 140 | )); 141 | } 142 | 143 | /** 144 | * @return Generator 145 | */ 146 | private function values(): Generator 147 | { 148 | /** @var QueryToOne|QueryMorphTo|QueryToMany $query */ 149 | foreach ($this as $query) { 150 | if ($query instanceof QueryToOne || $query instanceof QueryMorphTo) { 151 | if ($value = $query->first()) { 152 | yield $value; 153 | } 154 | continue; 155 | } 156 | 157 | foreach ($query->cursor() as $value) { 158 | yield $value; 159 | } 160 | } 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /src/QueryOne.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 73 | $this->driver = $driver; 74 | $this->parser = $parser; 75 | $this->model = $model; 76 | $this->resourceId = $resourceId; 77 | $this->queryParameters = new ExtendedQueryParameters(); 78 | } 79 | 80 | /** 81 | * @return JsonApiBuilder 82 | */ 83 | public function query(): JsonApiBuilder 84 | { 85 | $query = $this->schema->newQuery( 86 | $this->driver->query() 87 | ); 88 | 89 | if ($this->model) { 90 | $query->whereKey($this->model->getKey()); 91 | } else { 92 | $query->whereResourceId($this->resourceId); 93 | } 94 | 95 | return $query->withQueryParameters($this->queryParameters); 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | */ 101 | public function filter(?array $filters): self 102 | { 103 | $this->queryParameters->setFilters($filters); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * @inheritDoc 110 | */ 111 | public function first(): ?object 112 | { 113 | if ($this->model && empty($this->queryParameters->filter())) { 114 | $this->schema 115 | ->loaderFor($this->model) 116 | ->loadMissing($this->queryParameters->includePaths()) 117 | ->loadCount($this->queryParameters->countable()); 118 | 119 | return $this->parser->parseOne($this->model); 120 | } 121 | 122 | return $this->parser->parseNullable( 123 | $this->query()->first() 124 | ); 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/QueryToMany.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 55 | $this->model = $model; 56 | $this->relation = $relation; 57 | $this->queryParameters = new ExtendedQueryParameters(); 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function filter(?array $filters): self 64 | { 65 | $this->queryParameters->setFilters($filters); 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * @inheritDoc 72 | */ 73 | public function sort($fields): self 74 | { 75 | $this->queryParameters->setSortFields($fields); 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * @inheritDoc 82 | */ 83 | public function get(): iterable 84 | { 85 | return $this->relation->parse( 86 | $this->query()->get() 87 | ); 88 | } 89 | 90 | /** 91 | * @return LazyCollection 92 | */ 93 | public function cursor(): LazyCollection 94 | { 95 | $value = $this->relation->parse( 96 | $this->query()->cursor() 97 | ); 98 | 99 | if ($value instanceof LazyCollection) { 100 | return $value; 101 | } 102 | 103 | return LazyCollection::make($value); 104 | } 105 | 106 | /** 107 | * @inheritDoc 108 | */ 109 | public function paginate(array $page): Page 110 | { 111 | return $this->relation->parsePage( 112 | $this->query()->paginate($page) 113 | ); 114 | } 115 | 116 | /** 117 | * @inheritDoc 118 | */ 119 | public function getOrPaginate(?array $page): iterable 120 | { 121 | if (is_null($page)) { 122 | $page = $this->relation->defaultPagination(); 123 | } 124 | 125 | if (is_null($page)) { 126 | return $this->get(); 127 | } 128 | 129 | return $this->paginate($page); 130 | } 131 | 132 | /** 133 | * @return JsonApiBuilder 134 | */ 135 | public function query(): JsonApiBuilder 136 | { 137 | $this->prepareModel(); 138 | 139 | $base = $this->relation->schema()->relatableQuery( 140 | $this->request, $this->getRelation() 141 | ); 142 | 143 | return $this->relation 144 | ->newQuery($base) 145 | ->withQueryParameters($this->queryParameters); 146 | } 147 | 148 | /** 149 | * @return EloquentRelation 150 | */ 151 | private function getRelation(): EloquentRelation 152 | { 153 | $name = $this->relation->relationName(); 154 | 155 | assert(method_exists($this->model, $name) || $this->model->relationResolver($this->model::class, $name), sprintf( 156 | 'Expecting method %s to exist on model %s', 157 | $name, 158 | $this->model::class, 159 | )); 160 | 161 | $relation = $this->model->{$name}(); 162 | 163 | assert($relation instanceof EloquentRelation, sprintf( 164 | 'Expecting method %s on model %s to return an Eloquent relation.', 165 | $name, 166 | $this->model::class, 167 | )); 168 | 169 | return $relation; 170 | } 171 | 172 | /** 173 | * @return $this 174 | */ 175 | private function prepareModel(): self 176 | { 177 | if ($this->relation->isCountableInRelationship()) { 178 | $this->model->loadCount( 179 | $this->relation->withCountName(), 180 | ); 181 | } 182 | 183 | return $this; 184 | } 185 | 186 | } 187 | -------------------------------------------------------------------------------- /src/QueryToOne.php: -------------------------------------------------------------------------------- 1 | model = $model; 46 | $this->relation = $relation; 47 | $this->queryParameters = new ExtendedQueryParameters(); 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public function filter(?array $filters): self 54 | { 55 | $this->queryParameters->setFilters($filters); 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function first(): ?object 64 | { 65 | if ($this->model->relationLoaded($this->relation->name()) && empty($this->queryParameters->filter())) { 66 | return $this->relation->parse( 67 | $this->related() 68 | ); 69 | } 70 | 71 | return $this->relation->parse( 72 | $this->query()->first() 73 | ); 74 | } 75 | 76 | /** 77 | * @return JsonApiBuilder 78 | */ 79 | public function query(): JsonApiBuilder 80 | { 81 | return $this->relation 82 | ->newQuery($this->getRelation()) 83 | ->withQueryParameters($this->queryParameters); 84 | } 85 | 86 | /** 87 | * @return EloquentRelation 88 | */ 89 | private function getRelation(): EloquentRelation 90 | { 91 | $name = $this->relation->relationName(); 92 | 93 | assert(method_exists($this->model, $name) || $this->model->relationResolver($this->model::class, $name), sprintf( 94 | 'Expecting method %s to exist on model %s', 95 | $name, 96 | $this->model::class, 97 | )); 98 | 99 | $relation = $this->model->{$name}(); 100 | 101 | assert($relation instanceof EloquentRelation, sprintf( 102 | 'Expecting method %s on model %s to return an Eloquent relation.', 103 | $name, 104 | $this->model::class, 105 | )); 106 | 107 | return $relation; 108 | } 109 | 110 | /** 111 | * Return the already loaded related model. 112 | * 113 | * @return Model|null 114 | */ 115 | private function related(): ?Model 116 | { 117 | if ($related = $this->model->getRelation($this->relation->relationName())) { 118 | $this->relation 119 | ->schema() 120 | ->loaderFor($related) 121 | ->loadMissing($this->queryParameters->includePaths()) 122 | ->loadCount($this->queryParameters->countable()); 123 | 124 | return $related; 125 | } 126 | 127 | return null; 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/Resources/Relation.php: -------------------------------------------------------------------------------- 1 | name(), 70 | $field->relationName(), 71 | $field->uriName(), 72 | ); 73 | 74 | $this->field = $field; 75 | } 76 | 77 | /** 78 | * @return array|null 79 | */ 80 | public function meta(): ?array 81 | { 82 | if (is_array($this->cachedMeta)) { 83 | return $this->cachedMeta ?: null; 84 | } 85 | 86 | $this->cachedMeta = array_replace( 87 | $this->defaultMeta(), 88 | parent::meta() ?: [], 89 | ); 90 | 91 | return $this->cachedMeta ?: null; 92 | } 93 | 94 | /** 95 | * @inheritDoc 96 | */ 97 | protected function value() 98 | { 99 | if ($this->field instanceof MorphToMany) { 100 | return $this->field->value($this->resource); 101 | } 102 | 103 | return $this->field->parse( 104 | parent::value() 105 | ); 106 | } 107 | 108 | /** 109 | * Get default relationship meta. 110 | * 111 | * @return array 112 | */ 113 | private function defaultMeta(): array 114 | { 115 | if ($this->countable()) { 116 | return array_filter([ 117 | self::withCount() => $this->count(), 118 | ], fn($value) => (null !== $value)); 119 | } 120 | 121 | return []; 122 | } 123 | 124 | /** 125 | * Is the field countable? 126 | * 127 | * @return bool 128 | */ 129 | private function countable(): bool 130 | { 131 | if ($this->field instanceof Countable) { 132 | return $this->field->isCountable(); 133 | } 134 | 135 | return false; 136 | } 137 | 138 | /** 139 | * Get the relationship count. 140 | * 141 | * @return int|null 142 | */ 143 | private function count(): ?int 144 | { 145 | if ($this->field instanceof MorphToMany) { 146 | return $this->field->count($this->resource); 147 | } 148 | 149 | if ($this->field instanceof ToMany) { 150 | $value = $this->resource->{$this->field->keyForCount()}; 151 | return !is_null($value) ? intval($value) : null; 152 | } 153 | 154 | return null; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/SoftDeletes.php: -------------------------------------------------------------------------------- 1 | newInstance()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Sorting/SortColumn.php: -------------------------------------------------------------------------------- 1 | fieldName = $fieldName; 51 | $this->column = $column ?? $this->guessColumn(); 52 | } 53 | 54 | /** 55 | * @inheritDoc 56 | */ 57 | public function sortField(): string 58 | { 59 | return $this->fieldName; 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function sort($query, string $direction = 'asc') 66 | { 67 | return $query->orderBy( 68 | $query->getModel()->qualifyColumn($this->column), 69 | $direction, 70 | ); 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | private function guessColumn(): string 77 | { 78 | return Str::underscore($this->fieldName); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Sorting/SortCountable.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 58 | $this->fieldName = $fieldName; 59 | $this->key = $key ?? $fieldName; 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function sortField(): string 66 | { 67 | return $this->key; 68 | } 69 | 70 | /** 71 | * @inheritDoc 72 | */ 73 | public function sort($query, string $direction = 'asc') 74 | { 75 | $relation = $this->schema->toMany($this->fieldName); 76 | 77 | return $query 78 | ->withCount($relation->withCountName()) 79 | ->orderBy($relation->keyForCount(), $direction); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Sorting/SortWithCount.php: -------------------------------------------------------------------------------- 1 | relationName = $relationName; 62 | $this->key = $key ?? $relationName; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function sortField(): string 69 | { 70 | return $this->key; 71 | } 72 | 73 | /** 74 | * Set an alias for the relationship count. 75 | * 76 | * @param string $alias 77 | * @return $this 78 | */ 79 | public function countAs(string $alias): self 80 | { 81 | $this->countAs = $alias; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * @param Closure $callback 88 | * @return $this 89 | */ 90 | public function using(Closure $callback): self 91 | { 92 | $this->callback = $callback; 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * @inheritDoc 99 | */ 100 | public function sort($query, string $direction = 'asc') 101 | { 102 | $callback = $this->callback ?? static fn($query) => $query; 103 | 104 | return $query 105 | ->withCount([$this->withCountName() => $callback]) 106 | ->orderBy($this->keyForCount(), $direction); 107 | } 108 | 109 | /** 110 | * @return string 111 | */ 112 | protected function withCountName(): string 113 | { 114 | if ($this->countAs) { 115 | return "{$this->relationName} as {$this->countAs}"; 116 | } 117 | 118 | return $this->relationName; 119 | } 120 | 121 | /** 122 | * @return string 123 | */ 124 | protected function keyForCount(): string 125 | { 126 | if ($this->countAs) { 127 | return $this->countAs; 128 | } 129 | 130 | return Str::snake($this->relationName) . '_count'; 131 | } 132 | 133 | } 134 | --------------------------------------------------------------------------------