src/Support/QueryBuilderEngine.php
Copy Code
<?php
namespace GhostCompiler\LaravelQueryBuilder\Support;
use Carbon\CarbonImmutable;
use Closure;
use GhostCompiler\LaravelQueryBuilder\Contracts\Filter;
use GhostCompiler\LaravelQueryBuilder\Exceptions\IncludeDepthExceededException;
use GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidFilterException;
use GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidIncludeException;
use GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidQueryBuilderQuery;
use GhostCompiler\LaravelQueryBuilder\Exceptions\InvalidSortException;
use GhostCompiler\LaravelQueryBuilder\Exceptions\UnauthorizedRelationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use ReflectionException;
use ReflectionMethod;
use ReflectionProperty;
use stdClass;
use Throwable;
use WeakMap;
class QueryBuilderEngine
{
/**
* @var list<string>
*/
protected array $allowedRequestKeys = [
'search',
'filters',
'filter',
'sort_by',
'sort_dir',
'sort',
'page',
'per_page',
'date_from',
'date_to',
'date_column',
'columns',
'fields',
'with',
'include',
'trashed',
];
/**
* @var WeakMap<object, list<string>>|null
*/
protected static ?WeakMap $appliedSortRegistry = null;
/**
* @var WeakMap<object, array<string, mixed>>|null
*/
protected static ?WeakMap $requestRegistry = null;
/**
* @var WeakMap<object, array<string, mixed>>|null
*/
protected static ?WeakMap $responseRegistry = null;
/**
* @var array<string, array<int, string>>
*/
protected static array $columnCache = [];
/**
* @var array<string, ReflectionProperty|false>
*/
protected static array $reflectionPropertyCache = [];
/**
* @var array<string, ReflectionMethod|false>
*/
protected static array $reflectionMethodCache = [];
/**
* @var array<string, mixed>|null
*/
protected ?array $runtimeDefinition = null;
/**
* @var array<string, mixed>|null
*/
protected static ?array $legacyConfigDefaults = null;
/**
* @var array<string, mixed>|null
*/
protected static ?array $modernConfigDefaults = null;
public function apply(Builder $query, Request|array $request, array $response = []): Builder
{
$errors = [];
$params = $this->validatedRequestParams($request, $errors);
$response = $this->validatedResponseOptions($response, $errors);
$query = $this->applyColumnSelection($query, $params, $errors);
$query = $this->applySoftDeletes($query, $params, $errors);
$query = $this->applySearch($query, $params, $errors);
$query = $this->applyFilters($query, $params, $errors);
$query = $this->applyDateRange($query, $params, $errors);
$query = $this->applyEagerLoads($query, $params, $errors);
$query = $this->applySorting($query, $params, $errors);
$this->rememberRequestParams($query, $params);
$this->rememberResponseOptions($query, $response);
$this->throwIfStrict($query->getModel(), $errors);
return $query;
}
/**
* @param array<string, mixed> $definition
*/
public function applyWithDefinition(Builder $query, Request|array $request, array $definition = [], array $response = []): Builder
{
$previous = $this->runtimeDefinition;
$this->runtimeDefinition = $definition;
try {
return $this->apply($query, $request, $response);
} finally {
$this->runtimeDefinition = $previous;
}
}
public function paginate(Builder $query, Request|array|null $request = null): LengthAwarePaginator
{
$params = $this->resolveRequestParams($query, $request);
$page = max((int) ($params['page'] ?? 1), 1);
return $query->paginate(
$this->resolvePerPage($query->getModel(), $params),
['*'],
'page',
$page,
);
}
public function paginateTable(Builder $query, Request|array|null $request = null, array $response = []): array
{
$params = $this->resolveRequestParams($query, $request);
$responseOptions = $this->resolveResponseOptions($query, $response);
$paginator = $this->paginate($query, $params);
$appliedSorts = $this->appliedSortsFor($query)
?? $this->summarizeRequestedSorts($query->getModel(), $params);
$payload = [
$responseOptions['status_key'] => $responseOptions['status'],
'data' => $paginator->items(),
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
'has_more' => $paginator->hasMorePages(),
'links' => [
'first' => $paginator->url(1),
'last' => $paginator->url($paginator->lastPage()),
'prev' => $paginator->previousPageUrl(),
'next' => $paginator->nextPageUrl(),
],
],
'meta' => [
'search' => $params['search'] ?? null,
'applied_sorts' => $appliedSorts,
'applied_filters' => $this->summarizeFilters($params),
],
];
if (is_string($responseOptions['message']) && trim($responseOptions['message']) !== '') {
$payload[$responseOptions['message_key']] = $responseOptions['message'];
}
return $payload;
}
protected function applyColumnSelection(Builder $query, array $params, array &$errors): Builder
{
$model = $query->getModel();
$columns = $params['columns'] ?? $this->fieldsForModel($params, $model);
if ($columns === null || $columns === '') {
return $query;
}
$requested = $this->stringOrArrayToArray($columns);
$selectable = array_values(array_diff(
$this->modelArrayOption($model, 'selectable'),
$this->maskedColumns($model)
));
$tableColumns = $this->tableColumns($model);
if ($requested === [] || $tableColumns === []) {
return $query;
}
if ($selectable === []) {
$this->recordError($errors, 'columns', 'Column selection requires an explicit [$selectable] allow-list on the model.');
return $query;
}
$safeColumns = array_values(array_intersect($requested, $tableColumns, $selectable));
$keyName = $model->getKeyName();
if ($safeColumns === []) {
$this->recordError($errors, 'columns', 'No requested columns are allowed on this model.');
return $query;
}
$invalidColumns = array_values(array_diff($requested, $safeColumns));
foreach ($invalidColumns as $invalidColumn) {
$parameter = array_key_exists('fields', $params) ? 'fields' : 'columns';
$this->recordError($errors, $parameter, "Column [{$invalidColumn}] is not selectable.");
}
if (! in_array($keyName, $safeColumns, true)) {
$safeColumns[] = $keyName;
}
$query->select(array_map(
fn (string $column) => $this->qualify($query, $column),
$safeColumns
));
return $query;
}
protected function applySoftDeletes(Builder $query, array $params, array &$errors): Builder
{
$model = $query->getModel();
if (! $this->usesSoftDeletes($model)) {
if (($params['trashed'] ?? null) !== null) {
$this->recordError($errors, 'trashed', 'The model does not use soft deletes.');
}
return $query;
}
$deletedAtColumn = method_exists($model, 'getDeletedAtColumn')
? $model->getDeletedAtColumn()
: 'deleted_at';
$qualifiedDeletedAtColumn = $this->qualify($query, $deletedAtColumn);
$trashed = $params['trashed'] ?? null;
if ($trashed !== null && ! in_array($trashed, ['with', 'only'], true)) {
$this->recordError($errors, 'trashed', 'The trashed parameter must be either [with] or [only].');
}
$query = $query->withoutGlobalScope(SoftDeletingScope::class);
if ($trashed === 'with') {
return $query;
}
if ($trashed === 'only') {
return $query
->whereNotNull($qualifiedDeletedAtColumn);
}
return $query->whereNull($qualifiedDeletedAtColumn);
}
protected function applySearch(Builder $query, array $params, array &$errors): Builder
{
$term = trim((string) ($params['search'] ?? ''));
$searchable = $this->modelArrayOption($query->getModel(), 'searchable');
if ($term === '' || $searchable === []) {
return $query;
}
if (mb_strlen($term) < $this->minSearchLength()) {
$this->recordError($errors, 'search', 'The search term is shorter than the configured minimum length.');
return $query;
}
$model = $query->getModel();
$tableColumns = $this->tableColumns($model);
$query->where(function (Builder $nested) use ($searchable, $term, $tableColumns, &$errors): void {
foreach ($searchable as $field) {
if (! is_string($field) || $field === '') {
continue;
}
if (str_contains($field, '.')) {
if (! $this->isAllowedRelationDepth($field)) {
$this->recordError($errors, 'search', "Search field [{$field}] exceeds the configured maximum relation depth.");
continue;
}
$leafColumn = $this->leafColumn($field);
$this->whereHasNested(
$nested,
$field,
fn (Builder $relationQuery) => $relationQuery->where(
$leafColumn,
'LIKE',
$this->likePattern($term, $this->searchLikeMode())
),
true,
);
continue;
}
if ($tableColumns !== [] && ! in_array($field, $tableColumns, true)) {
$this->recordError($errors, 'search', "Search field [{$field}] is not a valid column.");
continue;
}
$nested->orWhere(
$this->qualify($nested, $field),
'LIKE',
$this->likePattern($term, $this->searchLikeMode())
);
}
});
return $query;
}
protected function applyFilters(Builder $query, array $params, array &$errors): Builder
{
$filters = $params['filters'] ?? [];
if (! is_array($filters) || $filters === []) {
return $query;
}
$filters = array_slice($filters, 0, $this->maxFilterCount(), true);
$model = $query->getModel();
$filterable = $this->modelArrayOption($model, 'filterable');
$customFilters = $this->customFilters($model);
$tableColumns = $this->tableColumns($model);
if ($filterable === [] && $customFilters === []) {
$this->recordError($errors, 'filters', 'Filtering requires an explicit [$filterable] or [$customFilters] allow-list on the model.');
return $query;
}
foreach ($filters as $field => $definition) {
if (! is_string($field) || $field === '') {
continue;
}
if ($this->isProtectedSoftDeleteFilter($model, $field)) {
$this->recordError($errors, "filters.{$field}", "Filtering by [{$field}] is reserved for the [trashed] query-builder command.");
continue;
}
$hasCustomFilter = array_key_exists($field, $customFilters);
if (! in_array($field, $filterable, true) && ! $hasCustomFilter) {
$this->recordError($errors, "filters.{$field}", "Filter [{$field}] is not allowed.");
continue;
}
if (! is_array($definition)) {
$definition = ['operator' => '=', 'value' => $definition];
}
$operator = strtolower((string) ($definition['operator'] ?? '='));
$value = $definition['value'] ?? null;
if (! in_array($operator, $this->operators(), true)) {
$this->recordError($errors, "filters.{$field}", "Operator [{$operator}] is not supported.");
continue;
}
if (! $this->isOperatorAllowedForField($model, $field, $operator)) {
$this->recordError($errors, "filters.{$field}", "Operator [{$operator}] is not allowed for [{$field}].");
continue;
}
if (in_array($operator, ['in', 'not_in'], true)) {
$values = $this->stringOrArrayToArray($value);
if (count($values) > $this->maxFilterValueCount()) {
$this->recordError(
$errors,
"filters.{$field}",
"Filter [{$field}] exceeds the maximum allowed list size of {$this->maxFilterValueCount()} values."
);
$value = array_slice($values, 0, $this->maxFilterValueCount());
}
}
if ($operator === 'between') {
$values = $this->stringOrArrayToArray($value);
if (count($values) !== 2) {
$this->recordError($errors, "filters.{$field}", "Filter [{$field}] with [between] requires exactly two values.");
continue;
}
}
if ($hasCustomFilter) {
if (! $this->applyCustomFilter($query, $model, $customFilters[$field], $field, $operator, $value, $definition, $errors)) {
$this->recordError($errors, "filters.{$field}", "Custom filter [{$field}] could not be resolved.");
}
continue;
}
$value = $this->normalizeFilterValue($model, $field, $operator, $value);
if (str_contains($field, '.')) {
if (! $this->isAllowedRelationDepth($field)) {
$this->recordError($errors, "filters.{$field}", "Filter [{$field}] exceeds the configured maximum relation depth.");
continue;
}
$leafColumn = $this->leafColumn($field);
$this->whereHasNested(
$query,
$field,
fn (Builder $relationQuery) => $this->applyOperator($relationQuery, $leafColumn, $operator, $value),
);
continue;
}
if ($tableColumns !== [] && ! in_array($field, $tableColumns, true)) {
$this->recordError($errors, "filters.{$field}", "Column [{$field}] is not filterable because it does not exist.");
continue;
}
$this->applyOperator($query, $this->qualify($query, $field), $operator, $value);
}
return $query;
}
protected function applyDateRange(Builder $query, array $params, array &$errors): Builder
{
$from = $params['date_from'] ?? null;
$to = $params['date_to'] ?? null;
if ($from === null && $to === null) {
return $query;
}
$model = $query->getModel();
$column = (string) ($params['date_column'] ?? 'created_at');
$tableColumns = $this->tableColumns($model);
$dateFilterable = $this->dateFilterableColumns($model);
if ($tableColumns !== [] && ! in_array($column, $tableColumns, true)) {
$this->recordError($errors, 'date_column', "Date column [{$column}] does not exist.");
return $query;
}
if ($dateFilterable !== [] && ! in_array($column, $dateFilterable, true)) {
$this->recordError($errors, 'date_column', "Date column [{$column}] is not allowed for date filtering.");
return $query;
}
$qualifiedColumn = $this->qualify($query, $column);
if ($from !== null) {
$fromDate = $this->parseDateBoundary((string) $from, true);
if ($fromDate !== null) {
$query->where($qualifiedColumn, '>=', $fromDate->toDateTimeString());
} else {
$this->recordError($errors, 'date_from', 'The date_from value could not be parsed.');
}
}
if ($to !== null) {
$toDate = $this->parseDateBoundary((string) $to, false);
if ($toDate !== null) {
$query->where($qualifiedColumn, '<=', $toDate->toDateTimeString());
} else {
$this->recordError($errors, 'date_to', 'The date_to value could not be parsed.');
}
}
return $query;
}
protected function applyEagerLoads(Builder $query, array $params, array &$errors): Builder
{
$requested = $params['with'] ?? [];
if (! is_array($requested)) {
$requested = $this->stringOrArrayToArray($requested);
}
if ($requested === []) {
return $query;
}
$model = $query->getModel();
$allowedRelations = $this->modelArrayOption($model, 'allowedRelations');
$safeRelations = [];
if ($allowedRelations === []) {
$this->recordError($errors, 'with', 'Eager loading requires an explicit [$allowedRelations] allow-list on the model.');
return $query;
}
foreach ($requested as $relation) {
if (! is_string($relation) || $relation === '') {
continue;
}
if (! $this->isAllowedRelationDepth($relation)) {
$this->recordError($errors, 'with', "Relation [{$relation}] exceeds the configured maximum relation depth.");
continue;
}
if (! in_array($relation, $allowedRelations, true)) {
$this->recordError($errors, 'with', "Relation [{$relation}] is not allowed for eager loading.");
continue;
}
if (! $this->relationExists($model, $relation)) {
$this->recordError($errors, 'with', "Relation [{$relation}] does not exist on the model.");
continue;
}
if (! $this->relationPolicyAllows($model, $relation)) {
if ($this->strictIncludes()) {
$this->recordError($errors, 'with', "Relation [{$relation}] is not authorized for eager loading.");
}
continue;
}
$safeRelations[] = $relation;
}
if ($safeRelations !== []) {
$query->with($safeRelations);
}
return $query;
}
protected function applySorting(Builder $query, array $params, array &$errors): Builder
{
$model = $query->getModel();
$hasRequestedSort = array_key_exists('sort_by', $params)
&& $this->stringOrArrayToArray($params['sort_by']) !== [];
$fields = $hasRequestedSort
? $this->stringOrArrayToArray($params['sort_by'])
: [];
$directions = $this->stringOrArrayToArray($params['sort_dir'] ?? $this->defaultSortDirection($model));
$sortable = $this->modelArrayOption($model, 'sortable');
$tableColumns = $this->tableColumns($model);
$joinRegistry = [];
$appliedSorts = [];
foreach ($fields as $index => $field) {
if (! is_string($field) || $field === '') {
continue;
}
if (! in_array($field, $sortable, true)) {
$this->recordError($errors, 'sort_by', "Sort field [{$field}] is not allowed.");
continue;
}
$direction = strtolower($directions[$index] ?? $directions[0] ?? $this->defaultSortDirection($model));
if (! in_array($direction, ['asc', 'desc'], true)) {
$this->recordError($errors, 'sort_dir', "Sort direction [{$direction}] is invalid.");
$direction = 'asc';
}
if (str_contains($field, '.')) {
if (count(explode('.', $field)) !== 2) {
$this->recordError($errors, 'sort_by', "Relation sort [{$field}] must be a one-level relation.");
continue;
}
if ($this->sortByRelation($query, $field, $direction, $joinRegistry)) {
$appliedSorts[] = "{$field}:{$direction}";
} else {
$this->recordError($errors, 'sort_by', "Relation sort [{$field}] could not be applied.");
}
continue;
}
if ($tableColumns !== [] && ! in_array($field, $tableColumns, true)) {
$this->recordError($errors, 'sort_by', "Sort column [{$field}] does not exist.");
continue;
}
$query->orderBy($this->qualify($query, $field), $direction);
$appliedSorts[] = "{$field}:{$direction}";
}
if ($appliedSorts === []) {
$fallbackField = $this->defaultSortBy($model);
$fallbackDirection = $this->defaultSortDirection($model);
if (
! str_contains($fallbackField, '.')
&& ($tableColumns === [] || in_array($fallbackField, $tableColumns, true))
&& ($sortable === [] || in_array($fallbackField, $sortable, true))
) {
$query->orderBy($this->qualify($query, $fallbackField), $fallbackDirection);
$appliedSorts[] = "{$fallbackField}:{$fallbackDirection}";
}
}
$this->rememberAppliedSorts($query, $appliedSorts);
return $query;
}
protected function sortByRelation(Builder $query, string $field, string $direction, array &$joinRegistry): bool
{
[$relationName, $leafColumn] = explode('.', $field, 2);
if (isset($joinRegistry[$relationName])) {
return false;
}
try {
$model = $query->getModel();
$relation = $this->resolveRelation($model, [$relationName]);
if ($relation === null) {
return false;
}
$relatedModel = $relation->getRelated();
$relatedColumns = $this->tableColumns($relatedModel);
if ($relatedColumns !== [] && ! in_array($leafColumn, $relatedColumns, true)) {
return false;
}
$baseTable = $this->baseTableReference($query);
if ($query->getQuery()->columns === null) {
$query->select($baseTable.'.*');
}
if ($relation instanceof BelongsTo) {
$alias = 'qb_sort_'.$relationName;
$joinRegistry[$relationName] = $alias;
$query
->leftJoin(
$relatedModel->getTable().' as '.$alias,
$baseTable.'.'.$relation->getForeignKeyName(),
'=',
$alias.'.'.$relation->getOwnerKeyName()
);
$this->applyNullsLastOrder($query, "{$alias}.{$leafColumn}", $direction);
return true;
}
$sortAlias = 'qb_sort_value_'.$relationName.'_'.$leafColumn;
$subquery = null;
if ($relation instanceof HasOne) {
$subquery = DB::table($relatedModel->getTable())
->select($leafColumn)
->whereColumn($relation->getForeignKeyName(), $baseTable.'.'.$model->getKeyName())
->limit(1);
}
if ($relation instanceof HasMany) {
$subquery = DB::table($relatedModel->getTable())
->select($leafColumn)
->whereColumn($relation->getForeignKeyName(), $baseTable.'.'.$model->getKeyName())
->orderBy($leafColumn, $direction)
->limit(1);
}
if ($relation instanceof BelongsToMany) {
$subquery = DB::table($relatedModel->getTable())
->select($relatedModel->getTable().'.'.$leafColumn)
->join(
$relation->getTable(),
$relation->getTable().'.'.$relation->getRelatedPivotKeyName(),
'=',
$relatedModel->getTable().'.'.$relatedModel->getKeyName()
)
->whereColumn(
$relation->getTable().'.'.$relation->getForeignPivotKeyName(),
$baseTable.'.'.$model->getKeyName()
)
->orderBy($relatedModel->getTable().'.'.$leafColumn, $direction)
->limit(1);
}
if ($subquery === null) {
return false;
}
$joinRegistry[$relationName] = true;
$query->selectSub($subquery, $sortAlias);
$this->applyNullsLastOrder($query, $sortAlias, $direction);
return true;
} catch (Throwable) {
return false;
}
}
protected function applyNullsLastOrder(Builder $query, string $column, string $direction): void
{
$wrappedColumn = $query->getQuery()->getGrammar()->wrap($column);
if ($query->getModel()->getConnection()->getDriverName() === 'pgsql') {
$query->orderByRaw("{$wrappedColumn} {$direction} NULLS LAST");
return;
}
$query
->orderByRaw("{$wrappedColumn} IS NULL")
->orderBy($column, $direction);
}
protected function applyOperator(Builder $query, string $column, string $operator, mixed $value): void
{
$value = is_array($value) && ! in_array($operator, ['in', 'not_in', 'between'], true)
? implode(',', array_map('strval', array_values($value)))
: $value;
match ($operator) {
'=', '!=', '<', '>', '<=', '>=' => $query->where($column, $operator, $value),
'like' => $query->where($column, 'LIKE', $this->likePattern((string) $value, $this->filterLikeMode())),
'not_like' => $query->where($column, 'NOT LIKE', $this->likePattern((string) $value, $this->filterLikeMode())),
'in' => $query->whereIn($column, $this->stringOrArrayToArray($value)),
'not_in' => $query->whereNotIn($column, $this->stringOrArrayToArray($value)),
'between' => $this->applyBetween($query, $column, $value),
'null' => $query->whereNull($column),
'not_null' => $query->whereNotNull($column),
default => null,
};
}
protected function applyBetween(Builder $query, string $column, mixed $value): void
{
$values = $this->stringOrArrayToArray($value);
if (count($values) !== 2) {
return;
}
$query->whereBetween($column, [$values[0], $values[1]]);
}
protected function whereHasNested(Builder $query, string $field, callable $callback, bool $useOrWhere = false): void
{
$relations = explode('.', $field);
array_pop($relations);
if ($relations === []) {
return;
}
$buildChain = function (Builder $builder, array $remainingRelations) use ($callback, &$buildChain): void {
$currentRelation = array_shift($remainingRelations);
if ($currentRelation === null) {
$callback($builder);
return;
}
try {
$builder->whereHas($currentRelation, function (Builder $relationQuery) use ($remainingRelations, $callback, $buildChain): void {
if ($remainingRelations === []) {
$callback($relationQuery);
return;
}
$buildChain($relationQuery, $remainingRelations);
});
} catch (Throwable) {
}
};
$firstRelation = array_shift($relations);
if ($firstRelation === '') {
return;
}
$method = $useOrWhere ? 'orWhereHas' : 'whereHas';
try {
$query->{$method}($firstRelation, function (Builder $relationQuery) use ($relations, $callback, $buildChain): void {
if ($relations === []) {
$callback($relationQuery);
return;
}
$buildChain($relationQuery, $relations);
});
} catch (Throwable) {
}
}
protected function resolveRelation(Model $model, array $chain): mixed
{
$current = $model;
$resolvedRelation = null;
foreach ($chain as $relationName) {
if (! method_exists($current, $relationName)) {
return null;
}
try {
$resolvedRelation = $current->{$relationName}();
if (! is_object($resolvedRelation) || ! method_exists($resolvedRelation, 'getRelated')) {
return null;
}
$current = $resolvedRelation->getRelated();
} catch (Throwable) {
return null;
}
}
return $resolvedRelation;
}
protected function summarizeFilters(array $params): array
{
$summary = [];
foreach (($params['filters'] ?? []) as $field => $definition) {
if (! is_string($field)) {
continue;
}
$summary['filters'][$field] = is_array($definition)
? (($definition['operator'] ?? '=').':'.($definition['value'] ?? ''))
: '=:'.$definition;
}
if (($params['date_from'] ?? null) !== null || ($params['date_to'] ?? null) !== null) {
$summary['date_range'] = [
'column' => $params['date_column'] ?? 'created_at',
'from' => $params['date_from'] ?? null,
'to' => $params['date_to'] ?? null,
];
}
if (($params['trashed'] ?? null) !== null) {
$summary['trashed'] = $params['trashed'];
}
if (($params['with'] ?? null) !== null) {
$summary['with'] = $this->stringOrArrayToArray($params['with']);
}
return $summary;
}
protected function summarizeRequestedSorts(Model $model, array $params): array
{
$fields = $this->stringOrArrayToArray($params['sort_by'] ?? $this->defaultSortBy($model));
$directions = $this->stringOrArrayToArray($params['sort_dir'] ?? $this->defaultSortDirection($model));
return array_map(
fn (string $field, int $index) => $field.':'.strtolower($directions[$index] ?? $directions[0] ?? $this->defaultSortDirection($model)),
$fields,
array_keys($fields),
);
}
protected function tableColumns(Model $model): array
{
$connection = $model->getConnectionName() ?? config('database.default', 'default');
$cacheKey = $connection.':'.$model->getTable();
if (! array_key_exists($cacheKey, static::$columnCache)) {
try {
static::$columnCache[$cacheKey] = Schema::connection($connection)->getColumnListing($model->getTable());
} catch (Throwable) {
static::$columnCache[$cacheKey] = [];
}
}
return static::$columnCache[$cacheKey];
}
protected function fieldsForModel(array $params, Model $model): array|string|null
{
$fields = $params['fields'] ?? null;
if (! is_array($fields)) {
return null;
}
$candidates = [
$model->getTable(),
class_basename($model),
strtolower(class_basename($model)),
];
foreach ($candidates as $candidate) {
if (array_key_exists($candidate, $fields)) {
return $fields[$candidate];
}
}
return null;
}
/**
* @return list<string>
*/
protected function maskedColumns(Model $model): array
{
if (! (bool) $this->configValue('mask_sensitive_columns', true)) {
return [];
}
$configured = $this->configValue('masked_columns', []);
if (! is_array($configured)) {
return [];
}
$columns = $configured[$model->getTable()] ?? $configured[get_class($model)] ?? [];
return array_values(array_filter(array_map(
static fn (mixed $column): string => trim((string) $column),
is_array($columns) ? $columns : [$columns],
), static fn (string $column): bool => $column !== ''));
}
protected function relationExists(Model $model, string $relation): bool
{
return $this->resolveRelation($model, explode('.', $relation)) !== null;
}
protected function relationPolicyAllows(Model $model, string $relation): bool
{
if (! (bool) $this->configValue('policy_aware_includes', true)) {
return true;
}
try {
$gate = Gate::getFacadeRoot();
if (! is_object($gate) || ! method_exists($gate, 'has') || ! $gate->has('viewRelation')) {
return true;
}
return Gate::allows('viewRelation', [$model, $relation]);
} catch (Throwable) {
return true;
}
}
protected function strictIncludes(): bool
{
return (bool) $this->configValue('strict_includes', true);
}
protected function normalizeParams(Request|array $request): array
{
if (! $request instanceof Request) {
return $this->normalizeJsonApiParams($request);
}
$params = $request->all();
$headerParams = $this->headerParams($request);
if ($headerParams === []) {
return $this->normalizeJsonApiParams($params);
}
if ($this->overrideRequestValuesWithHeaders()) {
return $this->normalizeJsonApiParams(array_replace_recursive($params, $headerParams));
}
return $this->normalizeJsonApiParams(array_replace_recursive($headerParams, $params));
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
protected function normalizeJsonApiParams(array $params): array
{
if (array_key_exists('filter', $params) && ! array_key_exists('filters', $params)) {
$params['filters'] = $params['filter'];
}
if (array_key_exists('include', $params) && ! array_key_exists('with', $params)) {
$params['with'] = $params['include'];
}
if (array_key_exists('sort', $params) && ! array_key_exists('sort_by', $params)) {
[$fields, $directions] = $this->normalizeJsonApiSort($params['sort']);
$params['sort_by'] = $fields;
$params['sort_dir'] = $directions;
}
if (isset($params['page']) && is_array($params['page'])) {
$page = $params['page'];
if (array_key_exists('size', $page) && ! array_key_exists('per_page', $params)) {
$params['per_page'] = $page['size'];
}
if (array_key_exists('number', $page)) {
$params['page'] = $page['number'];
} else {
unset($params['page']);
}
}
unset($params['filter'], $params['include'], $params['sort']);
return $params;
}
/**
* @return array{0: list<string>, 1: list<string>}
*/
protected function normalizeJsonApiSort(mixed $sort): array
{
$fields = [];
$directions = [];
foreach ($this->stringOrArrayToArray($sort) as $field) {
$direction = str_starts_with($field, '-') ? 'desc' : 'asc';
$field = ltrim($field, '-');
if ($field === '') {
continue;
}
$fields[] = $field;
$directions[] = $direction;
}
return [$fields, $directions];
}
protected function resolveRequestParams(Builder $query, Request|array|null $request = null): array
{
$errors = [];
$model = $query->getModel();
if ($request !== null) {
$validated = $this->validatedRequestParams($request, $errors);
$this->throwIfStrict($model, $errors);
return $validated;
}
$remembered = $this->rememberedRequestParams($query);
if ($remembered !== []) {
return $remembered;
}
if ($this->handleRequestAutomatically()) {
$validated = $this->validatedRequestParams(request(), $errors);
$this->throwIfStrict($model, $errors);
return $validated;
}
return [];
}
/**
* @param list<array{parameter: string, reason: string}> $errors
* @return array<string, mixed>
*/
protected function validatedRequestParams(Request|array $request, array &$errors): array
{
$params = $this->normalizeParams($request);
$validated = [];
foreach ($params as $key => $value) {
if (! is_string($key) || ! in_array($key, $this->allowedRequestKeys, true)) {
if (is_string($key)) {
$this->recordError($errors, $key, "The request key [{$key}] is not supported by the query builder.");
}
continue;
}
$validated[$key] = $value;
}
if (array_key_exists('search', $validated)) {
$validated['search'] = $this->validatedScalarStringValue('search', $validated['search'], $errors);
}
if (array_key_exists('sort_by', $validated)) {
$validated['sort_by'] = $this->validatedListInput('sort_by', $validated['sort_by'], $errors);
}
if (array_key_exists('sort_dir', $validated)) {
$validated['sort_dir'] = $this->validatedListInput('sort_dir', $validated['sort_dir'], $errors);
}
if (array_key_exists('columns', $validated)) {
$validated['columns'] = $this->validatedListInput('columns', $validated['columns'], $errors);
}
if (array_key_exists('fields', $validated)) {
$validated['fields'] = $this->validatedFields($validated['fields'], $errors);
}
if (array_key_exists('with', $validated)) {
$validated['with'] = $this->validatedListInput('with', $validated['with'], $errors, true);
}
if (array_key_exists('page', $validated)) {
$validated['page'] = $this->validatedPositiveInteger('page', $validated['page'], $errors);
}
if (array_key_exists('per_page', $validated)) {
$validated['per_page'] = $this->validatedPositiveInteger('per_page', $validated['per_page'], $errors);
}
if (array_key_exists('date_from', $validated)) {
$validated['date_from'] = $this->validatedScalarStringValue('date_from', $validated['date_from'], $errors);
}
if (array_key_exists('date_to', $validated)) {
$validated['date_to'] = $this->validatedScalarStringValue('date_to', $validated['date_to'], $errors);
}
if (array_key_exists('date_column', $validated)) {
$validated['date_column'] = $this->validatedScalarStringValue('date_column', $validated['date_column'], $errors);
}
if (array_key_exists('trashed', $validated)) {
$validated['trashed'] = $this->validatedScalarStringValue('trashed', $validated['trashed'], $errors);
}
if (array_key_exists('filters', $validated)) {
$validated['filters'] = $this->validatedFilters($validated['filters'], $errors);
}
return array_filter(
$validated,
static fn (mixed $value): bool => $value !== null
);
}
/**
* @param list<array{parameter: string, reason: string}> $errors
* @param array<string, mixed> $response
* @return array<string, mixed>
*/
protected function validatedResponseOptions(array $response, array &$errors): array
{
$validated = [];
if (array_key_exists('status_key', $response)) {
$statusKey = $this->validatedResponseKey('response.status_key', $response['status_key'], $errors);
if ($statusKey !== null) {
$validated['status_key'] = $statusKey;
}
}
if (array_key_exists('message_key', $response)) {
$messageKey = $this->validatedResponseKey('response.message_key', $response['message_key'], $errors);
if ($messageKey !== null) {
$validated['message_key'] = $messageKey;
}
}
if (array_key_exists('message', $response)) {
$message = $this->validatedScalarStringValue('response.message', $response['message'], $errors);
if ($message !== null) {
$validated['message'] = $message;
}
}
if (array_key_exists('status', $response)) {
$validated['status'] = $response['status'];
}
return $validated;
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function validatedScalarStringValue(string $parameter, mixed $value, array &$errors): ?string
{
if ($value === null) {
return null;
}
if (is_array($value) || is_object($value)) {
$this->recordError($errors, $parameter, "The [{$parameter}] value must be a scalar string value.");
return null;
}
return trim((string) $value);
}
/**
* @param list<array{parameter: string, reason: string}> $errors
* @return array<int, string>|string|null
*/
protected function validatedListInput(string $parameter, mixed $value, array &$errors, bool $forceArray = false): array|string|null
{
if ($value === null) {
return null;
}
if (is_array($value)) {
$list = [];
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
$this->recordError($errors, $parameter, "The [{$parameter}] list contains a non-scalar value.");
continue;
}
$item = trim((string) $item);
if ($item !== '') {
$list[] = $item;
}
}
return $list;
}
if (is_object($value)) {
$this->recordError($errors, $parameter, "The [{$parameter}] value must be a string or array.");
return null;
}
$stringValue = trim((string) $value);
if ($stringValue === '') {
return $forceArray ? [] : null;
}
return $forceArray ? array_values(array_filter(array_map('trim', explode(',', $stringValue)))) : $stringValue;
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function validatedPositiveInteger(string $parameter, mixed $value, array &$errors): ?int
{
if ($value === null || $value === '') {
return null;
}
if (is_array($value) || is_object($value) || filter_var($value, FILTER_VALIDATE_INT) === false) {
$this->recordError($errors, $parameter, "The [{$parameter}] value must be a valid integer.");
return null;
}
return max((int) $value, 1);
}
/**
* @param list<array{parameter: string, reason: string}> $errors
* @return array<string, mixed>
*/
protected function validatedFilters(mixed $filters, array &$errors): array
{
if (! is_array($filters)) {
$this->recordError($errors, 'filters', 'The [filters] value must be an associative array.');
return [];
}
$validated = [];
foreach ($filters as $field => $definition) {
if (! is_string($field) || trim($field) === '') {
$this->recordError($errors, 'filters', 'Each filter key must be a non-empty string.');
continue;
}
$field = trim($field);
if (! is_array($definition)) {
if (is_object($definition)) {
$this->recordError($errors, "filters.{$field}", 'Filter values must be scalar or an operator/value array.');
continue;
}
$validated[$field] = $definition;
continue;
}
$operator = $definition['operator'] ?? '=';
$value = $definition['value'] ?? null;
if (is_array($operator) || is_object($operator)) {
$this->recordError($errors, "filters.{$field}", 'Filter operator must be a scalar value.');
continue;
}
if (is_object($value)) {
$this->recordError($errors, "filters.{$field}", 'Filter value must be scalar or array.');
continue;
}
$validated[$field] = [
'operator' => strtolower(trim((string) $operator)),
'value' => $value,
];
}
return $validated;
}
/**
* @param list<array{parameter: string, reason: string}> $errors
* @return array<string, list<string>>
*/
protected function validatedFields(mixed $fields, array &$errors): array
{
if (! is_array($fields)) {
$this->recordError($errors, 'fields', 'The [fields] value must be an associative array.');
return [];
}
$validated = [];
foreach ($fields as $resource => $columns) {
if (! is_string($resource) || trim($resource) === '') {
$this->recordError($errors, 'fields', 'Each sparse fieldset key must be a non-empty resource name.');
continue;
}
$validated[trim($resource)] = $this->stringOrArrayToArray($columns);
}
return $validated;
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function validatedResponseKey(string $parameter, mixed $value, array &$errors): ?string
{
$key = $this->validatedScalarStringValue($parameter, $value, $errors);
if ($key === null || $key === '') {
return null;
}
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
$this->recordError($errors, $parameter, "The [{$parameter}] value must be a valid response key.");
return null;
}
return $key;
}
/**
* @return array<string, mixed>
*/
protected function headerParams(Request $request): array
{
if (! $this->queryHeadersEnabled()) {
return [];
}
$params = [];
foreach ($this->queryHeaderNames() as $parameter => $headerNames) {
$headerValue = $this->headerValue($request, $headerNames);
if ($headerValue === null) {
continue;
}
$params[$parameter] = $this->normalizeHeaderValue($parameter, $headerValue);
}
return $params;
}
/**
* @param array<int, string> $headerNames
*/
protected function headerValue(Request $request, array $headerNames): ?string
{
foreach ($headerNames as $headerName) {
$value = $request->headers->get($headerName);
if (! is_string($value)) {
continue;
}
$value = trim($value);
if ($value !== '') {
return $value;
}
}
return null;
}
protected function normalizeHeaderValue(string $parameter, string $value): mixed
{
return match ($parameter) {
'filters' => $this->decodedHeaderFilters($value),
'sort_by', 'sort_dir', 'columns', 'with' => $this->decodedHeaderList($value),
default => trim($value),
};
}
/**
* @return array<string, mixed>|string
*/
protected function decodedHeaderFilters(string $value): array|string
{
$decoded = $this->decodedJsonHeader($value);
if (is_array($decoded)) {
return $decoded;
}
$parsed = [];
parse_str($value, $parsed);
return $parsed !== [] ? $parsed : trim($value);
}
/**
* @return array<int, string>|string
*/
protected function decodedHeaderList(string $value): array|string
{
$decoded = $this->decodedJsonHeader($value);
if (is_array($decoded)) {
return array_values(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$decoded
), static fn (string $item): bool => $item !== ''));
}
return trim($value);
}
protected function decodedJsonHeader(string $value): mixed
{
$trimmed = trim($value);
if ($trimmed === '' || ! in_array($trimmed[0], ['{', '[', '"'], true)) {
return null;
}
$decoded = json_decode($trimmed, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
protected function stringOrArrayToArray(mixed $value): array
{
if (is_array($value)) {
return array_values(array_filter(array_map(
fn (mixed $item) => is_string($item) ? trim($item) : (string) $item,
$value
), fn (string $item) => $item !== ''));
}
if ($value === null || $value === '') {
return [];
}
return array_values(array_filter(array_map('trim', explode(',', (string) $value)), fn (string $item) => $item !== ''));
}
protected function modelArrayOption(Model $model, string $property): array
{
if ($this->runtimeDefinition !== null && array_key_exists($property, $this->runtimeDefinition)) {
return (array) $this->runtimeDefinition[$property];
}
return (array) $this->readModelProperty($model, $property, []);
}
protected function defaultSortBy(Model $model): string
{
$value = $this->readModelProperty($model, 'defaultSortBy');
if (is_string($value) && $value !== '') {
return $value;
}
return $model->getKeyName();
}
protected function defaultSortDirection(Model $model): string
{
$direction = $this->readModelProperty(
$model,
'defaultSortDir',
(string) $this->configValue('default_sort_direction', 'asc'),
);
return strtolower($direction) === 'desc' ? 'desc' : 'asc';
}
protected function resolvePerPage(Model $model, array $params): int
{
$default = (int) $this->readModelProperty(
$model,
'defaultPerPage',
(int) $this->configValue('default_per_page', 15),
);
$max = (int) $this->readModelProperty(
$model,
'maxPerPage',
(int) $this->configValue('max_per_page', 100),
);
return min(max((int) ($params['per_page'] ?? $default), 1), max($max, 1));
}
protected function minSearchLength(): int
{
return max((int) $this->configValue('min_search_length', 3), 1);
}
protected function maxFilterCount(): int
{
return max((int) $this->configValue('max_filter_count', 15), 1);
}
protected function maxRelationDepth(): int
{
$includeDepth = $this->configValue('max_include_depth', 3);
$relationDepth = $this->configValue('max_relation_depth', 3);
$defaultIncludeDepth = data_get($this->modernConfigDefaults(), 'max_include_depth', 3);
$defaultRelationDepth = data_get($this->legacyConfigDefaults(), 'max_relation_depth', 3);
if ((int) $includeDepth === (int) $defaultIncludeDepth && (int) $relationDepth !== (int) $defaultRelationDepth) {
return max((int) $relationDepth, 1);
}
return max((int) $includeDepth, 1);
}
protected function maxFilterValueCount(): int
{
return max((int) $this->configValue('max_filter_value_count', 100), 1);
}
/**
* @return list<string>
*/
protected function dateFilterableColumns(Model $model): array
{
$configured = $this->readModelProperty($model, 'dateFilterable');
if (is_array($configured) && $configured !== []) {
return array_values(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$configured
), static fn (string $value): bool => $value !== ''));
}
return $this->modelArrayOption($model, 'filterable');
}
protected function searchLikeMode(): string
{
return $this->validatedLikeMode((string) $this->configValue('search_like_mode', 'contains'));
}
protected function filterLikeMode(): string
{
return $this->validatedLikeMode((string) $this->configValue('filter_like_mode', 'contains'));
}
protected function operators(): array
{
return ['=', '!=', '<', '>', '<=', '>=', 'like', 'not_like', 'in', 'not_in', 'between', 'null', 'not_null'];
}
protected function usesSoftDeletes(Model $model): bool
{
return in_array(SoftDeletes::class, class_uses_recursive($model), true);
}
protected function isProtectedSoftDeleteFilter(Model $model, string $field): bool
{
if (! $this->usesSoftDeletes($model) || str_contains($field, '.')) {
return false;
}
$deletedAtColumn = method_exists($model, 'getDeletedAtColumn')
? $model->getDeletedAtColumn()
: 'deleted_at';
return $field === $deletedAtColumn;
}
protected function validatedLikeMode(string $mode): string
{
$mode = strtolower(trim($mode));
return in_array($mode, ['contains', 'starts_with', 'ends_with', 'exact'], true)
? $mode
: 'contains';
}
protected function likePattern(string $value, string $mode): string
{
return match ($mode) {
'starts_with' => $value.'%',
'ends_with' => '%'.$value,
'exact' => $value,
default => '%'.$value.'%',
};
}
protected function normalizeFilterValue(Model $model, string $field, string $operator, mixed $value): mixed
{
if (! $this->shouldNormalizeBooleanFilter($model, $field, $operator)) {
return $value;
}
if (in_array($operator, ['in', 'not_in'], true)) {
return array_map(
fn (mixed $item) => $this->normalizeBooleanLikeValue($item),
$this->stringOrArrayToArray($value),
);
}
return $this->normalizeBooleanLikeValue($value);
}
protected function shouldNormalizeBooleanFilter(Model $model, string $field, string $operator): bool
{
if (! in_array($operator, ['=', '!=', 'in', 'not_in'], true)) {
return false;
}
$booleanFilterable = $this->modelArrayOption($model, 'booleanFilterable');
if (in_array($field, $booleanFilterable, true)) {
return true;
}
if (str_contains($field, '.')) {
$fieldModel = $this->fieldModel($model, $field);
if ($fieldModel === null) {
return false;
}
$castField = $this->leafColumn($field);
$casts = $fieldModel->getCasts();
$cast = strtolower((string) ($casts[$castField] ?? ''));
return in_array($cast, ['bool', 'boolean'], true);
}
$casts = $model->getCasts();
$cast = strtolower((string) ($casts[$field] ?? ''));
return in_array($cast, ['bool', 'boolean'], true);
}
protected function normalizeBooleanLikeValue(mixed $value): mixed
{
if (is_bool($value)) {
return $value;
}
if (is_string($value)) {
$normalized = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
return $normalized ?? $value;
}
if (is_int($value)) {
return match ($value) {
1 => true,
0 => false,
default => $value,
};
}
return $value;
}
protected function parseDateBoundary(string $value, bool $startOfDay): ?CarbonImmutable
{
try {
$date = CarbonImmutable::parse($value);
return $startOfDay ? $date->startOfDay() : $date->endOfDay();
} catch (Throwable) {
return null;
}
}
protected function isAllowedRelationDepth(string $relation): bool
{
return count(explode('.', $relation)) <= $this->maxRelationDepth();
}
protected function leafColumn(string $field): string
{
$parts = explode('.', $field);
return (string) end($parts);
}
protected function qualify(Builder $query, string $column): string
{
return $this->baseTableReference($query).'.'.$column;
}
protected function baseTableReference(Builder $query): string
{
$from = $query->getQuery()->from;
if (! is_string($from) || trim($from) === '') {
return $query->getModel()->getTable();
}
$normalized = preg_replace('/\s+/', ' ', trim($from)) ?? trim($from);
if (preg_match('/\s+as\s+([^\s]+)$/i', $normalized, $matches) === 1) {
return trim($matches[1], "`\"'");
}
if (preg_match('/^\S+\s+([^\s]+)$/', $normalized, $matches) === 1 && ! str_starts_with($normalized, '(')) {
return trim($matches[1], "`\"'");
}
return trim($normalized, "`\"'");
}
protected function strictMode(Model $model): bool
{
return (bool) $this->readModelProperty(
$model,
'queryBuilderStrict',
(bool) $this->configValue('strict_mode', false),
);
}
protected function handleRequestAutomatically(): bool
{
return (bool) $this->configValue('handle_request_automatically', true);
}
protected function queryHeadersEnabled(): bool
{
return (bool) $this->configValue('query_headers.enabled', false);
}
protected function overrideRequestValuesWithHeaders(): bool
{
return (bool) $this->configValue('query_headers.override_request_values', true);
}
/**
* @return array<string, array<int, string>>
*/
protected function queryHeaderNames(): array
{
$configured = $this->configValue('query_headers.names', []);
if (! is_array($configured)) {
return [];
}
$headers = [];
foreach ($configured as $parameter => $names) {
if (! is_string($parameter) || ! in_array($parameter, $this->allowedRequestKeys, true)) {
continue;
}
$normalized = array_values(array_filter(array_map(
static fn (mixed $name): string => trim((string) $name),
is_array($names) ? $names : [$names],
), static fn (string $name): bool => $name !== ''));
if ($normalized !== []) {
$headers[$parameter] = $normalized;
}
}
return $headers;
}
/**
* @param array<string, mixed> $response
* @return array{status_key: string, status: mixed, message_key: string, message: mixed}
*/
protected function resolveResponseOptions(Builder $query, array $response = []): array
{
$defaults = [
'status_key' => (string) $this->configValue('response.status_key', 'status'),
'status' => $this->configValue('response.status_value', true),
'message_key' => (string) $this->configValue('response.message_key', 'message'),
'message' => null,
];
$remembered = $this->responseRegistry()[$this->registryKey($query)] ?? [];
return array_replace($defaults, $remembered, $response);
}
/**
* @return array<string, array<int, string>>
*/
protected function allowedFilterOperators(Model $model): array
{
return (array) $this->readModelProperty($model, 'allowedFilterOperators', []);
}
/**
* @return array<string, mixed>
*/
protected function customFilters(Model $model): array
{
return (array) $this->readModelProperty($model, 'customFilters', []);
}
protected function isOperatorAllowedForField(Model $model, string $field, string $operator): bool
{
$allowedOperators = $this->allowedFilterOperators($model);
if (! array_key_exists($field, $allowedOperators)) {
return true;
}
return in_array($operator, (array) $allowedOperators[$field], true);
}
protected function applyCustomFilter(
Builder $query,
Model $model,
mixed $callback,
string $field,
string $operator,
mixed $value,
array $definition,
array &$errors,
): bool {
$resolved = $this->resolveCustomFilterCallable($model, $callback);
if ($resolved === null) {
return false;
}
try {
$resolved($query, $value, $operator, $field, $definition);
return true;
} catch (Throwable $exception) {
$this->recordError($errors, "filters.{$field}", 'Custom filter failed: '.$exception->getMessage());
return false;
}
}
protected function resolveCustomFilterCallable(Model $model, mixed $callback): ?Closure
{
if (is_string($callback) && method_exists($model, $callback)) {
return function (...$arguments) use ($model, $callback): mixed {
return $this->invokeModelMethod($model, $callback, $arguments);
};
}
if (is_string($callback) && class_exists($callback)) {
$instance = app($callback);
if ($instance instanceof Filter) {
return static function (Builder $query, mixed $value) use ($instance): mixed {
return $instance->apply($query, $value);
};
}
}
if ($callback instanceof Filter) {
return static function (Builder $query, mixed $value) use ($callback): mixed {
return $callback->apply($query, $value);
};
}
if ($callback instanceof Closure) {
return $callback;
}
if (is_callable($callback)) {
return Closure::fromCallable($callback);
}
return null;
}
protected function invokeModelMethod(Model $model, string $method, array $arguments = []): mixed
{
try {
$reflection = $this->reflectionMethod($model, $method);
if (! $reflection instanceof ReflectionMethod) {
return null;
}
$reflection->setAccessible(true);
return $reflection->invokeArgs($model, $arguments);
} catch (ReflectionException) {
return null;
}
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function throwIfStrict(Model $model, array $errors): void
{
if (! $this->strictMode($model) || $errors === []) {
return;
}
throw $this->exceptionForErrors($errors);
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function exceptionForErrors(array $errors): InvalidQueryBuilderQuery
{
$first = $errors[0] ?? ['parameter' => '', 'reason' => ''];
$parameter = (string) $first['parameter'];
$reason = strtolower((string) $first['reason']);
if (str_starts_with($parameter, 'filters')) {
return new InvalidFilterException($errors);
}
if (in_array($parameter, ['sort_by', 'sort_dir'], true)) {
return new InvalidSortException($errors);
}
if ($parameter === 'with') {
if (str_contains($reason, 'maximum relation depth')) {
return new IncludeDepthExceededException($errors);
}
if (str_contains($reason, 'not authorized')) {
return new UnauthorizedRelationException($errors);
}
return new InvalidIncludeException($errors);
}
return new InvalidQueryBuilderQuery($errors);
}
/**
* @param list<array{parameter: string, reason: string}> $errors
*/
protected function recordError(array &$errors, string $parameter, string $reason): void
{
$errors[] = [
'parameter' => $parameter,
'reason' => $reason,
];
}
/**
* @param array<string, mixed> $params
*/
protected function rememberRequestParams(Builder $query, array $params): void
{
$this->requestRegistry()[$this->registryKey($query)] = $params;
}
protected function rememberedRequestParams(Builder $query): array
{
return $this->requestRegistry()[$this->registryKey($query)] ?? [];
}
/**
* @param array<string, mixed> $response
*/
protected function rememberResponseOptions(Builder $query, array $response): void
{
if ($response === []) {
return;
}
$this->responseRegistry()[$this->registryKey($query)] = $response;
}
/**
* @param list<string> $appliedSorts
*/
protected function rememberAppliedSorts(Builder $query, array $appliedSorts): void
{
$this->appliedSortRegistry()[$this->registryKey($query)] = $appliedSorts;
}
/**
* @return list<string>|null
*/
protected function appliedSortsFor(Builder $query): ?array
{
return $this->appliedSortRegistry()[$this->registryKey($query)] ?? null;
}
protected function registryKey(Builder $query): object
{
return $query;
}
/**
* @return WeakMap<object, list<string>>
*/
protected function appliedSortRegistry(): WeakMap
{
return static::$appliedSortRegistry ??= new WeakMap;
}
/**
* @return WeakMap<object, array<string, mixed>>
*/
protected function requestRegistry(): WeakMap
{
return static::$requestRegistry ??= new WeakMap;
}
/**
* @return WeakMap<object, array<string, mixed>>
*/
protected function responseRegistry(): WeakMap
{
return static::$responseRegistry ??= new WeakMap;
}
protected function configValue(string $key, mixed $default = null): mixed
{
$missing = new stdClass;
$legacy = config('query-builder.'.$key, $missing);
$modern = config('querybuilder.'.$key, $missing);
$legacyDefault = data_get($this->legacyConfigDefaults(), $key, $missing);
$modernDefault = data_get($this->modernConfigDefaults(), $key, $missing);
if ($legacy !== $missing && $legacyDefault !== $missing && $legacy !== $legacyDefault) {
return $legacy;
}
if ($modern !== $missing && $modernDefault !== $missing && $modern !== $modernDefault) {
return $modern;
}
if ($modern !== $missing) {
return $modern;
}
if ($legacy !== $missing) {
return $legacy;
}
return $default;
}
/**
* @return array<string, mixed>
*/
protected function legacyConfigDefaults(): array
{
return static::$legacyConfigDefaults ??= require __DIR__.'/../../config/query-builder.php';
}
/**
* @return array<string, mixed>
*/
protected function modernConfigDefaults(): array
{
return static::$modernConfigDefaults ??= require __DIR__.'/../../config/querybuilder.php';
}
protected function readModelProperty(Model $model, string $property, mixed $default = null): mixed
{
if ($this->runtimeDefinition !== null && array_key_exists($property, $this->runtimeDefinition)) {
return $this->runtimeDefinition[$property];
}
$reflection = $this->reflectionProperty($model, $property);
if (! $reflection instanceof ReflectionProperty) {
return $default;
}
try {
return $reflection->isInitialized($model)
? $reflection->getValue($model)
: $default;
} catch (ReflectionException) {
return $default;
}
}
protected function fieldModel(Model $model, string $field): ?Model
{
if (! str_contains($field, '.')) {
return $model;
}
$relations = explode('.', $field);
array_pop($relations);
$relation = $this->resolveRelation($model, $relations);
return $relation?->getRelated();
}
protected function reflectionProperty(Model $model, string $property): ReflectionProperty|false
{
$cacheKey = get_class($model).'::'.$property;
if (array_key_exists($cacheKey, static::$reflectionPropertyCache)) {
return static::$reflectionPropertyCache[$cacheKey];
}
try {
return static::$reflectionPropertyCache[$cacheKey] = new ReflectionProperty($model, $property);
} catch (ReflectionException) {
return static::$reflectionPropertyCache[$cacheKey] = false;
}
}
protected function reflectionMethod(Model $model, string $method): ReflectionMethod|false
{
$cacheKey = get_class($model).'::'.$method;
if (array_key_exists($cacheKey, static::$reflectionMethodCache)) {
return static::$reflectionMethodCache[$cacheKey];
}
try {
return static::$reflectionMethodCache[$cacheKey] = new ReflectionMethod($model, $method);
} catch (ReflectionException) {
return static::$reflectionMethodCache[$cacheKey] = false;
}
}
}