diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 5cb492da..357de9ad 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 ] + 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/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/CHANGELOG.md b/CHANGELOG.md index 4e564b6c..25c92549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,43 +4,31 @@ ### [Unreleased] -### [v11.1.6](https://github.com/yajra/laravel-datatables/compare/v11.1.5...v11.1.6) - 2025-01-21 +### v12.2.0 - 2025-05-08 -- fix: static analysis #3213 -- ci: update workflow #3213 +- feat: add relation resolver param to order callback #3232 +- fix: improve column alias detection #3236 +- fix: #3235 -### [v11.1.5](https://github.com/yajra/laravel-datatables/compare/v11.1.4...v11.1.5) - 2024-09-26 +### v12.1.2 - 2025-05-07 -- Add skip total records back #3170 -- Alternative to #3169. -- Partially reverts #3157. +- fix: prevent prefixing null/empty string #3233 -### [v11.1.4](https://github.com/yajra/laravel-datatables/compare/v11.1.3...v11.1.4) - 2024-08-17 +### v12.1.1 - 2025-05-05 -- fix: Ensure dates are not turned into arrays by the processor #3163 -- fix: ##3156 +- fix: prevent ambiguous column names #3227 -### [v11.1.3](https://github.com/yajra/laravel-datatables/compare/v11.1.2...v11.1.3) - 2024-07-15 +### v12.1.0 - 2025-04-28 -- fix: make query for filteredRecords when totalRecords was manually set #3157 +- feat: add relation resolver param to filter callbacks #3229 -### [v11.1.2](https://github.com/yajra/laravel-datatables/compare/v11.1.1...v11.1.2) - 2024-07-03 +### v12.0.1 - 2025-04-07 -- fix: ErrorException when direction is null #3154 +- fix: query results improvements #3224 -### [v11.1.1](https://github.com/yajra/laravel-datatables/compare/v11.1.0...v11.1.1) - 2024-04-16 +### v12.0.0 - 2025-02-26 -- fix: mariadb support for scout search #3146 - -### [v11.1.0](https://github.com/yajra/laravel-datatables/compare/v11.0.0...v11.1.0) - 2024-04-16 - -- feat: Optimize simple queries #3135 -- fix: #3133 - -### [v11.0.0](https://github.com/yajra/laravel-datatables/compare/v11.0.0...master) - 2024-03-14 - -- Laravel 11 support - - -[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..8c935a37 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) @@ -86,19 +86,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/DataTableAbstract.php b/src/DataTableAbstract.php index c4667d05..b599721f 100644 --- a/src/DataTableAbstract.php +++ b/src/DataTableAbstract.php @@ -779,7 +779,7 @@ protected function searchPanesSearch(): void /** * Count filtered items. */ - protected function filteredCount(): int + public function filteredCount(): int { return $this->filteredRecords ??= $this->count(); } diff --git a/src/EloquentDataTable.php b/src/EloquentDataTable.php index 8a6f154e..a19c7940 100644 --- a/src/EloquentDataTable.php +++ b/src/EloquentDataTable.php @@ -155,8 +155,7 @@ protected function isMorphRelation($relation) } /** - * Resolve the proper column name be used. - * + * {@inheritDoc} * * @throws \Yajra\DataTables\Exceptions\Exception */ @@ -164,10 +163,10 @@ protected function resolveRelationColumn(string $column): string { $parts = explode('.', $column); $columnName = array_pop($parts); - $relation = implode('.', $parts); + $relation = str_replace('[]', '', implode('.', $parts)); if ($this->isNotEagerLoaded($relation)) { - return $column; + return parent::resolveRelationColumn($column); } return $this->joinEagerLoadedColumn($relation, $columnName); @@ -184,54 +183,56 @@ protected function resolveRelationColumn(string $column): string */ protected function joinEagerLoadedColumn($relation, $relationColumn) { - $table = ''; + $tableAlias = ''; $lastQuery = $this->query; foreach (explode('.', $relation) as $eachRelation) { $model = $lastQuery->getRelation($eachRelation); + $lastAlias = $tableAlias ?: $this->getTablePrefix($lastQuery); + $tableAlias = $tableAlias.'_'.$eachRelation; + $pivotAlias = $tableAlias.'_pivot'; switch (true) { case $model instanceof BelongsToMany: - $pivot = $model->getTable(); - $pivotPK = $model->getExistenceCompareKey(); - $pivotFK = $model->getQualifiedParentKeyName(); + $pivot = $model->getTable().' as '.$pivotAlias; + $pivotPK = $pivotAlias.'.'.$model->getForeignPivotKeyName(); + $pivotFK = ltrim($lastAlias.'.'.$model->getParentKeyName(), '.'); $this->performJoin($pivot, $pivotPK, $pivotFK); $related = $model->getRelated(); - $table = $related->getTable(); + $table = $related->getTable().' as '.$tableAlias; $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(); + $pivot = explode('.', $model->getQualifiedParentKeyName())[0].' as '.$pivotAlias; // extract pivot table from key + $pivotPK = $pivotAlias.'.'.$model->getFirstKeyName(); + $pivotFK = ltrim($lastAlias.'.'.$model->getLocalKeyName(), '.'); $this->performJoin($pivot, $pivotPK, $pivotFK); $related = $model->getRelated(); - $table = $related->getTable(); + $table = $related->getTable().' as '.$tableAlias; $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(); + $table = $model->getRelated()->getTable().' as '.$tableAlias; + $foreign = $tableAlias.'.'.$model->getForeignKeyName(); + $other = ltrim($lastAlias.'.'.$model->getLocalKeyName(), '.'); break; case $model instanceof BelongsTo: - $table = $model->getRelated()->getTable(); - $foreign = $model->getQualifiedForeignKeyName(); - $other = $model->getQualifiedOwnerKeyName(); + $table = $model->getRelated()->getTable().' as '.$tableAlias; + $foreign = ltrim($lastAlias.'.'.$model->getForeignKeyName(), '.'); + $other = $tableAlias.'.'.$model->getOwnerKeyName(); break; default: @@ -241,7 +242,7 @@ protected function joinEagerLoadedColumn($relation, $relationColumn) $lastQuery = $model->getQuery(); } - return $table.'.'.$relationColumn; + return $tableAlias.'.'.$relationColumn; } /** diff --git a/src/QueryDataTable.php b/src/QueryDataTable.php index f6610dad..dd09aabe 100644 --- a/src/QueryDataTable.php +++ b/src/QueryDataTable.php @@ -80,6 +80,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'); @@ -130,11 +137,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(); } /** @@ -343,7 +350,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 +384,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 +451,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 +470,47 @@ 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]; - } + $q = $this->getBaseQueryBuilder($query); + + // Column is an alias, no prefix required + foreach ($q->columns ?? [] as $select) { + $sql = trim($select instanceof Expression ? $select->getValue($this->getConnection()->getQueryGrammar()) : $select); + $match = preg_quote($column).'\b|'.preg_quote($this->wrap($column)); + if (preg_match("/(\s)as(\s+)($match)/i", $sql)) { + return $column; + } + } + + $prefix = $this->getTablePrefix($query); - $column = $from.'.'.$column; + return $prefix ? $prefix.'.'.$column : $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 ?? ''; + + if (! $from instanceof Expression) { + if (str_contains((string) $from, ' as ')) { + $from = explode(' as ', (string) $from)[1]; } + + return $from; } - return $this->wrap($column); + return null; } /** @@ -639,7 +673,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 +693,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 +716,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/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/QueryDataTableTest.php b/tests/Integration/QueryDataTableTest.php index 24aeb8f2..55b4dda7 100644 --- a/tests/Integration/QueryDataTableTest.php +++ b/tests/Integration/QueryDataTableTest.php @@ -222,7 +222,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' => [