Laragear/Refine

View on GitHub
src/ModelRefiner.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

namespace Laragear\Refine;

use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laragear\Refine\Contracts\ValidatesRefiner;
use UnexpectedValueException;

use function array_pad;
use function explode;
use function htmlspecialchars;
use function in_array;
use function join;
use function max;
use function min;

use const ENT_QUOTES;
use const ENT_SUBSTITUTE;

abstract class ModelRefiner extends Refiner implements ValidatesRefiner
{
    /**
     * If the query search should use full-text search.
     *
     * @var bool
     *
     * @see https://laravel.com/docs/11.x/queries#full-text-where-clauses
     */
    protected bool $fullTextSearch = false;

    /**
     * Return the validation rules.
     *
     * @return array<string, string|string[]|\Illuminate\Contracts\Validation\Rule[]>
     */
    public function validationRules(): array
    {
        return [
            'query' => 'sometimes|nullable|string',
            'only' => 'sometimes|nullable|array',
            'only.*' => ['required_with:only', 'string', Rule::in($this->getOnlyColumns())],
            'has' => 'sometimes|nullable|array',
            'has.*' => ['required_with:has', 'string', Rule::in($this->getHasRelations())],
            'has_not' => 'sometimes|nullable|array',
            'has_not.*' => ['required_with:missing', 'string', Rule::in($this->getHasNotRelations())],
            'with' => 'sometimes|nullable|array',
            'with.*' => ['required_with:with', 'string', Rule::in($this->getWithRelations())],
            'with_count' => 'sometimes|nullable|array',
            'with_count.*' => ['required_with:with_count', 'string', Rule::in($this->getCountRelations())],
            'with_sum' => 'sometimes|nullable|array',
            'with_sum.*' => ['required_with:with_sum', 'string', Rule::in($this->getWithSumRelations())],
            'trashed' => 'sometimes|nullable|boolean',
            'order_by' => ['sometimes', 'nullable', Rule::in($this->getOrderByColumns())],
            'order_by_desc' => ['sometimes', 'nullable', Rule::in($this->getOrderByColumns())],
            'limit' => 'sometimes|nullable|integer',
            'per_page' => 'sometimes|nullable|integer',
        ];
    }

    /**
     * Return the keys to use to refine the query.
     *
     * @return string[]
     */
    public function getKeys(Request $request): array
    {
        return [
            'query',
            'only',
            'except',
            'has',
            'has_not',
            'with',
            'with_count',
            'with_sum',
            'trashed',
            'order',
            'order_by',
            'order_by_desc',
            'limit',
            'per_page',
        ];
    }

    /**
     * Return the columns that should only be included in the query.
     *
     * @return string[]
     */
    protected function getOnlyColumns(): array
    {
        return [];
    }

    /**
     * Return the relations that should exist for the query.
     *
     * @return string[]
     */
    protected function getHasRelations(): array
    {
        return [];
    }

    /**
     * Return the relations that should be missing for the query.
     *
     * @return string[]
     */
    protected function getHasNotRelations(): array
    {
        return [];
    }

    /**
     * Return the relations that can be queried.
     *
     * @return string[]
     */
    protected function getWithRelations(): array
    {
        return [];
    }

    /**
     * Return the relations that can be counted.
     *
     * @return string[]
     */
    protected function getCountRelations(): array
    {
        return [];
    }

    /**
     * Return the relations and the columns that should be sum.
     *
     * @return string[]
     */
    protected function getWithSumRelations(): array
    {
        // Separate the relation name using hyphen (`-`). For example, `published_posts-votes`.
        return [];
    }

    /**
     * Return the columns that can be used to sort the query.
     *
     * @return string[]
     */
    protected function getOrderByColumns(): array
    {
        return [];
    }

    /**
     * Return the column used for full-text search.
     *
     * @return string|string[]
     */
    protected function getQueryColumns(): string|array
    {
        return [];
    }

