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 @@ [![Donate](https://img.shields.io/badge/donate-paypal-blue.svg)](https://www.paypal.me/yajra) [![Donate](https://img.shields.io/badge/donate-patreon-blue.svg)](https://www.patreon.com/bePatron?u=4521203) -[![Laravel 4.2|5.x|6|7|8|9|10|11](https://img.shields.io/badge/Laravel-4.2|5.x|6|7|8|9|10|11-orange.svg)](http://laravel.com) +[![Laravel 12](https://img.shields.io/badge/Laravel-12-orange.svg)](http://laravel.com) [![Latest Stable Version](https://img.shields.io/packagist/v/yajra/laravel-datatables-oracle.svg)](https://packagist.org/packages/yajra/laravel-datatables-oracle) [![Continuous Integration](https://github.com/yajra/laravel-datatables/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/yajra/laravel-datatables/actions/workflows/continuous-integration.yml) [![Static Analysis](https://github.com/yajra/laravel-datatables/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/yajra/laravel-datatables/actions/workflows/static-analysis.yml) @@ -31,7 +31,9 @@ return DataTables::make(User::all())->toJson(); - + @@ -40,8 +42,10 @@ return DataTables::make(User::all())->toJson();
DataTables Logo + DataTables Logo + A big thank you to DataTables for supporting this project with a free DataTables Editor license.
- - + +
JetBrains LogoA big thank you to JetBrains for supporting this project with free open-source licenses of their IDEs. + JetBrains logo. + 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(); - + @@ -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() + ); + } }
Blackfire.io LogoBlackfire.io Logo A big thank you to Blackfire.io for supporting this project with a free open-source license.