diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 5cb492da..971c16c8 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -1,33 +1,35 @@
-name: "Continuous Integration"
+name: Continuous Integration
on:
push:
+ branches:
+ - master
+ - '*.x'
pull_request:
schedule:
- cron: '0 0 * * *'
jobs:
- phpunit:
+ tests:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
- php: [8.2, 8.3, 8.4]
- stability: [prefer-stable]
+ php: [ 8.2, 8.3, 8.4, 8.5 ]
+ stability: [ prefer-stable ]
- name: PHP ${{ matrix.php }} - ${{ matrix.stability }}
+ name: PHP ${{ matrix.php }} - STABILITY ${{ matrix.stability }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, memcached
tools: composer:v2
coverage: none
@@ -39,7 +41,7 @@ jobs:
with:
timeout_minutes: 5
max_attempts: 5
- command: COMPOSER_ROOT_VERSION=dev-master composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress
+ command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress
- name: Execute tests
run: vendor/bin/phpunit
diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml
index fbde1d8c..147b6e3a 100644
--- a/.github/workflows/pint.yml
+++ b/.github/workflows/pint.yml
@@ -4,12 +4,14 @@ on:
push:
branches:
- master
+ - 11.x
jobs:
phplint:
runs-on: ubuntu-latest
permissions:
contents: write
+ pull-requests: write
steps:
- uses: actions/checkout@v4
@@ -24,4 +26,4 @@ jobs:
- uses: stefanzweifel/git-auto-commit-action@v5
with:
- commit_message: "fix: pint"
+ commit_message: "fix: pint :robot:"
diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml
new file mode 100644
index 00000000..3581d7cf
--- /dev/null
+++ b/.github/workflows/semantic-release.yml
@@ -0,0 +1,41 @@
+name: Semantic Releases
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ packages: write
+ statuses: write
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Setup Configuration
+ run: |
+ if [ -n "$GH_TOKEN_SECRET" ]; then
+ echo "GH_TOKEN=$GH_TOKEN_SECRET" >> $GITHUB_ENV
+ else
+ echo "GH_TOKEN=$GITHUB_TOKEN" >> $GITHUB_ENV
+ fi
+ env:
+ GH_TOKEN_SECRET: ${{ secrets.GH_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 'lts/*'
+
+ - name: Semantic Release
+ uses: cycjimmy/semantic-release-action@9cc899c47e6841430bbaedb43de1560a568dfd16
+ env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index bdf913b3..b6933cea 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -1,21 +1,12 @@
-name: "Static Analysis"
+name: Static Analysis
on:
push:
- paths:
- - .github/workflows/static-analysis.yml
- - composer.*
- - phpstan.neon.dist
- - src/**
- - tests/**
+ branches:
+ - master
+ - '*.x'
pull_request:
- paths:
- - .github/workflows/static-analysis.yml
- - composer.*
- - phpstan.neon.dist
- - src/**
- - tests/**
schedule:
- cron: '0 0 * * *'
@@ -23,15 +14,9 @@ on:
jobs:
static-analysis-phpstan:
- name: "Static Analysis with PHPStan"
+ name: Source Code
runs-on: ubuntu-latest
- strategy:
- fail-fast: true
- matrix:
- php: [8.2, 8.3, 8.4]
- stability: [prefer-stable]
-
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -39,16 +24,16 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: ${{ matrix.php }}
+ php-version: 8.2
tools: composer:v2
coverage: none
- name: Install dependencies
- uses: nick-invision/retry@v1
+ uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 5
- command: COMPOSER_ROOT_VERSION=dev-master composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress
+ command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress
- - name: "Run a static analysis with phpstan/phpstan"
- run: "vendor/bin/phpstan --error-format=table"
+ - name: Run Static Analysis
+ run: vendor/bin/phpunit
diff --git a/.releaserc.yml b/.releaserc.yml
new file mode 100644
index 00000000..e96c554c
--- /dev/null
+++ b/.releaserc.yml
@@ -0,0 +1,26 @@
+{
+ "branches": [
+ "main",
+ "master",
+ "*.x"
+ ],
+ "plugins": [
+ "@semantic-release/commit-analyzer",
+ "@semantic-release/release-notes-generator",
+ [
+ "@semantic-release/changelog",
+ {
+ "changelogFile": "CHANGELOG.md"
+ }
+ ],
+ [
+ "@semantic-release/git",
+ {
+ "assets": [
+ "CHANGELOG.md"
+ ]
+ }
+ ],
+ "@semantic-release/github"
+ ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e564b6c..91ad2c0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,46 +1,90 @@
+## [12.6.1](https://github.com/yajra/laravel-datatables/compare/v12.6.0...v12.6.1) (2025-10-11)
+
+
+### Bug Fixes
+
+* value when mask uses "/" ([c771900](https://github.com/yajra/laravel-datatables/commit/c77190030c713e5b64c433bd161d9f33a210f22b))
+
+# [12.6.0](https://github.com/yajra/laravel-datatables/compare/v12.5.1...v12.6.0) (2025-10-08)
+
+
+### Bug Fixes
+
+* replace unsafe eval() with Blade::render() in compileBlade ([7f46d58](https://github.com/yajra/laravel-datatables/commit/7f46d5872b0324493c28ecc8d848c182e88f30e0))
+
+
+### Features
+
+* add __isset() method to Request for attribute existence check ([33f44d4](https://github.com/yajra/laravel-datatables/commit/33f44d42d284d6ea0a054de81ad5a57c3050867d))
+
# Laravel DataTables
## CHANGELOG
### [Unreleased]
-### [v11.1.6](https://github.com/yajra/laravel-datatables/compare/v11.1.5...v11.1.6) - 2025-01-21
+### v12.5.1 - 2025-10-02
+
+- fix: ambiguous column in columnControlSearch() method #3252
+
+### v12.5.0 - 2025-10-01
+
+- feat: server-side column control #3251
+- fix: https://github.com/yajra/laravel-datatables/issues/3250
+
+### v12.4.2 - 2025-09-09
+
+- fix: remove @internal annotation from orderColumn() method #3248
+
+### v12.4.1 - 2025-08-29
+
+- fix: request handling with playwright / pest 4 #3247
+
+### v12.4.0 - 2025-06-15
+
+- feat: add min search length control #3242
+- fix: #3241
+
+### v12.3.1 - 2025-06-10
-- fix: static analysis #3213
-- ci: update workflow #3213
+- fix: support for array notation #3243
-### [v11.1.5](https://github.com/yajra/laravel-datatables/compare/v11.1.4...v11.1.5) - 2024-09-26
+### v12.3.0 - 2025-05-17
-- Add skip total records back #3170
-- Alternative to #3169.
-- Partially reverts #3157.
+- feat: add option to enable alias on relation tables #3234
+- tests: Add tests to cover prefix detection #3239
+- fix: https://github.com/yajra/laravel-datatables/pull/1782
-### [v11.1.4](https://github.com/yajra/laravel-datatables/compare/v11.1.3...v11.1.4) - 2024-08-17
+### v12.2.1 - 2025-05-09
-- fix: Ensure dates are not turned into arrays by the processor #3163
-- fix: ##3156
+- fix: improve prefix detection #3238
+- fix: #3237
-### [v11.1.3](https://github.com/yajra/laravel-datatables/compare/v11.1.2...v11.1.3) - 2024-07-15
+### v12.2.0 - 2025-05-08
-- fix: make query for filteredRecords when totalRecords was manually set #3157
+- feat: add relation resolver param to order callback #3232
+- fix: improve column alias detection #3236
+- fix: #3235
-### [v11.1.2](https://github.com/yajra/laravel-datatables/compare/v11.1.1...v11.1.2) - 2024-07-03
+### v12.1.2 - 2025-05-07
-- fix: ErrorException when direction is null #3154
+- fix: prevent prefixing null/empty string #3233
-### [v11.1.1](https://github.com/yajra/laravel-datatables/compare/v11.1.0...v11.1.1) - 2024-04-16
+### v12.1.1 - 2025-05-05
-- fix: mariadb support for scout search #3146
+- fix: prevent ambiguous column names #3227
-### [v11.1.0](https://github.com/yajra/laravel-datatables/compare/v11.0.0...v11.1.0) - 2024-04-16
+### v12.1.0 - 2025-04-28
-- feat: Optimize simple queries #3135
-- fix: #3133
+- feat: add relation resolver param to filter callbacks #3229
-### [v11.0.0](https://github.com/yajra/laravel-datatables/compare/v11.0.0...master) - 2024-03-14
+### v12.0.1 - 2025-04-07
-- Laravel 11 support
+- fix: query results improvements #3224
+### v12.0.0 - 2025-02-26
-[Unreleased]: https://github.com/yajra/laravel-datatables/compare/v11.0.0...master
+- feat: Laravel v12 Compatibility #3217
+- fix: prevent duplicate table name errors #3216
+[Unreleased]: https://github.com/yajra/laravel-datatables/compare/v12.0.0...master
diff --git a/README.md b/README.md
index fd75c131..32cff155 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](https://www.paypal.me/yajra)
[](https://www.patreon.com/bePatron?u=4521203)
-[](http://laravel.com)
+[](http://laravel.com)
[](https://packagist.org/packages/yajra/laravel-datatables-oracle)
[](https://github.com/yajra/laravel-datatables/actions/workflows/continuous-integration.yml)
[](https://github.com/yajra/laravel-datatables/actions/workflows/static-analysis.yml)
@@ -31,7 +31,9 @@ return DataTables::make(User::all())->toJson();
-  |
+
+
+ |
A big thank you to DataTables for supporting this project with a free DataTables Editor license. |
@@ -40,8 +42,10 @@ return DataTables::make(User::all())->toJson();
-  |
- A big thank you to JetBrains for supporting this project with free open-source licenses of their IDEs. |
+
+
+ |
+ A big thank you to JetBrains for supporting this project with free open-source licenses of their IDEs. |
@@ -49,7 +53,7 @@ return DataTables::make(User::all())->toJson();
-  |
+  |
A big thank you to Blackfire.io for supporting this project with a free open-source license. |
@@ -86,19 +90,20 @@ return DataTables::make(User::all())->toJson();
| 9.x | 10.x |
| 10.x | 10.x |
| 11.x | 11.x |
+| 12.x | 12.x |
## Quick Installation
### Option 1: Install all DataTables libraries
```bash
-composer require yajra/laravel-datatables:"^11"
+composer require yajra/laravel-datatables:"^12"
```
### Option 2: Install only this library
```bash
-composer require yajra/laravel-datatables-oracle:"^11"
+composer require yajra/laravel-datatables-oracle:"^12"
```
#### Service Provider & Facade (Optional on Laravel 5.5+)
diff --git a/UPGRADE.md b/UPGRADE.md
index 9ddec173..36a386b5 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,5 +1,9 @@
# UPGRADE GUIDE
+## Upgrading from v11.x to v12.x
+
+- See PR https://github.com/yajra/laravel-datatables/pull/3216
+
## Upgrading from v9.x to v10.x
- `ApiResourceDataTable` support dropped, use `CollectionDataTable` instead.
diff --git a/composer.json b/composer.json
index ac6d1273..6e034353 100644
--- a/composer.json
+++ b/composer.json
@@ -16,20 +16,20 @@
],
"require": {
"php": "^8.2",
- "illuminate/database": "^11",
- "illuminate/filesystem": "^11",
- "illuminate/http": "^11",
- "illuminate/support": "^11",
- "illuminate/view": "^11"
+ "illuminate/database": "^12",
+ "illuminate/filesystem": "^12",
+ "illuminate/http": "^12",
+ "illuminate/support": "^12",
+ "illuminate/view": "^12"
},
"require-dev": {
"algolia/algoliasearch-client-php": "^3.4.1",
- "larastan/larastan": "^2.9.1",
+ "larastan/larastan": "^3.1.0",
"laravel/pint": "^1.14",
"laravel/scout": "^10.8.3",
"meilisearch/meilisearch-php": "^1.6.1",
- "orchestra/testbench": "^9",
- "rector/rector": "^1.0"
+ "orchestra/testbench": "^10",
+ "rector/rector": "^2.0"
},
"suggest": {
"yajra/laravel-datatables-export": "Plugin for server-side exporting using livewire and queue worker.",
@@ -53,7 +53,7 @@
},
"extra": {
"branch-alias": {
- "dev-master": "11.x-dev"
+ "dev-master": "12.x-dev"
},
"laravel": {
"providers": [
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 6e0471da..26779953 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -10,7 +10,21 @@ parameters:
ignoreErrors:
- '#Unsafe usage of new static\(\).#'
- - identifier: missingType.iterableValue
+ - identifier: missingType.iterableValue
+ - identifier: argument.type
+ - identifier: cast.string
+ - identifier: foreach.nonIterable
+ - identifier: binaryOp.invalid
+ - identifier: offsetAccess.nonOffsetAccessible
+ - identifier: return.type
+ - identifier: method.nonObject
+ - identifier: varTag.nativeType
+ - identifier: assign.propertyType
+ - identifier: callable.nonCallable
+ - identifier: property.nonObject
excludePaths:
- src/helper.php
+
+ noEnvCallsOutsideOfConfig: false
+ treatPhpDocTypesAsCertain: false
diff --git a/src/CollectionDataTable.php b/src/CollectionDataTable.php
index f8ff4082..4392549e 100644
--- a/src/CollectionDataTable.php
+++ b/src/CollectionDataTable.php
@@ -143,6 +143,8 @@ public function paging(): void
public function make(bool $mDataSupport = true): JsonResponse
{
try {
+ $this->validateMinLengthSearch();
+
$this->totalRecords = $this->totalCount();
if ($this->totalRecords) {
diff --git a/src/DataTableAbstract.php b/src/DataTableAbstract.php
index c4667d05..0e51ed2b 100644
--- a/src/DataTableAbstract.php
+++ b/src/DataTableAbstract.php
@@ -122,6 +122,8 @@ abstract class DataTableAbstract implements DataTable
protected bool $editOnlySelectedColumns = false;
+ protected int $minSearchLength = 0;
+
/**
* Can the DataTable engine be created with these parameters.
*
@@ -728,10 +730,16 @@ protected function filterRecords(): void
}
$this->columnSearch();
+ $this->columnControlSearch();
$this->searchPanesSearch();
$this->filteredCount();
}
+ public function columnControlSearch(): void
+ {
+ // Not implemented in the abstract class.
+ }
+
/**
* Perform global search.
*/
@@ -779,7 +787,7 @@ protected function searchPanesSearch(): void
/**
* Count filtered items.
*/
- protected function filteredCount(): int
+ public function filteredCount(): int
{
return $this->filteredRecords ??= $this->count();
}
@@ -989,4 +997,26 @@ protected function getPrimaryKeyName(): string
{
return 'id';
}
+
+ public function minSearchLength(int $length): static
+ {
+ $this->minSearchLength = $length;
+
+ return $this;
+ }
+
+ protected function validateMinLengthSearch(): void
+ {
+ if ($this->request->isSearchable()
+ && $this->minSearchLength > 0
+ && Str::length($this->request->keyword()) < $this->minSearchLength
+ ) {
+ $this->totalRecords = 0;
+ $this->filteredRecords = 0;
+ throw new \Exception(
+ __('Please enter at least :length characters to search.', ['length' => $this->minSearchLength]),
+ 400
+ );
+ }
+ }
}
diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php
index 8a6f154e..a7783d57 100644
--- a/src/EloquentDataTable.php
+++ b/src/EloquentDataTable.php
@@ -17,6 +17,12 @@
*/
class EloquentDataTable extends QueryDataTable
{
+ /**
+ * Flag to enable the generation of unique table aliases on eagerly loaded join columns.
+ * You may want to enable it if you encounter a "Not unique table/alias" error when performing a search or applying ordering.
+ */
+ protected bool $enableEagerJoinAliases = false;
+
/**
* EloquentEngine constructor.
*/
@@ -155,8 +161,7 @@ protected function isMorphRelation($relation)
}
/**
- * Resolve the proper column name be used.
- *
+ * {@inheritDoc}
*
* @throws \Yajra\DataTables\Exceptions\Exception
*/
@@ -164,10 +169,10 @@ protected function resolveRelationColumn(string $column): string
{
$parts = explode('.', $column);
$columnName = array_pop($parts);
- $relation = implode('.', $parts);
+ $relation = preg_replace('/\[.*?\]/', '', implode('.', $parts));
if ($this->isNotEagerLoaded($relation)) {
- return $column;
+ return parent::resolveRelationColumn($column);
}
return $this->joinEagerLoadedColumn($relation, $columnName);
@@ -184,54 +189,84 @@ protected function resolveRelationColumn(string $column): string
*/
protected function joinEagerLoadedColumn($relation, $relationColumn)
{
- $table = '';
+ $tableAlias = $pivotAlias = '';
$lastQuery = $this->query;
foreach (explode('.', $relation) as $eachRelation) {
$model = $lastQuery->getRelation($eachRelation);
+ if ($this->enableEagerJoinAliases) {
+ $lastAlias = $tableAlias ?: $this->getTablePrefix($lastQuery);
+ $tableAlias = $tableAlias.'_'.$eachRelation;
+ $pivotAlias = $tableAlias.'_pivot';
+ } else {
+ $lastAlias = $tableAlias ?: $lastQuery->getModel()->getTable();
+ }
switch (true) {
case $model instanceof BelongsToMany:
- $pivot = $model->getTable();
- $pivotPK = $model->getExistenceCompareKey();
- $pivotFK = $model->getQualifiedParentKeyName();
+ if ($this->enableEagerJoinAliases) {
+ $pivot = $model->getTable().' as '.$pivotAlias;
+ } else {
+ $pivot = $pivotAlias = $model->getTable();
+ }
+ $pivotPK = $pivotAlias.'.'.$model->getForeignPivotKeyName();
+ $pivotFK = ltrim($lastAlias.'.'.$model->getParentKeyName(), '.');
$this->performJoin($pivot, $pivotPK, $pivotFK);
$related = $model->getRelated();
- $table = $related->getTable();
+ if ($this->enableEagerJoinAliases) {
+ $table = $related->getTable().' as '.$tableAlias;
+ } else {
+ $table = $tableAlias = $related->getTable();
+ }
$tablePK = $model->getRelatedPivotKeyName();
- $foreign = $pivot.'.'.$tablePK;
- $other = $related->getQualifiedKeyName();
+ $foreign = $pivotAlias.'.'.$tablePK;
+ $other = $tableAlias.'.'.$related->getKeyName();
- $lastQuery->addSelect($table.'.'.$relationColumn);
- $this->performJoin($table, $foreign, $other);
+ $lastQuery->addSelect($tableAlias.'.'.$relationColumn);
break;
case $model instanceof HasOneThrough:
- $pivot = explode('.', $model->getQualifiedParentKeyName())[0]; // extract pivot table from key
- $pivotPK = $pivot.'.'.$model->getFirstKeyName();
- $pivotFK = $model->getQualifiedLocalKeyName();
+ if ($this->enableEagerJoinAliases) {
+ $pivot = explode('.', $model->getQualifiedParentKeyName())[0].' as '.$pivotAlias;
+ } else {
+ $pivot = $pivotAlias = explode('.', $model->getQualifiedParentKeyName())[0];
+ }
+ $pivotPK = $pivotAlias.'.'.$model->getFirstKeyName();
+ $pivotFK = ltrim($lastAlias.'.'.$model->getLocalKeyName(), '.');
$this->performJoin($pivot, $pivotPK, $pivotFK);
$related = $model->getRelated();
- $table = $related->getTable();
+ if ($this->enableEagerJoinAliases) {
+ $table = $related->getTable().' as '.$tableAlias;
+ } else {
+ $table = $tableAlias = $related->getTable();
+ }
$tablePK = $model->getSecondLocalKeyName();
- $foreign = $pivot.'.'.$tablePK;
- $other = $related->getQualifiedKeyName();
+ $foreign = $pivotAlias.'.'.$tablePK;
+ $other = $tableAlias.'.'.$related->getKeyName();
$lastQuery->addSelect($lastQuery->getModel()->getTable().'.*');
break;
case $model instanceof HasOneOrMany:
- $table = $model->getRelated()->getTable();
- $foreign = $model->getQualifiedForeignKeyName();
- $other = $model->getQualifiedParentKeyName();
+ if ($this->enableEagerJoinAliases) {
+ $table = $model->getRelated()->getTable().' as '.$tableAlias;
+ } else {
+ $table = $tableAlias = $model->getRelated()->getTable();
+ }
+ $foreign = $tableAlias.'.'.$model->getForeignKeyName();
+ $other = ltrim($lastAlias.'.'.$model->getLocalKeyName(), '.');
break;
case $model instanceof BelongsTo:
- $table = $model->getRelated()->getTable();
- $foreign = $model->getQualifiedForeignKeyName();
- $other = $model->getQualifiedOwnerKeyName();
+ if ($this->enableEagerJoinAliases) {
+ $table = $model->getRelated()->getTable().' as '.$tableAlias;
+ } else {
+ $table = $tableAlias = $model->getRelated()->getTable();
+ }
+ $foreign = ltrim($lastAlias.'.'.$model->getForeignKeyName(), '.');
+ $other = $tableAlias.'.'.$model->getOwnerKeyName();
break;
default:
@@ -241,7 +276,20 @@ protected function joinEagerLoadedColumn($relation, $relationColumn)
$lastQuery = $model->getQuery();
}
- return $table.'.'.$relationColumn;
+ return $tableAlias.'.'.$relationColumn;
+ }
+
+ /**
+ * Enable the generation of unique table aliases on eagerly loaded join columns.
+ * You may want to enable it if you encounter a "Not unique table/alias" error when performing a search or applying ordering.
+ *
+ * @return $this
+ */
+ public function enableEagerJoinAliases(): static
+ {
+ $this->enableEagerJoinAliases = true;
+
+ return $this;
}
/**
diff --git a/src/QueryDataTable.php b/src/QueryDataTable.php
index f6610dad..01cd5b71 100644
--- a/src/QueryDataTable.php
+++ b/src/QueryDataTable.php
@@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Expression;
use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -80,6 +81,13 @@ class QueryDataTable extends DataTableAbstract
*/
protected bool $disableUserOrdering = false;
+ /**
+ * Paginated results.
+ *
+ * @var Collection
+ */
+ protected Collection $results;
+
public function __construct(protected QueryBuilder $query)
{
$this->request = app('datatables.request');
@@ -117,6 +125,8 @@ public static function canCreate($source): bool
public function make(bool $mDataSupport = true): JsonResponse
{
try {
+ $this->validateMinLengthSearch();
+
$results = $this->prepareQuery()->results();
$processed = $this->processResults($results, $mDataSupport);
$data = $this->transform($results, $processed);
@@ -130,11 +140,11 @@ public function make(bool $mDataSupport = true): JsonResponse
/**
* Get paginated results.
*
- * @return \Illuminate\Support\Collection
+ * @return Collection
*/
public function results(): Collection
{
- return $this->query->get();
+ return $this->results ??= $this->query->get();
}
/**
@@ -249,6 +259,7 @@ protected function filterRecords(): void
}
$this->columnSearch();
+ $this->columnControlSearch();
$this->searchPanesSearch();
// If no modification between the original query and the filtered one has been made
@@ -272,23 +283,132 @@ public function columnSearch(): void
$columns = $this->request->columns();
foreach ($columns as $index => $column) {
- $column = $this->getColumnName($index);
+ $columnName = $this->getColumnName($index);
- if (is_null($column)) {
+ if (is_null($columnName)) {
continue;
}
- if (! $this->request->isColumnSearchable($index) || $this->isBlacklisted($column) && ! $this->hasFilterColumn($column)) {
+ if (! $this->request->isColumnSearchable($index) || $this->isBlacklisted($columnName) && ! $this->hasFilterColumn($columnName)) {
continue;
}
- if ($this->hasFilterColumn($column)) {
+ if ($this->hasFilterColumn($columnName)) {
$keyword = $this->getColumnSearchKeyword($index, true);
- $this->applyFilterColumn($this->getBaseQueryBuilder(), $column, $keyword);
+ $this->applyFilterColumn($this->getBaseQueryBuilder(), $columnName, $keyword);
} else {
- $column = $this->resolveRelationColumn($column);
+ $columnName = $this->resolveRelationColumn($columnName);
$keyword = $this->getColumnSearchKeyword($index);
- $this->compileColumnSearch($index, $column, $keyword);
+ $this->compileColumnSearch($index, $columnName, $keyword);
+ }
+ }
+ }
+
+ public function columnControlSearch(): void
+ {
+ $columns = $this->request->columns();
+
+ foreach ($columns as $index => $column) {
+ $columnName = $this->getColumnName($index);
+
+ if (is_null($columnName) || ! ($column['searchable'] ?? false)) {
+ continue;
+ }
+
+ if ($this->isBlacklisted($columnName) && ! $this->hasFilterColumn($columnName)) {
+ continue;
+ }
+
+ $columnControl = $this->request->columnControl($index);
+ $list = $columnControl['list'] ?? [];
+ $search = $columnControl['search'] ?? [];
+ $value = $search['value'] ?? '';
+ $logic = $search['logic'] ?? 'equal';
+ $mask = $search['mask'] ?? ''; // for date type
+ $type = $search['type'] ?? 'text'; // text, num, date
+
+ if ($value != '' || str_contains(strtolower($logic), 'empty') || $list) {
+ $operator = match ($logic) {
+ 'contains', 'notContains', 'starts', 'ends' => 'LIKE',
+ 'greater' => '>',
+ 'less' => '<',
+ 'greaterOrEqual' => '>=',
+ 'lessOrEqual' => '<=',
+ 'empty', 'notEmpty' => null,
+ default => '=',
+ };
+
+ switch ($logic) {
+ case 'contains':
+ case 'notContains':
+ $value = '%'.$value.'%';
+ break;
+ case 'starts':
+ $value = $value.'%';
+ break;
+ case 'ends':
+ $value = '%'.$value;
+ break;
+ }
+
+ if ($this->hasFilterColumn($columnName)) {
+ $value = $list ? implode(', ', $list) : $value;
+ $this->applyFilterColumn($this->getBaseQueryBuilder(), $columnName, $value);
+
+ continue;
+ }
+
+ // Only resolve relation after checking for a custom filter.
+ // Because the custom filter for a column might not be found, e.g., $this->hasFilterColumn($columnName)
+ // and applyFilterColumn() already resolves relations
+ $columnName = $this->resolveRelationColumn($columnName);
+
+ if ($list) {
+ if (str_contains($logic, 'not')) {
+ $this->query->whereNotIn($columnName, $list);
+ } else {
+ $this->query->whereIn($columnName, $list);
+ }
+
+ continue;
+ }
+
+ if (str_contains(strtolower($logic), 'empty')) {
+ $this->query->whereNull($columnName, not: $logic === 'notEmpty');
+
+ continue;
+ }
+
+ if ($type === 'date') {
+ try {
+ // column control replaces / with - on date value
+ if ($mask && str_contains($mask, '/')) {
+ $value = str_replace('-', '/', $value);
+ }
+
+ $value = $mask ? Carbon::createFromFormat($mask, $value) : Carbon::parse($value);
+
+ if ($logic === 'notEqual') {
+ $this->query->where(function ($q) use ($columnName, $value) {
+ $q->whereDate($columnName, '!=', $value)->orWhereNull($columnName);
+ });
+ } else {
+ $this->query->whereDate($columnName, $operator, $value);
+ }
+ } catch (\Exception) {
+ // can't parse date
+ }
+
+ continue;
+ }
+
+ if (str_contains($logic, 'not')) {
+ $this->query->whereNot($columnName, $operator, $value);
+
+ continue;
+ }
+
+ $this->query->where($columnName, $operator, $value);
}
}
}
@@ -343,7 +463,7 @@ protected function applyFilterColumn($query, string $columnName, string $keyword
$builder = $this->query->newQuery();
}
- $callback($builder, $keyword);
+ $callback($builder, $keyword, fn ($column) => $this->resolveRelationColumn($column));
/** @var \Illuminate\Database\Query\Builder $baseQueryBuilder */
$baseQueryBuilder = $this->getBaseQueryBuilder($builder);
@@ -377,11 +497,11 @@ public function getQuery(): QueryBuilder
}
/**
- * Resolve the proper column name be used.
+ * Resolve the proper column name to be used.
*/
protected function resolveRelationColumn(string $column): string
{
- return $column;
+ return $this->addTablePrefix($this->query, $column);
}
/**
@@ -444,7 +564,7 @@ protected function castColumn(string $column): string
*/
protected function compileQuerySearch($query, string $column, string $keyword, string $boolean = 'or'): void
{
- $column = $this->addTablePrefix($query, $column);
+ $column = $this->wrap($this->addTablePrefix($query, $column));
$column = $this->castColumn($column);
$sql = $column.' LIKE ?';
@@ -463,20 +583,96 @@ protected function compileQuerySearch($query, string $column, string $keyword, s
*/
protected function addTablePrefix($query, string $column): string
{
- if (! str_contains($column, '.')) {
- $q = $this->getBaseQueryBuilder($query);
- $from = $q->from ?? '';
+ // Column is already prefixed
+ if (str_contains($column, '.')) {
+ return $column;
+ }
- if (! $from instanceof Expression) {
- if (str_contains((string) $from, ' as ')) {
- $from = explode(' as ', (string) $from)[1];
- }
+ // Extract selected columns from the query
+ $selects = $this->getSelectedColumns($query);
+
+ // We have a match
+ if (isset($selects['columns'][$column])) {
+ return $selects['columns'][$column];
+ }
+
+ // Multiple wildcards => Unable to determine prefix
+ if (in_array('*', $selects['wildcards']) || count(array_unique($selects['wildcards'])) > 1) {
+ return $column;
+ }
+
+ // Use the only wildcard available
+ if (! empty($selects['wildcards'])) {
+ return $selects['wildcards'][0].'.'.$column;
+ }
+
+ // Fallback on table prefix
+ return ltrim($this->getTablePrefix($query).'.'.$column, '.');
+ }
+
+ /**
+ * Try to get the base table prefix.
+ * To be used to prevent ambiguous field name.
+ *
+ * @param QueryBuilder|EloquentBuilder $query
+ */
+ protected function getTablePrefix($query): ?string
+ {
+ $q = $this->getBaseQueryBuilder($query);
+ $from = $q->from ?? '';
- $column = $from.'.'.$column;
+ if (! $from instanceof Expression) {
+ if (str_contains((string) $from, ' as ')) {
+ $from = explode(' as ', (string) $from)[1];
}
+
+ return $from;
}
- return $this->wrap($column);
+ return null;
+ }
+
+ /**
+ * Get declared column names from the query.
+ *
+ * @param QueryBuilder|EloquentBuilder $query
+ */
+ protected function getSelectedColumns($query): array
+ {
+ $q = $this->getBaseQueryBuilder($query);
+
+ $selects = [
+ 'wildcards' => [],
+ 'columns' => [],
+ ];
+
+ foreach ($q->columns ?? [] as $select) {
+ $sql = trim($select instanceof Expression ? $select->getValue($this->getConnection()->getQueryGrammar()) : (string) $select);
+ // Remove expressions
+ $sql = preg_replace('/\s*\w*\((?:[^()]*|(?R))*\)/', '_', $sql);
+ // Remove multiple spaces
+ $sql = preg_replace('/\s+/', ' ', (string) $sql);
+ // Remove wrappers
+ $sql = str_replace(['`', '"', '[', ']'], '', $sql);
+ // Loop on select columns
+ foreach (explode(',', $sql) as $column) {
+ $column = trim($column);
+ if (preg_match('/[\w.]+\s+(?:as\s+)?([a-zA-Z0-9_]+)$/i', $column, $matches)) {
+ // Column with alias
+ $selects['columns'][$matches[1]] = $matches[1];
+ } elseif (preg_match('/^([\w.]+)$/i', $column)) {
+ // Column without alias
+ [$table, $name] = str_contains($column, '.') ? explode('.', $column) : [null, $column];
+ if ($name === '*') {
+ $selects['wildcards'][] = $table ?? '*';
+ } else {
+ $selects['columns'][$name] = $column;
+ }
+ }
+ }
+ }
+
+ return $selects;
}
/**
@@ -540,7 +736,7 @@ public function orderColumns(array $columns, $sql, $bindings = []): static
* @param array $bindings
* @return $this
*
- * @internal string $1 Special variable that returns the requested order direction of the column.
+ * string $1 Special variable that returns the requested order direction of the column.
*/
public function orderColumn($column, $sql, $bindings = []): static
{
@@ -609,10 +805,6 @@ public function addColumn($name, $content, $order = false): static
/**
* Perform search using search pane values.
- *
- *
- * @throws \Psr\Container\ContainerExceptionInterface
- * @throws \Psr\Container\NotFoundExceptionInterface
*/
protected function searchPanesSearch(): void
{
@@ -639,7 +831,7 @@ protected function searchPanesSearch(): void
*/
protected function resolveCallbackParameter(): array
{
- return [$this->query, $this->scoutSearched];
+ return [$this->query, $this->scoutSearched, fn ($column) => $this->resolveRelationColumn($column)];
}
/**
@@ -659,13 +851,10 @@ protected function defaultOrdering(): void
})
->reject(fn ($orderable) => $this->isBlacklisted($orderable['name']) && ! $this->hasOrderColumn($orderable['name']))
->each(function ($orderable) {
- $column = $this->resolveRelationColumn($orderable['name']);
-
if ($this->hasOrderColumn($orderable['name'])) {
- $this->applyOrderColumn($orderable['name'], $orderable);
- } elseif ($this->hasOrderColumn($column)) {
- $this->applyOrderColumn($column, $orderable);
+ $this->applyOrderColumn($orderable);
} else {
+ $column = $this->resolveRelationColumn($orderable['name']);
$nullsLastSql = $this->getNullsLastSql($column, $orderable['direction']);
$normalSql = $this->wrap($column).' '.$orderable['direction'];
$sql = $this->nullsLast ? $nullsLastSql : $normalSql;
@@ -685,18 +874,18 @@ protected function hasOrderColumn(string $column): bool
/**
* Apply orderColumn custom query.
*/
- protected function applyOrderColumn(string $column, array $orderable): void
+ protected function applyOrderColumn(array $orderable): void
{
- $sql = $this->columnDef['order'][$column]['sql'];
+ $sql = $this->columnDef['order'][$orderable['name']]['sql'];
if ($sql === false) {
return;
}
if (is_callable($sql)) {
- call_user_func($sql, $this->query, $orderable['direction']);
+ call_user_func($sql, $this->query, $orderable['direction'], fn ($column) => $this->resolveRelationColumn($column));
} else {
$sql = str_replace('$1', $orderable['direction'], (string) $sql);
- $bindings = $this->columnDef['order'][$column]['bindings'];
+ $bindings = $this->columnDef['order'][$orderable['name']]['bindings'];
$this->query->orderByRaw($sql, $bindings);
}
}
diff --git a/src/Utilities/Helper.php b/src/Utilities/Helper.php
index a859f17d..b0e87127 100644
--- a/src/Utilities/Helper.php
+++ b/src/Utilities/Helper.php
@@ -6,6 +6,7 @@
use DateTime;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
use ReflectionFunction;
use ReflectionMethod;
@@ -124,12 +125,7 @@ public static function compileBlade(string $str, array $data = []): false|string
return view($str, $data)->render();
}
- ob_start() && extract($data, EXTR_SKIP);
- eval('?>'.app('blade.compiler')->compileString($str));
- $str = ob_get_contents();
- ob_end_clean();
-
- return $str;
+ return Blade::render($str, $data);
}
/**
diff --git a/src/Utilities/Request.php b/src/Utilities/Request.php
index 97150b87..c26d9102 100644
--- a/src/Utilities/Request.php
+++ b/src/Utilities/Request.php
@@ -9,16 +9,6 @@
*/
class Request
{
- protected BaseRequest $request;
-
- /**
- * Request constructor.
- */
- public function __construct()
- {
- $this->request = app('request');
- }
-
/**
* Proxy non-existing method calls to base request class.
*
@@ -28,12 +18,22 @@ public function __construct()
*/
public function __call($name, $arguments)
{
- $callback = [$this->request, $name];
+ $callback = [request(), $name];
if (is_callable($callback)) {
return call_user_func_array($callback, $arguments);
}
}
+ /**
+ * Determine if an attribute exists on the base request.
+ *
+ * @param string $name
+ */
+ public function __isset($name): bool
+ {
+ return isset(request()->$name);
+ }
+
/**
* Get attributes from request instance.
*
@@ -42,7 +42,7 @@ public function __call($name, $arguments)
*/
public function __get($name)
{
- return $this->request->__get($name);
+ return request()->__get($name);
}
/**
@@ -50,7 +50,7 @@ public function __get($name)
*/
public function columns(): array
{
- return (array) $this->request->input('columns');
+ return (array) request()->input('columns');
}
/**
@@ -58,7 +58,7 @@ public function columns(): array
*/
public function isSearchable(): bool
{
- return $this->request->input('search.value') != '';
+ return request()->input('search.value') != '';
}
/**
@@ -66,7 +66,7 @@ public function isSearchable(): bool
*/
public function isRegex(int $index): bool
{
- return $this->request->input("columns.$index.search.regex") === 'true';
+ return request()->input("columns.$index.search.regex") === 'true';
}
/**
@@ -79,12 +79,12 @@ public function orderableColumns(): array
}
$orderable = [];
- for ($i = 0, $c = count((array) $this->request->input('order')); $i < $c; $i++) {
+ for ($i = 0, $c = count((array) request()->input('order')); $i < $c; $i++) {
/** @var int $order_col */
- $order_col = $this->request->input("order.$i.column");
+ $order_col = request()->input("order.$i.column");
/** @var string $direction */
- $direction = $this->request->input("order.$i.dir");
+ $direction = request()->input("order.$i.dir");
$order_dir = $direction && strtolower($direction) === 'asc' ? 'asc' : 'desc';
if ($this->isColumnOrderable($order_col)) {
@@ -100,7 +100,7 @@ public function orderableColumns(): array
*/
public function isOrderable(): bool
{
- return $this->request->input('order') && count((array) $this->request->input('order')) > 0;
+ return request()->input('order') && count((array) request()->input('order')) > 0;
}
/**
@@ -108,7 +108,7 @@ public function isOrderable(): bool
*/
public function isColumnOrderable(int $index): bool
{
- return $this->request->input("columns.$index.orderable", 'true') == 'true';
+ return request()->input("columns.$index.orderable", 'true') == 'true';
}
/**
@@ -119,7 +119,7 @@ public function isColumnOrderable(int $index): bool
public function searchableColumnIndex()
{
$searchable = [];
- $columns = (array) $this->request->input('columns');
+ $columns = (array) request()->input('columns');
for ($i = 0, $c = count($columns); $i < $c; $i++) {
if ($this->isColumnSearchable($i, false)) {
$searchable[] = $i;
@@ -137,17 +137,17 @@ public function isColumnSearchable(int $i, bool $column_search = true): bool
if ($column_search) {
return
(
- $this->request->input("columns.$i.searchable", 'true') === 'true'
+ request()->input("columns.$i.searchable", 'true') === 'true'
||
- $this->request->input("columns.$i.searchable", 'true') === true
+ request()->input("columns.$i.searchable", 'true') === true
)
&& $this->columnKeyword($i) != '';
}
return
- $this->request->input("columns.$i.searchable", 'true') === 'true'
+ request()->input("columns.$i.searchable", 'true') === 'true'
||
- $this->request->input("columns.$i.searchable", 'true') === true;
+ request()->input("columns.$i.searchable", 'true') === true;
}
/**
@@ -156,11 +156,21 @@ public function isColumnSearchable(int $i, bool $column_search = true): bool
public function columnKeyword(int $index): string
{
/** @var string $keyword */
- $keyword = $this->request->input("columns.$index.search.value") ?? '';
+ $keyword = request()->input("columns.$index.search.value") ?? '';
return $this->prepareKeyword($keyword);
}
+ public function columnControl(int $index): array
+ {
+ return request()->array("columns.$index.columnControl");
+ }
+
+ public function columnControlSearch(int $index): array
+ {
+ return request()->array("columns.$index.columnControl.search");
+ }
+
/**
* Prepare keyword string value.
*/
@@ -179,7 +189,7 @@ protected function prepareKeyword(float|array|int|string $keyword): string
public function keyword(): string
{
/** @var string $keyword */
- $keyword = $this->request->input('search.value') ?? '';
+ $keyword = request()->input('search.value') ?? '';
return $this->prepareKeyword($keyword);
}
@@ -190,7 +200,7 @@ public function keyword(): string
public function columnName(int $i): ?string
{
/** @var string[] $column */
- $column = $this->request->input("columns.$i");
+ $column = request()->input("columns.$i");
return (isset($column['name']) && $column['name'] != '') ? $column['name'] : $column['data'];
}
@@ -200,14 +210,14 @@ public function columnName(int $i): ?string
*/
public function isPaginationable(): bool
{
- return ! is_null($this->request->input('start')) &&
- ! is_null($this->request->input('length')) &&
- $this->request->input('length') != -1;
+ return ! is_null(request()->input('start')) &&
+ ! is_null(request()->input('length')) &&
+ request()->input('length') != -1;
}
public function getBaseRequest(): BaseRequest
{
- return $this->request;
+ return request();
}
/**
@@ -215,7 +225,7 @@ public function getBaseRequest(): BaseRequest
*/
public function start(): int
{
- $start = $this->request->input('start', 0);
+ $start = request()->input('start', 0);
return is_numeric($start) ? intval($start) : 0;
}
@@ -225,7 +235,7 @@ public function start(): int
*/
public function length(): int
{
- $length = $this->request->input('length', 10);
+ $length = request()->input('length', 10);
return is_numeric($length) ? intval($length) : 10;
}
@@ -235,7 +245,7 @@ public function length(): int
*/
public function draw(): int
{
- $draw = $this->request->input('draw', 0);
+ $draw = request()->input('draw', 0);
return is_numeric($draw) ? intval($draw) : 0;
}
diff --git a/src/helper.php b/src/helper.php
index ec17d6f1..7aa58a1a 100644
--- a/src/helper.php
+++ b/src/helper.php
@@ -6,7 +6,7 @@
* Or return the factory if source is not set.
*
* @param \Illuminate\Contracts\Database\Query\Builder|\Illuminate\Contracts\Database\Eloquent\Builder|\Illuminate\Support\Collection|array|null $source
- * @return \Yajra\DataTables\DataTables|\Yajra\DataTables\DataTableAbstract
+ * @return ($source is null ? \Yajra\DataTables\DataTables : \Yajra\DataTables\DataTableAbstract)
*
* @throws \Yajra\DataTables\Exceptions\Exception
*/
diff --git a/tests/Integration/CustomOrderTest.php b/tests/Integration/CustomOrderTest.php
index d4664405..f7281f3e 100644
--- a/tests/Integration/CustomOrderTest.php
+++ b/tests/Integration/CustomOrderTest.php
@@ -54,8 +54,8 @@ protected function setUp(): void
parent::setUp();
$this->app['router']->get('/relations/belongsTo', fn (DataTables $datatables) => $datatables->eloquent(Post::with('user')->select('posts.*'))
- ->orderColumn('user.id', function ($query, $order) {
- $query->orderBy('users.id', $order == 'desc' ? 'asc' : 'desc');
+ ->orderColumn('user.id', function ($query, $order, $resolver) {
+ $query->orderBy($resolver('user.id'), $order == 'desc' ? 'asc' : 'desc');
})
->toJson());
}
diff --git a/tests/Integration/MinSearchLengthDataTableTest.php b/tests/Integration/MinSearchLengthDataTableTest.php
new file mode 100644
index 00000000..fcab93d4
--- /dev/null
+++ b/tests/Integration/MinSearchLengthDataTableTest.php
@@ -0,0 +1,99 @@
+call('GET', '/eloquent/min-length', [
+ 'start' => 0,
+ 'length' => 10,
+ 'columns' => [
+ ['data' => 'id'],
+ ['data' => 'name'],
+ ['data' => 'email'],
+ ],
+ 'search' => [
+ 'value' => '',
+ 'regex' => false,
+ ],
+ ]);
+
+ $crawler->assertJson([
+ 'draw' => 0,
+ 'recordsTotal' => 20,
+ 'recordsFiltered' => 20,
+ ]);
+ }
+
+ #[Test]
+ public function it_returns_an_error_when_search_keyword_length_is_less_than_required()
+ {
+ $crawler = $this->call('GET', '/eloquent/min-length', [
+ 'start' => 0,
+ 'length' => 10,
+ 'columns' => [
+ ['data' => 'id'],
+ ['data' => 'name'],
+ ['data' => 'email'],
+ ],
+ 'search' => [
+ 'value' => 'abc',
+ 'regex' => false,
+ ],
+ ]);
+
+ $crawler->assertJson([
+ 'draw' => 0,
+ 'recordsTotal' => 0,
+ 'recordsFiltered' => 0,
+ 'data' => [],
+ 'error' => "Exception Message:\n\nPlease enter at least 5 characters to search.",
+ ]);
+ }
+
+ #[Test]
+ public function it_returns_filtered_records_when_search_keyword_length_is_met()
+ {
+ $crawler = $this->call('GET', '/eloquent/min-length', [
+ 'draw' => 1,
+ 'start' => 0,
+ 'length' => 10,
+ 'columns' => [
+ ['data' => 'id'],
+ ['data' => 'name'],
+ ['data' => 'email'],
+ ],
+ 'search' => [
+ 'value' => 'Record-17',
+ 'regex' => false,
+ ],
+ ]);
+
+ $crawler->assertJson([
+ 'draw' => 1,
+ 'recordsTotal' => 20,
+ 'recordsFiltered' => 1,
+ ]);
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Route::get('/eloquent/min-length', fn () => (new EloquentDataTable(User::query()))
+ ->minSearchLength(5)
+ ->toJson());
+ }
+}
diff --git a/tests/Integration/QueryDataTableTest.php b/tests/Integration/QueryDataTableTest.php
index 24aeb8f2..c6477f35 100644
--- a/tests/Integration/QueryDataTableTest.php
+++ b/tests/Integration/QueryDataTableTest.php
@@ -8,6 +8,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\TestWith;
use Yajra\DataTables\DataTables;
use Yajra\DataTables\Facades\DataTables as DatatablesFacade;
use Yajra\DataTables\QueryDataTable;
@@ -222,7 +223,7 @@ public function it_returns_only_the_selected_columns()
}
#[Test]
- public function it_edit_only_the_selected_columns_after_using_editOnlySelectedColumns()
+ public function it_edit_only_the_selected_columns_after_using_edit_only_selected_columns()
{
$json = $this->call('GET', '/query/edit-columns', [
'columns' => [
@@ -406,6 +407,42 @@ public function it_can_return_added_column_with_dependency_injection()
$this->assertEquals($user->name.'_di', $data['name_di']);
}
+ #[Test]
+ #[TestWith(['title', '"posts"."title"'])] // column from base table wildcard posts.*
+ #[TestWith(['email', '"users"."email"'])] // column from join table added to select without alias
+ #[TestWith(['alias_field_with_as', '"alias_field_with_as"'])]
+ #[TestWith(['alias_field_without_as', '"alias_field_without_as"'])]
+ #[TestWith(['alias_expression_with_as', '"alias_expression_with_as"'])]
+ #[TestWith(['alias_expression_without_as', '"alias_expression_without_as"'])]
+ #[TestWith(['alias_case_with_as', '"alias_case_with_as"'])]
+ #[TestWith(['alias_case_without_as', '"alias_case_without_as"'])]
+ #[TestWith(['sub_query', '"sub_query"'])]
+ public function it_can_detect_column_alias(string $column, string $expected)
+ {
+ DB::enableQueryLog();
+
+ $crawler = $this->call('GET', '/query/aliases', [
+ 'columns' => [
+ ['data' => $column, 'name' => $column, 'orderable' => 'true'],
+ ],
+ 'order' => [['column' => 0, 'dir' => 'asc']],
+ ]);
+
+ $crawler->assertJsonStructure([
+ 'draw',
+ 'recordsTotal',
+ 'recordsFiltered',
+ ]);
+
+ DB::disableQueryLog();
+ $queryLog = DB::getQueryLog();
+
+ $this->assertCount(2, $queryLog);
+
+ $sql = end($queryLog)['query'];
+ $this->assertStringContainsString("order by $expected asc", $sql);
+ }
+
protected function setUp(): void
{
parent::setUp();
@@ -497,5 +534,23 @@ protected function setUp(): void
$router->get('/closure-di', fn (DataTables $dataTable) => $dataTable->query(DB::table('users'))
->addColumn('name_di', fn ($user, User $u) => $u->newQuery()->find($user->id)->name.'_di')
->toJson());
+
+ $router->get('/query/aliases', fn (DataTables $dataTable) => $dataTable->query(
+ DB::table('posts')
+ ->join('users', 'users.id', '=', 'posts.user_id')
+ ->select([
+ 'posts.*',
+ 'users.email', // From join table, without alias
+ 'posts.id as alias_field_with_as',
+ DB::raw('posts.id alias_field_without_as'),
+ DB::raw('(1 + 1) as alias_expression_with_as'),
+ DB::raw('(SELECT 1) alias_expression_without_as'),
+ DB::raw('CASE WHEN 1 THEN 1 ELSE 2 END as alias_case_with_as'),
+ DB::raw('CASE WHEN 0 THEN 1 ELSE 2 END alias_case_without_as'),
+ 'sub_query' => DB::table('users')
+ ->whereColumn('posts.user_id', 'users.id')
+ ->select('name'),
+ ])
+ )->toJson());
}
}
diff --git a/tests/Unit/QueryDataTableTest.php b/tests/Unit/QueryDataTableTest.php
index 4e7ee785..20864a72 100644
--- a/tests/Unit/QueryDataTableTest.php
+++ b/tests/Unit/QueryDataTableTest.php
@@ -4,6 +4,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
+use PHPUnit\Framework\Attributes\Test;
use Yajra\DataTables\Tests\Models\User;
use Yajra\DataTables\Tests\TestCase;
@@ -171,4 +172,42 @@ public function assertQueryHasNoSelect($expected, $query): void
$this->assertSame($expected, Str::startsWith($sql, 'select count(*) from (select 1 as dt_row_count from'), "'{$sql}' has select");
}
+
+ #[Test]
+ public function test_column_name_is_resolved_in_column_control(): void
+ {
+ app('datatables.request')->merge([
+ 'columns' => [
+ [
+ 'name' => 'id',
+ 'data' => 'id',
+ 'searchable' => 'true',
+ 'orderable' => 'true',
+ 'search' => ['value' => null, 'regex' => 'false'],
+ 'columnControl' => [
+ 'search' => [
+ 'value' => '123',
+ 'logic' => 'equal',
+ 'type' => 'num',
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ /** @var \Yajra\DataTables\QueryDataTable $dataTable */
+ $dataTable = app('datatables')->of(
+ User::query()
+ ->select('users.*')
+ ->join('role_user', 'users.id', '=', 'role_user.user_id')
+ ->join('roles', 'role_user.role_id', '=', 'roles.id')
+ );
+
+ $dataTable->columnControlSearch();
+
+ $this->assertStringContainsString(
+ '"users"."id" = \'123\'',
+ $dataTable->getQuery()->toRawSql()
+ );
+ }
}