    /**
     * Filter the query by a column containing a given text.
     */
    public function query(EloquentBuilder $query, string $search): void
    {
        if ($columns = (array) $this->getQueryColumns()) {
            $query->where(function (EloquentBuilder $query) use ($search, $columns): void {
                $query->whereKey($search);

                if ($this->fullTextSearch) {
                    $query->orWhereFullText($columns, $search);
                } else {
                    foreach ($columns as $column) {
                        $query->orWhere($column, 'ILIKE', Str::wrap(
                            htmlspecialchars($search, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '%')
                        );
                    }
                }
            });
        }
    }

    /**
     * Select only some columns to return in the query results.
     *
     * @param  string[]  $columns
     */
    public function only(EloquentBuilder $query, array $columns): void
    {
        $query->select($columns);
    }

    /**
     * Find records that contain at least one related model.
     *
     * @param  string[]  $relations
     */
    public function has(EloquentBuilder $query, array $relations): void
    {
        foreach ($relations as $relation) {
            $query->has($this->normalizeRelation($relation));
        }
    }

    /**
     * Find records that do not contain any related model.
     *
     * @param  string[]  $relations
     */
    public function hasNot(EloquentBuilder $query, array $relations): void
    {
        foreach ($relations as $relation) {
            $query->doesntHave($this->normalizeRelation($relation));
        }
    }

    /**
     * Load the given relations to the query results.
     *
     * @param  string[]  $relations
     */
    public function with(EloquentBuilder $query, array $relations): void
    {
        foreach ($relations as $relation) {
            $query->with($this->normalizeRelation($relation));
        }
    }

    /**
     * Load the given count of relations to the query result.
     *
     * @param  string[]  $relations
     */
    public function withCount(EloquentBuilder $query, array $relations): void
    {
        foreach ($relations as $relation) {
            $query->withCount($this->normalizeRelation($relation));
        }
    }

    /**
     * Load the given count of relations to the query result.
     *
     * @param  string[]  $relations
     */
    public function withSum(EloquentBuilder $query, array $relations): void
    {
        foreach ($relations as $relation) {
            [$relation, $column] = array_pad(explode('-', $relation), 2, null);

            if (! $relation || ! $column) {
                throw new UnexpectedValueException('Cannot find the relation or column to sum');
            }

            $query->withSum($this->normalizeRelation($relation), $column);
        }
    }

    /**
     * Normalize the relation name or relations separated by dot notation.
     */
    protected function normalizeRelation(string $relation): string
    {
        return join('.', array_map(Str::camel(...), explode('.', $relation)));
    }

    /**
     * Load trashed models in the query.
     */
    public function trashed(EloquentBuilder $query, string $trashed): void
    {
        if (in_array(Str::lower($trashed), ['1', 'true', 'on']) && $query->hasNamedScope(SoftDeletingScope::class)) {
            // @phpstan-ignore-next-line
            $query->withTrashed();
        }
    }

    /**
     * Sort the query using the given column and order.
     *
     * @param  "asc"|"desc"  $direction
     */
    public function orderBy(EloquentBuilder $query, string $column, Request $request, string $direction = 'asc'): void
    {
        // Do not add another order if one is already defined.
        ! $query->getQuery()->orders && $query->orderBy($column, $direction);
    }

    /**
     * Sort the query using the given column and order.
     */
    public function orderByDesc(EloquentBuilder $query, string $column, Request $request): void
    {
        $this->orderBy($query, $column, $request, 'desc');
    }

    /**
     * Limit the query results by the given amount.
     */
    public function limit(EloquentBuilder $query, int $limit): void
    {
        // This will the limit between zero and the default model "perPage" configuration (15 by default).
        $query->limit(max(0, min($limit, $query->getModel()->getPerPage())));
    }

    /**
     * Limit the query results by the given amount. Alias for `limit`.
     *
     * @internal
     */
    public function perPage(EloquentBuilder $query, int $limit): void
    {
        $this->limit($query, $limit);
    }
}