804 lines
20 KiB
PHP
804 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Yajra\DataTables;
|
|
|
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Database\Query\Expression;
|
|
use Illuminate\Support\Str;
|
|
use Yajra\DataTables\Utilities\Helper;
|
|
|
|
class QueryDataTable extends DataTableAbstract
|
|
{
|
|
/**
|
|
* Builder object.
|
|
*
|
|
* @var \Illuminate\Database\Query\Builder
|
|
*/
|
|
protected $query;
|
|
|
|
/**
|
|
* Database connection used.
|
|
*
|
|
* @var \Illuminate\Database\Connection
|
|
*/
|
|
protected $connection;
|
|
|
|
/**
|
|
* Flag for ordering NULLS LAST option.
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected $nullsLast = false;
|
|
|
|
/**
|
|
* Flag to check if query preparation was already done.
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected $prepared = false;
|
|
|
|
/**
|
|
* Query callback for custom pagination using limit without offset.
|
|
*
|
|
* @var callable
|
|
*/
|
|
protected $limitCallback;
|
|
|
|
/**
|
|
* Flag to skip total records count query.
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected $skipTotalRecords = false;
|
|
|
|
/**
|
|
* Flag to keep the select bindings.
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected $keepSelectBindings = false;
|
|
|
|
/**
|
|
* Can the DataTable engine be created with these parameters.
|
|
*
|
|
* @param mixed $source
|
|
* @return bool
|
|
*/
|
|
public static function canCreate($source)
|
|
{
|
|
return $source instanceof Builder;
|
|
}
|
|
|
|
/**
|
|
* @param \Illuminate\Database\Query\Builder $builder
|
|
*/
|
|
public function __construct(Builder $builder)
|
|
{
|
|
$this->query = $builder;
|
|
$this->request = app('datatables.request');
|
|
$this->config = app('datatables.config');
|
|
$this->columns = $builder->columns;
|
|
$this->connection = $builder->getConnection();
|
|
if ($this->config->isDebugging()) {
|
|
$this->connection->enableQueryLog();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Organizes works.
|
|
*
|
|
* @param bool $mDataSupport
|
|
* @return \Illuminate\Http\JsonResponse
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function make($mDataSupport = true)
|
|
{
|
|
try {
|
|
$this->prepareQuery();
|
|
|
|
$results = $this->results();
|
|
$processed = $this->processResults($results, $mDataSupport);
|
|
$data = $this->transform($results, $processed);
|
|
|
|
return $this->render($data);
|
|
} catch (\Exception $exception) {
|
|
return $this->errorResponse($exception);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform search using search pane values.
|
|
*/
|
|
protected function searchPanesSearch()
|
|
{
|
|
$columns = $this->request->get('searchPanes', []);
|
|
|
|
foreach ($columns as $column => $values) {
|
|
if ($this->isBlacklisted($column)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->searchPanes[$column] && $callback = $this->searchPanes[$column]['builder']) {
|
|
$callback($this->query, $values);
|
|
} else {
|
|
$this->query->whereIn($column, $values);
|
|
}
|
|
|
|
$this->isFilterApplied = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepare query by executing count, filter, order and paginate.
|
|
*/
|
|
protected function prepareQuery()
|
|
{
|
|
if (! $this->prepared) {
|
|
$this->totalRecords = $this->totalCount();
|
|
|
|
if ($this->totalRecords) {
|
|
$this->filterRecords();
|
|
}
|
|
|
|
$this->ordering();
|
|
$this->paginate();
|
|
}
|
|
|
|
$this->prepared = true;
|
|
}
|
|
|
|
/**
|
|
* Skip total records and set the recordsTotal equals to recordsFiltered.
|
|
* This will improve the performance by skipping the total count query.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function skipTotalRecords()
|
|
{
|
|
$this->skipTotalRecords = true;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Keep the select bindings.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function keepSelectBindings()
|
|
{
|
|
$this->keepSelectBindings = true;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Count total items.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function totalCount()
|
|
{
|
|
if ($this->skipTotalRecords) {
|
|
$this->isFilterApplied = true;
|
|
|
|
return 1;
|
|
}
|
|
|
|
return $this->totalRecords ? $this->totalRecords : $this->count();
|
|
}
|
|
|
|
/**
|
|
* Count filtered items.
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function filteredCount()
|
|
{
|
|
$this->filteredRecords = $this->filteredRecords ?: $this->count();
|
|
if ($this->skipTotalRecords) {
|
|
$this->totalRecords = $this->filteredRecords;
|
|
}
|
|
|
|
return $this->filteredRecords;
|
|
}
|
|
|
|
/**
|
|
* Counts current query.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function count()
|
|
{
|
|
return $this->prepareCountQuery()->count();
|
|
}
|
|
|
|
/**
|
|
* Prepare count query builder.
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
|
|
*/
|
|
public function prepareCountQuery()
|
|
{
|
|
$builder = clone $this->query;
|
|
|
|
if ($this->isComplexQuery($builder)) {
|
|
$table = $this->connection->raw('('.$builder->toSql().') count_row_table');
|
|
|
|
return $this->connection->table($table)
|
|
->setBindings($builder->getBindings());
|
|
}
|
|
|
|
$row_count = $this->wrap('row_count');
|
|
$builder->select($this->connection->raw("'1' as {$row_count}"));
|
|
if (! $this->keepSelectBindings) {
|
|
$builder->setBindings([], 'select');
|
|
}
|
|
|
|
return $builder;
|
|
}
|
|
|
|
/**
|
|
* Check if builder query uses complex sql.
|
|
*
|
|
* @param \Illuminate\Database\Query\Builder $builder
|
|
* @return bool
|
|
*/
|
|
protected function isComplexQuery($builder)
|
|
{
|
|
return Str::contains(Str::lower($builder->toSql()), ['union', 'having', 'distinct', 'order by', 'group by']);
|
|
}
|
|
|
|
/**
|
|
* Wrap column with DB grammar.
|
|
*
|
|
* @param string $column
|
|
* @return string
|
|
*/
|
|
protected function wrap($column)
|
|
{
|
|
return $this->connection->getQueryGrammar()->wrap($column);
|
|
}
|
|
|
|
/**
|
|
* Get paginated results.
|
|
*
|
|
* @return \Illuminate\Support\Collection
|
|
*/
|
|
public function results()
|
|
{
|
|
return $this->query->get();
|
|
}
|
|
|
|
/**
|
|
* Get filtered, ordered and paginated query.
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
|
|
*/
|
|
public function getFilteredQuery()
|
|
{
|
|
$this->prepareQuery();
|
|
|
|
return $this->getQuery();
|
|
}
|
|
|
|
/**
|
|
* Get query builder instance.
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder
|
|
*/
|
|
public function getQuery()
|
|
{
|
|
return $this->query;
|
|
}
|
|
|
|
/**
|
|
* Perform column search.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function columnSearch()
|
|
{
|
|
$columns = $this->request->columns();
|
|
|
|
foreach ($columns as $index => $column) {
|
|
$column = $this->getColumnName($index);
|
|
|
|
if (! $this->request->isColumnSearchable($index) || $this->isBlacklisted($column) && ! $this->hasFilterColumn($column)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->hasFilterColumn($column)) {
|
|
$keyword = $this->getColumnSearchKeyword($index, true);
|
|
$this->applyFilterColumn($this->getBaseQueryBuilder(), $column, $keyword);
|
|
} else {
|
|
$column = $this->resolveRelationColumn($column);
|
|
$keyword = $this->getColumnSearchKeyword($index);
|
|
$this->compileColumnSearch($index, $column, $keyword);
|
|
}
|
|
|
|
$this->isFilterApplied = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if column has custom filter handler.
|
|
*
|
|
* @param string $columnName
|
|
* @return bool
|
|
*/
|
|
public function hasFilterColumn($columnName)
|
|
{
|
|
return isset($this->columnDef['filter'][$columnName]);
|
|
}
|
|
|
|
/**
|
|
* Get column keyword to use for search.
|
|
*
|
|
* @param int $i
|
|
* @param bool $raw
|
|
* @return string
|
|
*/
|
|
protected function getColumnSearchKeyword($i, $raw = false)
|
|
{
|
|
$keyword = $this->request->columnKeyword($i);
|
|
if ($raw || $this->request->isRegex($i)) {
|
|
return $keyword;
|
|
}
|
|
|
|
return $this->setupKeyword($keyword);
|
|
}
|
|
|
|
/**
|
|
* Apply filterColumn api search.
|
|
*
|
|
* @param mixed $query
|
|
* @param string $columnName
|
|
* @param string $keyword
|
|
* @param string $boolean
|
|
*/
|
|
protected function applyFilterColumn($query, $columnName, $keyword, $boolean = 'and')
|
|
{
|
|
$query = $this->getBaseQueryBuilder($query);
|
|
$callback = $this->columnDef['filter'][$columnName]['method'];
|
|
|
|
if ($this->query instanceof EloquentBuilder) {
|
|
$builder = $this->query->newModelInstance()->newQuery();
|
|
} else {
|
|
$builder = $this->query->newQuery();
|
|
}
|
|
|
|
$callback($builder, $keyword);
|
|
|
|
$query->addNestedWhereQuery($this->getBaseQueryBuilder($builder), $boolean);
|
|
}
|
|
|
|
/**
|
|
* Get the base query builder instance.
|
|
*
|
|
* @param mixed $instance
|
|
* @return \Illuminate\Database\Query\Builder
|
|
*/
|
|
protected function getBaseQueryBuilder($instance = null)
|
|
{
|
|
if (! $instance) {
|
|
$instance = $this->query;
|
|
}
|
|
|
|
if ($instance instanceof EloquentBuilder) {
|
|
return $instance->getQuery();
|
|
}
|
|
|
|
return $instance;
|
|
}
|
|
|
|
/**
|
|
* Resolve the proper column name be used.
|
|
*
|
|
* @param string $column
|
|
* @return string
|
|
*/
|
|
protected function resolveRelationColumn($column)
|
|
{
|
|
return $column;
|
|
}
|
|
|
|
/**
|
|
* Compile queries for column search.
|
|
*
|
|
* @param int $i
|
|
* @param string $column
|
|
* @param string $keyword
|
|
*/
|
|
protected function compileColumnSearch($i, $column, $keyword)
|
|
{
|
|
if ($this->request->isRegex($i)) {
|
|
$this->regexColumnSearch($column, $keyword);
|
|
} else {
|
|
$this->compileQuerySearch($this->query, $column, $keyword, '');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile regex query column search.
|
|
*
|
|
* @param mixed $column
|
|
* @param string $keyword
|
|
*/
|
|
protected function regexColumnSearch($column, $keyword)
|
|
{
|
|
$column = $this->wrap($column);
|
|
|
|
switch ($this->connection->getDriverName()) {
|
|
case 'oracle':
|
|
$sql = ! $this->config->isCaseInsensitive()
|
|
? 'REGEXP_LIKE( ' . $column . ' , ? )'
|
|
: 'REGEXP_LIKE( LOWER(' . $column . ') , ?, \'i\' )';
|
|
break;
|
|
|
|
case 'pgsql':
|
|
$column = $this->castColumn($column);
|
|
$sql = ! $this->config->isCaseInsensitive() ? $column . ' ~ ?' : $column . ' ~* ? ';
|
|
break;
|
|
|
|
default:
|
|
$sql = ! $this->config->isCaseInsensitive()
|
|
? $column . ' REGEXP ?'
|
|
: 'LOWER(' . $column . ') REGEXP ?';
|
|
$keyword = Str::lower($keyword);
|
|
}
|
|
|
|
$this->query->whereRaw($sql, [$keyword]);
|
|
}
|
|
|
|
/**
|
|
* Wrap a column and cast based on database driver.
|
|
*
|
|
* @param string $column
|
|
* @return string
|
|
*/
|
|
protected function castColumn($column)
|
|
{
|
|
switch ($this->connection->getDriverName()) {
|
|
case 'pgsql':
|
|
return 'CAST(' . $column . ' as TEXT)';
|
|
case 'firebird':
|
|
return 'CAST(' . $column . ' as VARCHAR(255))';
|
|
default:
|
|
return $column;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile query builder where clause depending on configurations.
|
|
*
|
|
* @param mixed $query
|
|
* @param string $column
|
|
* @param string $keyword
|
|
* @param string $boolean
|
|
*/
|
|
protected function compileQuerySearch($query, $column, $keyword, $boolean = 'or')
|
|
{
|
|
$column = $this->addTablePrefix($query, $column);
|
|
$column = $this->castColumn($column);
|
|
$sql = $column . ' LIKE ?';
|
|
|
|
if ($this->config->isCaseInsensitive()) {
|
|
$sql = 'LOWER(' . $column . ') LIKE ?';
|
|
}
|
|
|
|
$query->{$boolean . 'WhereRaw'}($sql, [$this->prepareKeyword($keyword)]);
|
|
}
|
|
|
|
/**
|
|
* Patch for fix about ambiguous field.
|
|
* Ambiguous field error will appear when query use join table and search with keyword.
|
|
*
|
|
* @param mixed $query
|
|
* @param string $column
|
|
* @return string
|
|
*/
|
|
protected function addTablePrefix($query, $column)
|
|
{
|
|
if (strpos($column, '.') === false) {
|
|
$q = $this->getBaseQueryBuilder($query);
|
|
if (! $q->from instanceof Expression) {
|
|
$column = $q->from . '.' . $column;
|
|
}
|
|
}
|
|
|
|
return $this->wrap($column);
|
|
}
|
|
|
|
/**
|
|
* Prepare search keyword based on configurations.
|
|
*
|
|
* @param string $keyword
|
|
* @return string
|
|
*/
|
|
protected function prepareKeyword($keyword)
|
|
{
|
|
if ($this->config->isStartsWithSearch()) {
|
|
return "$keyword%";
|
|
}
|
|
|
|
if ($this->config->isCaseInsensitive()) {
|
|
$keyword = Str::lower($keyword);
|
|
}
|
|
|
|
if ($this->config->isWildcard()) {
|
|
$keyword = Helper::wildcardLikeString($keyword);
|
|
}
|
|
|
|
if ($this->config->isSmartSearch()) {
|
|
$keyword = "%$keyword%";
|
|
}
|
|
|
|
return $keyword;
|
|
}
|
|
|
|
/**
|
|
* Add custom filter handler for the give column.
|
|
*
|
|
* @param string $column
|
|
* @param callable $callback
|
|
* @return $this
|
|
*/
|
|
public function filterColumn($column, callable $callback)
|
|
{
|
|
$this->columnDef['filter'][$column] = ['method' => $callback];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Order each given columns versus the given custom sql.
|
|
*
|
|
* @param array $columns
|
|
* @param string $sql
|
|
* @param array $bindings
|
|
* @return $this
|
|
*/
|
|
public function orderColumns(array $columns, $sql, $bindings = [])
|
|
{
|
|
foreach ($columns as $column) {
|
|
$this->orderColumn($column, str_replace(':column', $column, $sql), $bindings);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Override default column ordering.
|
|
*
|
|
* @param string $column
|
|
* @param string|\Closure $sql
|
|
* @param array $bindings
|
|
* @return $this
|
|
*
|
|
* @internal string $1 Special variable that returns the requested order direction of the column.
|
|
*/
|
|
public function orderColumn($column, $sql, $bindings = [])
|
|
{
|
|
$this->columnDef['order'][$column] = compact('sql', 'bindings');
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set datatables to do ordering with NULLS LAST option.
|
|
*
|
|
* @return $this
|
|
*/
|
|
public function orderByNullsLast()
|
|
{
|
|
$this->nullsLast = true;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Paginate dataTable using limit without offset
|
|
* with additional where clause via callback.
|
|
*
|
|
* @param callable $callback
|
|
* @return $this
|
|
*/
|
|
public function limit(callable $callback)
|
|
{
|
|
$this->limitCallback = $callback;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Perform pagination.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function paging()
|
|
{
|
|
$limit = (int) $this->request->input('length') > 0 ? $this->request->input('length') : 10;
|
|
if (is_callable($this->limitCallback)) {
|
|
$this->query->limit($limit);
|
|
call_user_func_array($this->limitCallback, [$this->query]);
|
|
} else {
|
|
$this->query->skip($this->request->input('start'))->take($limit);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add column in collection.
|
|
*
|
|
* @param string $name
|
|
* @param string|callable $content
|
|
* @param bool|int $order
|
|
* @return $this
|
|
*/
|
|
public function addColumn($name, $content, $order = false)
|
|
{
|
|
$this->pushToBlacklist($name);
|
|
|
|
return parent::addColumn($name, $content, $order);
|
|
}
|
|
|
|
/**
|
|
* Resolve callback parameter instance.
|
|
*
|
|
* @return \Illuminate\Database\Query\Builder
|
|
*/
|
|
protected function resolveCallbackParameter()
|
|
{
|
|
return $this->query;
|
|
}
|
|
|
|
/**
|
|
* Perform default query orderBy clause.
|
|
*/
|
|
protected function defaultOrdering()
|
|
{
|
|
collect($this->request->orderableColumns())
|
|
->map(function ($orderable) {
|
|
$orderable['name'] = $this->getColumnName($orderable['column'], null, true);
|
|
|
|
return $orderable;
|
|
})
|
|
->reject(function ($orderable) {
|
|
return $this->isBlacklisted($orderable['name']) && ! $this->hasOrderColumn($orderable['name']);
|
|
})
|
|
->each(function ($orderable) {
|
|
$column = $this->resolveRelationColumn($orderable['name']);
|
|
|
|
if ($this->hasOrderColumn($column)) {
|
|
$this->applyOrderColumn($column, $orderable);
|
|
} else {
|
|
$nullsLastSql = $this->getNullsLastSql($column, $orderable['direction']);
|
|
$normalSql = $this->wrap($column) . ' ' . $orderable['direction'];
|
|
$sql = $this->nullsLast ? $nullsLastSql : $normalSql;
|
|
$this->query->orderByRaw($sql);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if column has custom sort handler.
|
|
*
|
|
* @param string $column
|
|
* @return bool
|
|
*/
|
|
protected function hasOrderColumn($column)
|
|
{
|
|
return isset($this->columnDef['order'][$column]);
|
|
}
|
|
|
|
/**
|
|
* Apply orderColumn custom query.
|
|
*
|
|
* @param string $column
|
|
* @param array $orderable
|
|
*/
|
|
protected function applyOrderColumn($column, $orderable)
|
|
{
|
|
$sql = $this->columnDef['order'][$column]['sql'];
|
|
if ($sql === false) {
|
|
return;
|
|
}
|
|
|
|
if (is_callable($sql)) {
|
|
call_user_func($sql, $this->query, $orderable['direction']);
|
|
} else {
|
|
$sql = str_replace('$1', $orderable['direction'], $sql);
|
|
$bindings = $this->columnDef['order'][$column]['bindings'];
|
|
$this->query->orderByRaw($sql, $bindings);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get NULLS LAST SQL.
|
|
*
|
|
* @param string $column
|
|
* @param string $direction
|
|
* @return string
|
|
*/
|
|
protected function getNullsLastSql($column, $direction)
|
|
{
|
|
$sql = $this->config->get('datatables.nulls_last_sql', '%s %s NULLS LAST');
|
|
|
|
return str_replace(
|
|
[':column', ':direction'],
|
|
[$column, $direction],
|
|
sprintf($sql, $column, $direction)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Perform global search for the given keyword.
|
|
*
|
|
* @param string $keyword
|
|
*/
|
|
protected function globalSearch($keyword)
|
|
{
|
|
$this->query->where(function ($query) use ($keyword) {
|
|
collect($this->request->searchableColumnIndex())
|
|
->map(function ($index) {
|
|
return $this->getColumnName($index);
|
|
})
|
|
->reject(function ($column) {
|
|
return $this->isBlacklisted($column) && ! $this->hasFilterColumn($column);
|
|
})
|
|
->each(function ($column) use ($keyword, $query) {
|
|
if ($this->hasFilterColumn($column)) {
|
|
$this->applyFilterColumn($query, $column, $keyword, 'or');
|
|
} else {
|
|
$this->compileQuerySearch($query, $column, $keyword);
|
|
}
|
|
|
|
$this->isFilterApplied = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append debug parameters on output.
|
|
*
|
|
* @param array $output
|
|
* @return array
|
|
*/
|
|
protected function showDebugger(array $output)
|
|
{
|
|
$query_log = $this->connection->getQueryLog();
|
|
array_walk_recursive($query_log, function (&$item) {
|
|
if (is_string($item)) {
|
|
$item = utf8_encode($item);
|
|
}
|
|
});
|
|
|
|
$output['queries'] = $query_log;
|
|
$output['input'] = $this->request->all();
|
|
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* Attach custom with meta on response.
|
|
*
|
|
* @param array $data
|
|
* @return array
|
|
*/
|
|
protected function attachAppends(array $data)
|
|
{
|
|
$appends = [];
|
|
foreach ($this->appends as $key => $value) {
|
|
if (is_callable($value)) {
|
|
$appends[$key] = value($value($this->getFilteredQuery()));
|
|
} else {
|
|
$appends[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return array_merge($data, $appends);
|
|
}
|
|
}
|