diff --git a/kernel/EloquentDataProvider.php b/kernel/EloquentDataProvider.php new file mode 100644 index 0000000..d1ca335 --- /dev/null +++ b/kernel/EloquentDataProvider.php @@ -0,0 +1,482 @@ +query = $query; + } + + public function setPerPage(int $perPage): self + { + $this->perPage = max(1, $perPage); + return $this; + } + + public function setFilters(array $filters): self + { + $this->filters = $filters; + return $this; + } + + public function setSort(array $sort): self + { + $this->sort = $sort; + return $this; + } + + public function with(array $relations): self + { + $this->with = $relations; + return $this; + } + + public function withCount(array $relations): self + { + $this->withCount = $relations; + return $this; + } + + public function setSearch(array $search): self + { + $this->search = $search; + return $this; + } + + public function allowFilters(array $filters): self + { + $this->allowedFilters = $filters; + return $this; + } + + public function allowSorts(array $sorts): self + { + $this->allowedSorts = $sorts; + return $this; + } + + public function allowSearch(array $fields): self + { + $this->allowedSearch = $fields; + return $this; + } + + public function setDefaultSort(array $sort): self + { + $this->defaultSort = $sort; + return $this; + } + + public function beforeQuery(callable $callback): self + { + $this->beforeQueryCallbacks[] = $callback; + return $this; + } + + public function afterQuery(callable $callback): self + { + $this->afterQueryCallbacks[] = $callback; + return $this; + } + + protected function applyFilters(): void + { + foreach ($this->filters as $field => $value) { + if (!$this->isFilterAllowed($field)) { + continue; + } + + if (is_array($value)) { + $this->applyArrayFilter($field, $value); + } elseif (Str::contains($field, '.')) { + $this->applyRelationFilter($field, $value); + } elseif ($value !== null && $value !== '') { + $this->query->where($field, $value); + } + } + } + + protected function applyArrayFilter(string $field, array $value): void + { + $operator = strtolower($value[0] ?? null); + $operand = $value[1] ?? null; + + switch ($operator) { + case 'in': + $this->query->whereIn($field, (array)$operand); + break; + case 'not in': + $this->query->whereNotIn($field, (array)$operand); + break; + case 'between': + $this->query->whereBetween($field, (array)$operand); + break; + case 'not between': + $this->query->whereNotBetween($field, (array)$operand); + break; + case 'null': + $this->query->whereNull($field); + break; + case 'not null': + $this->query->whereNotNull($field); + break; + case 'like': + $this->query->where($field, 'like', "%{$operand}%"); + break; + case '>': + case '<': + case '>=': + case '<=': + case '!=': + $this->query->where($field, $operator, $operand); + break; + default: + $this->query->whereIn($field, $value); + } + } + + protected function applyRelationFilter(string $field, $value): void + { + [$relation, $column] = explode('.', $field, 2); + + $this->query->whereHas($relation, function ($query) use ($column, $value) { + if (is_array($value)) { + $query->whereIn($column, $value); + } else { + $query->where($column, $value); + } + }); + } + + protected function applySort(): void + { + if (empty($this->sort) && !empty($this->defaultSort)) { + $this->sort = $this->defaultSort; + } + + foreach ($this->sort as $field => $direction) { + if (!$this->isSortAllowed($field)) { + continue; + } + + $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; + + if (Str::contains($field, '.')) { + $this->applyRelationSort($field, $direction); + } else { + $this->query->orderBy($field, $direction); + } + } + } + + protected function applyRelationSort(string $field, string $direction): void + { + [$relation, $column] = explode('.', $field, 2); + + $this->query->with([$relation => function ($query) use ($column, $direction) { + $query->orderBy($column, $direction); + }]); + } + + protected function applySearch(): void + { + if (empty($this->search) || empty($this->allowedSearch)) { + return; + } + + $searchTerm = Arr::get($this->search, 'term', ''); + if (empty($searchTerm)) { + return; + } + + $this->query->where(function ($query) use ($searchTerm) { + foreach ($this->allowedSearch as $field) { + if (Str::contains($field, '.')) { + [$relation, $column] = explode('.', $field, 2); + $query->orWhereHas($relation, function ($q) use ($column, $searchTerm) { + $q->where($column, 'like', "%{$searchTerm}%"); + }); + } else { + $query->orWhere($field, 'like', "%{$searchTerm}%"); + } + } + }); + } + + protected function applyRelations(): void + { + if (!empty($this->with)) { + $this->query->with($this->with); + } + + if (!empty($this->withCount)) { + $this->query->withCount($this->withCount); + } + } + + protected function isFilterAllowed(string $field): bool + { + if (empty($this->allowedFilters)) { + return true; + } + + $baseField = Str::before($field, '.'); + + return in_array($field, $this->allowedFilters) || + in_array($baseField, $this->allowedFilters); + } + + protected function isSortAllowed(string $field): bool + { + if (empty($this->allowedSorts)) { + return true; + } + + $baseField = Str::before($field, '.'); + + return in_array($field, $this->allowedSorts) || + in_array($baseField, $this->allowedSorts); + } + + protected function executeCallbacks(array $callbacks): void + { + foreach ($callbacks as $callback) { + call_user_func($callback, $this->query); + } + } + + /** + * Получение данных с ручной пагинацией + */ + public function getManualPaginated(int $page = 1, int $perPage = null): array + { + $perPage = $perPage ?? $this->perPage; + + $this->applyRelations(); + $this->applyFilters(); + $this->applySearch(); + $this->applySort(); + + $total = $this->query->count(); + $results = $this->query + ->offset(($page - 1) * $perPage) + ->limit($perPage) + ->get(); + + return [ + 'data' => $results, + 'meta' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => ceil($total / $perPage), + ] + ]; + } + + public function getAll(): \Illuminate\Database\Eloquent\Collection + { + $this->executeCallbacks($this->beforeQueryCallbacks); + + $this->applyRelations(); + $this->applyFilters(); + $this->applySearch(); + $this->applySort(); + + $result = $this->query->get(); + + $this->executeCallbacks($this->afterQueryCallbacks); + + return $result; + } + + public function getFirst(): ?\Illuminate\Database\Eloquent\Model + { + $this->executeCallbacks($this->beforeQueryCallbacks); + + $this->applyRelations(); + $this->applyFilters(); + $this->applySearch(); + $this->applySort(); + + $result = $this->query->first(); + + $this->executeCallbacks($this->afterQueryCallbacks); + + return $result; + } + + /** + * Генерирует массив с основными ссылками пагинации + * + * @param array $meta Мета-информация из getManualPaginated() + * @param string|null $baseUrl Базовый URL (если null - определит автоматически) + * @return array + */ + public function getPaginationLinks(array $meta, ?string $baseUrl = null): array + { + $currentPage = $meta['current_page']; + $lastPage = $meta['last_page']; + + // Определяем базовый URL + $baseUrl = $this->normalizeBaseUrl($baseUrl); + + $links = [ + 'first' => null, + 'previous' => null, + 'current' => $this->buildPageUrl($baseUrl, $currentPage), + 'next' => null, + 'last' => null, + ]; + + // Первая страница (если не на первой) + if ($currentPage > 1) { + $links['first'] = $this->buildPageUrl($baseUrl, 1); + } + + // Предыдущая страница + if ($currentPage > 1) { + $links['previous'] = $this->buildPageUrl($baseUrl, $currentPage - 1); + } + + // Следующая страница + if ($currentPage < $lastPage) { + $links['next'] = $this->buildPageUrl($baseUrl, $currentPage + 1); + } + + // Последняя страница (если не на последней) + if ($currentPage < $lastPage) { + $links['last'] = $this->buildPageUrl($baseUrl, $lastPage); + } + + // Дополнительная мета-информация + $links['meta'] = [ + 'current_page' => $currentPage, + 'last_page' => $lastPage, + 'per_page' => $meta['per_page'], + 'total' => $meta['total'] + ]; + + return $links; + } + + /** + * Нормализует базовый URL + */ + protected function normalizeBaseUrl(?string $url): string + { + if ($url !== null) { + return $url; + } + + if (function_exists('url')) { + // Laravel + return url()->current(); + } + + // Чистый PHP + $protocol = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['SERVER_PORT'] ?? null) == 443) ? 'https://' : 'http://'; + + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + + // Удаляем параметр page если он есть + $uri = preg_replace('/([?&])page=[^&]*(&|$)/', '$1', $uri); + + return $protocol . $host . rtrim($uri, '?&'); + } + + /** + * Строит URL для конкретной страницы + */ + protected function buildPageUrl(string $baseUrl, int $page): string + { + if ($page <= 1) { + return $baseUrl; // Первая страница без параметра + } + + $separator = strpos($baseUrl, '?') === false ? '?' : '&'; + return $baseUrl . $separator . 'page=' . $page; + } + + public function renderPaginationLinks(array $meta, string $url, int $showPages = 5): string + { + $currentPage = $meta['current_page']; + $lastPage = $meta['last_page']; + + // Определяем диапазон страниц для отображения + $startPage = max(1, $currentPage - floor($showPages / 2)); + $endPage = min($lastPage, $startPage + $showPages - 1); + + $html = '