584 lines
16 KiB
PHP
584 lines
16 KiB
PHP
<?php
|
||
|
||
namespace app\mzl\common\utils;
|
||
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Illuminate\Support\Collection;
|
||
|
||
/**
|
||
* 通用查询构建器 SimpleQueryBuilder
|
||
*/
|
||
class SimpleQueryBuilder
|
||
{
|
||
protected ?Model $model = null;
|
||
protected ?Builder $query = null;
|
||
|
||
protected array $filters = [];
|
||
protected array $sorts = [];
|
||
protected array $fields = [];
|
||
protected array $relations = [];
|
||
protected array $hasRelations = [];
|
||
protected array $pagination = [
|
||
'page' => 1,
|
||
'per_page' => 10,
|
||
];
|
||
|
||
/** @var string[] 要执行 withCount 的关联 */
|
||
protected array $counts = [];
|
||
|
||
public array $allowedFields = [];
|
||
|
||
protected ?\Closure $customQueryCallback = null;
|
||
|
||
public function __construct(?Model $model = null)
|
||
{
|
||
if ($model) {
|
||
$this->setModel($model);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置模型
|
||
*/
|
||
public function setModel(Model $model): self
|
||
{
|
||
$this->model = $model;
|
||
$this->query = $model->newQuery();
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 批量应用查询配置
|
||
*
|
||
* @param array $config
|
||
* @return $this
|
||
*/
|
||
public function applyQueryConfig(array $config): self
|
||
{
|
||
foreach ($config['filters'] ?? [] as $filter) {
|
||
$this->addFilter($filter['field'], $filter['operator'], $filter['value']);
|
||
}
|
||
|
||
foreach ($config['sorts'] ?? [] as $sort) {
|
||
$this->addSort($sort['field'], $sort['direction'] ?? 'asc');
|
||
}
|
||
|
||
if (!empty($config['fields'])) {
|
||
$this->selectFields($config['fields']);
|
||
}
|
||
|
||
foreach ($config['relations'] ?? [] as $relation => $fields) {
|
||
$this->withRelation($relation, $fields);
|
||
}
|
||
|
||
foreach ($config['hasRelations'] ?? [] as $relation => $callback) {
|
||
if ($callback instanceof \Closure) {
|
||
$this->addHasRelation($relation, $callback);
|
||
}
|
||
}
|
||
|
||
// 新增:处理 counts
|
||
foreach ($config['counts'] ?? [] as $relation) {
|
||
$this->addCount($relation);
|
||
}
|
||
|
||
if (!empty($config['pagination'])) {
|
||
$this->setPagination(
|
||
$config['pagination']['page'] ?? 1,
|
||
$config['pagination']['per_page'] ?? 15
|
||
);
|
||
}
|
||
|
||
if (!empty($config['custom']) && $config['custom'] instanceof \Closure) {
|
||
$this->withCustomQuery($config['custom']);
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 添加单个过滤条件
|
||
*/
|
||
public function addFilter(string $field, string $operator, mixed $value): self
|
||
{
|
||
$this->filters[] = compact('field', 'operator', 'value');
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 添加排序字段
|
||
*/
|
||
public function addSort(string $field, string $direction = 'asc'): self
|
||
{
|
||
$this->sorts[$field] = strtolower($direction) === 'desc' ? 'desc' : 'asc';
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 设置查询字段(select),可配合 allowedFields 校验
|
||
*/
|
||
public function selectFields(array $fields): self
|
||
{
|
||
$this->fields = array_unique($fields);
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 加载关联(支持指定字段)
|
||
*/
|
||
public function withRelation2(string $relation, array $fields = []): self
|
||
{
|
||
$this->relations[$relation] = !empty($fields) ? $fields : '*';
|
||
return $this;
|
||
}
|
||
/**
|
||
* 加载关联(支持指定字段 或 自定义闭包)
|
||
*
|
||
* @param string $relation 关联名,支持点式多级
|
||
* @param array|\Closure $fields 字段数组 或 查询闭包
|
||
*/
|
||
public function withRelation(string $relation, $fields = []): self
|
||
{
|
||
if ($fields instanceof \Closure) {
|
||
// 自定义关联查询闭包
|
||
$this->relations[$relation] = $fields;
|
||
} else {
|
||
// 字段选择,空数组表示全部
|
||
$this->relations[$relation] = !empty($fields) ? $fields : '*';
|
||
}
|
||
return $this;
|
||
}
|
||
|
||
|
||
/**
|
||
* 添加 whereHas 条件
|
||
*/
|
||
public function addHasRelation(string $relation, \Closure $callback): self
|
||
{
|
||
$this->hasRelations[] = compact('relation', 'callback');
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 设置分页
|
||
*/
|
||
public function setPagination(int $page, int $perPage): self
|
||
{
|
||
$this->pagination = [
|
||
'page' => max(1, $page),
|
||
'per_page' => min(max(1, $perPage), 100),
|
||
];
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 注入复杂查询闭包(适合业务层扩展)
|
||
*/
|
||
public function withCustomQuery(\Closure $callback): self
|
||
{
|
||
$this->customQueryCallback = $callback;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 添加关联数量统计 (withCount)
|
||
*
|
||
* @param string $relation 关联名,支持点式多层,如 "orders.items"
|
||
* @return $this
|
||
*/
|
||
public function addCount(string $relation): self
|
||
{
|
||
$this->counts[] = $relation;
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 构建完整查询(不执行)
|
||
*/
|
||
public function build(): Builder
|
||
{
|
||
if (!$this->query && $this->model) {
|
||
$this->query = $this->model->newQuery();
|
||
}
|
||
|
||
$this->applyFilters()
|
||
->applySorts()
|
||
->applyFields()
|
||
->applyRelations()
|
||
->applyWhereHas();
|
||
|
||
// ========== 新增:关联计数 ==========
|
||
if (!empty($this->counts)) {
|
||
// Laravel 支持 withCount(['rel1','rel2'])
|
||
$this->query->withCount($this->counts);
|
||
}
|
||
|
||
if ($this->customQueryCallback) {
|
||
call_user_func($this->customQueryCallback, $this->query);
|
||
}
|
||
|
||
return $this->query;
|
||
}
|
||
|
||
/**
|
||
* 获取集合(不分页)
|
||
*/
|
||
public function get(): Collection
|
||
{
|
||
return $this->build()->get();
|
||
}
|
||
|
||
/**
|
||
* 获取分页结果
|
||
*/
|
||
public function paginate(): LengthAwarePaginator
|
||
{
|
||
return $this->build()->paginate(
|
||
$this->pagination['per_page'],
|
||
['*'],
|
||
'page',
|
||
$this->pagination['page']
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 应用过滤
|
||
*/
|
||
protected function applyFilters(): self
|
||
{
|
||
foreach ($this->filters as $filter) {
|
||
$field = $filter['field'];
|
||
$operator = $filter['operator'];
|
||
$value = $filter['value'];
|
||
|
||
if (str_contains($field, '.')) {
|
||
$segments = explode('.', $field);
|
||
$relationPath = implode('.', array_slice($segments, 0, -1));
|
||
$lastField = end($segments);
|
||
|
||
$this->query->whereHas($relationPath, function (Builder $q) use ($lastField, $operator, $value) {
|
||
$this->applyCondition($q, $lastField, $operator, $value);
|
||
});
|
||
} else {
|
||
$this->applyCondition($this->query, $field, $operator, $value);
|
||
}
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 应用排序
|
||
*/
|
||
protected function applySorts(): self
|
||
{
|
||
foreach ($this->sorts as $field => $direction) {
|
||
$this->query->orderBy($field, $direction);
|
||
}
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 应用字段选择
|
||
*/
|
||
// protected function applyFields2(): self
|
||
// {
|
||
// if (!empty($this->fields)) {
|
||
// // 排除关联字段(如 class.teacher.realname)
|
||
// $fields = array_filter($this->fields, fn($field) => !str_contains($field, '.'));
|
||
|
||
// // 白名单过滤
|
||
// $fields = !empty($this->allowedFields)
|
||
// ? array_intersect($fields, $this->allowedFields)
|
||
// : $fields;
|
||
|
||
// if (empty($fields)) {
|
||
// throw new \InvalidArgumentException('字段选择无效');
|
||
// }
|
||
|
||
// $this->query->select(array_unique($fields));
|
||
// }
|
||
|
||
// return $this;
|
||
// }
|
||
|
||
/**
|
||
* 应用字段选择,支持普通列和带 alias 的 Raw 表达式
|
||
*/
|
||
protected function applyFields(): self
|
||
{
|
||
if (empty($this->fields)) {
|
||
return $this;
|
||
}
|
||
|
||
// 只保留主表字段(排除带点的关联字段)
|
||
$cols = array_filter($this->fields, fn($f) => ! str_contains($f, '.'));
|
||
|
||
// 如果设置了 allowedFields,做一次白名单交集
|
||
if (! empty($this->allowedFields)) {
|
||
$cols = array_intersect($cols, $this->allowedFields);
|
||
}
|
||
|
||
if (empty($cols)) {
|
||
throw new \InvalidArgumentException('字段选择无效');
|
||
}
|
||
|
||
$plain = [];
|
||
$raws = [];
|
||
|
||
foreach ($cols as $col) {
|
||
// 简单判断带 “ as ” 的就归到 Raw
|
||
if (preg_match('/\s+as\s+/i', $col)) {
|
||
$raws[] = $col;
|
||
} else {
|
||
$plain[] = $col;
|
||
}
|
||
}
|
||
|
||
// 先 select 普通列
|
||
if ($plain) {
|
||
$this->query->select($plain);
|
||
}
|
||
// 再 selectRaw alias
|
||
if ($raws) {
|
||
// 注意:selectRaw 接受一整个字符串
|
||
$this->query->selectRaw(implode(', ', $raws));
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 应用关联加载,支持 dot-notation 多级关联
|
||
*/
|
||
// protected function applyRelations2(): self
|
||
// {
|
||
// if (empty($this->relations)) {
|
||
// return $this;
|
||
// }
|
||
|
||
// $with = [];
|
||
// foreach ($this->relations as $path => $fields) {
|
||
// $with[$path] = function ($q) use ($fields) {
|
||
// if ($fields === '*' || empty($fields)) {
|
||
// return;
|
||
// }
|
||
// // 保证 id 在内
|
||
// $model = $q->getModel();
|
||
// $key = $model->getKeyName();
|
||
// $q->select(array_merge([$key], $fields));
|
||
// };
|
||
// }
|
||
|
||
// $this->query->with($with);
|
||
|
||
// return $this;
|
||
// }
|
||
|
||
/**
|
||
* 应用多级关联加载,支持给关联字段加 alias
|
||
*/
|
||
protected function applyRelations2(): self
|
||
{
|
||
if (empty($this->relations)) {
|
||
return $this;
|
||
}
|
||
|
||
$with = [];
|
||
foreach ($this->relations as $path => $fields) {
|
||
$with[$path] = function ($q) use ($fields) {
|
||
if ($fields === '*' || empty($fields)) {
|
||
return;
|
||
}
|
||
|
||
// 模型主键一定要带上
|
||
$model = $q->getModel();
|
||
$key = $model->getKeyName();
|
||
|
||
$plain = [];
|
||
$raws = [];
|
||
|
||
foreach ($fields as $col) {
|
||
if (preg_match('/\s+as\s+/i', $col)) {
|
||
$raws[] = $col;
|
||
} else {
|
||
$plain[] = $col;
|
||
}
|
||
}
|
||
|
||
// 确保主键至少出现一次
|
||
if (! in_array($key, $plain, true)) {
|
||
array_unshift($plain, $key);
|
||
}
|
||
|
||
// select 普通列
|
||
if ($plain) {
|
||
$q->select($plain);
|
||
}
|
||
// selectRaw 带 alias 的列
|
||
if ($raws) {
|
||
$q->selectRaw(implode(', ', $raws));
|
||
}
|
||
};
|
||
}
|
||
|
||
$this->query->with($with);
|
||
return $this;
|
||
}
|
||
/**
|
||
* 应用多级关联加载,支持字段数组或直接传入闭包
|
||
*/
|
||
protected function applyRelations(): self
|
||
{
|
||
if (empty($this->relations)) {
|
||
return $this;
|
||
}
|
||
|
||
$with = [];
|
||
foreach ($this->relations as $path => $handler) {
|
||
if ($handler instanceof \Closure) {
|
||
// 直接使用用户传入的闭包
|
||
$with[$path] = $handler;
|
||
} else {
|
||
// 按字段数组构造查询
|
||
$fields = $handler;
|
||
$with[$path] = function ($q) use ($fields) {
|
||
if ($fields === '*' || empty($fields)) {
|
||
return;
|
||
}
|
||
// 确保主键被选中
|
||
$model = $q->getModel();
|
||
$key = $model->getKeyName();
|
||
|
||
$plain = [];
|
||
$raws = [];
|
||
foreach ($fields as $col) {
|
||
if (preg_match('/\s+as\s+/i', $col)) {
|
||
$raws[] = $col;
|
||
} else {
|
||
$plain[] = $col;
|
||
}
|
||
}
|
||
if (! in_array($key, $plain, true)) {
|
||
array_unshift($plain, $key);
|
||
}
|
||
|
||
if ($plain) {
|
||
$q->select($plain);
|
||
}
|
||
if ($raws) {
|
||
$q->selectRaw(implode(', ', $raws));
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
$this->query->with($with);
|
||
return $this;
|
||
}
|
||
|
||
|
||
/**
|
||
* 应用 whereHas 关联过滤
|
||
*/
|
||
protected function applyWhereHas(): self
|
||
{
|
||
foreach ($this->hasRelations as $relationData) {
|
||
$this->query->whereHas($relationData['relation'], $relationData['callback']);
|
||
}
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* 应用单个过滤条件
|
||
*/
|
||
protected function applyCondition(Builder $query, string $field, string $operator, mixed $value): void
|
||
{
|
||
switch (strtolower($operator)) {
|
||
case 'between':
|
||
$query->whereBetween($field, (array)$value);
|
||
break;
|
||
case 'like':
|
||
$query->where($field, 'LIKE', "%{$value}%");
|
||
break;
|
||
case 'in':
|
||
$query->whereIn($field, (array)$value);
|
||
break;
|
||
case 'not_in':
|
||
$query->whereNotIn($field, (array)$value);
|
||
break;
|
||
case 'null':
|
||
$query->whereNull($field);
|
||
break;
|
||
case 'not_null':
|
||
$query->whereNotNull($field);
|
||
break;
|
||
default:
|
||
$query->where($field, $operator, $value);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据 queryConfig 构造 fieldsMap
|
||
* - 兼容 "col as alias"
|
||
* - 自动把 counts 生成的 xxx_count 加入根字段
|
||
*
|
||
* @param array $config 完整的 applyQueryConfig 输入
|
||
* @return array [''=>[主表字段…], 'relation'=>[字段…], …]
|
||
*/
|
||
public static function buildFieldsMap(array $config): array
|
||
{
|
||
$relations = $config['relations'] ?? [];
|
||
$counts = $config['counts'] ?? [];
|
||
$rawFields = $config['fields'] ?? [];
|
||
|
||
// 1) 根层字段:拆 alias + 自动加 count + 加顶级关联名
|
||
$root = [];
|
||
foreach ($rawFields as $f) {
|
||
if (preg_match('/\s+as\s+/i', $f)) {
|
||
[, $alias] = preg_split('/\s+as\s+/i', $f);
|
||
$root[] = $alias;
|
||
} else {
|
||
$root[] = $f;
|
||
}
|
||
}
|
||
// 把 withCount 生成的字段也加进来
|
||
foreach ($counts as $rel) {
|
||
$root[] = "{$rel}_count";
|
||
}
|
||
// 顶级关联名
|
||
foreach (array_keys($relations) as $path) {
|
||
$root[] = explode('.', $path, 2)[0];
|
||
}
|
||
$fieldsMap = ['' => array_values(array_unique($root))];
|
||
|
||
// 2) 每个关联:拆 alias + 加直接子关联
|
||
foreach ($relations as $path => $rawCols) {
|
||
$arr = [];
|
||
$segments = explode('.', $path);
|
||
$depth = count($segments);
|
||
// 拆 alias
|
||
foreach ($rawCols as $col) {
|
||
if (preg_match('/\s+as\s+/i', $col)) {
|
||
[, $alias] = preg_split('/\s+as\s+/i', $col);
|
||
$arr[] = $alias;
|
||
} else {
|
||
$arr[] = $col;
|
||
}
|
||
}
|
||
// 子关联名
|
||
if ($depth > 1) {
|
||
for ($i = 1; $i < $depth; $i++) {
|
||
$parent = implode('.', array_slice($segments, 0, $i));
|
||
$child = $segments[$i];
|
||
$fieldsMap[$parent][] = $child;
|
||
}
|
||
}
|
||
$fieldsMap[$path] = array_values(array_unique($arr));
|
||
}
|
||
|
||
return $fieldsMap;
|
||
}
|
||
} |