Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,26 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `NestedSet::isNode()` always returned `false` for genuine nodes because it
inspected object properties instead of used traits. It now uses
`class_uses_recursive()`.
- Moving a node no longer breaks when the model defines a global scope that
adds columns (e.g. via `addSelect`). `getNodeData()` now selects only `lft`
and `rgt` and returns them in a known order, so the `[$lft, $rgt]` read can
no longer pick up an extra column (upstream issue #513).
- `columnPatch()` now renders a zero offset as `+0` rather than concatenating
it onto the column (`"_rgt"0`), which produced invalid SQL on stricter
drivers such as SQL Server (upstream PR #595).

### Added

- Native PHP type declarations across the library (PHP 8.3+).
- `isBroken()`, `countErrors()`, `getTotalErrors()`, `fixTree()` and
`fixSubtree()` accept an optional callback to customise the underlying query
— most usefully to drop a global scope that would otherwise interfere with
the integrity checks, e.g.
`Category::fixTree(null, fn ($q) => $q->withoutGlobalScopes())`
(upstream PR #536).
- `withDepth()` now honours global scopes removed from the outer query, so
`Model::withoutGlobalScope(...)->withDepth()` counts the same ancestors in
the depth subquery (upstream PR #300).
- Laravel Pint for code style.
- Larastan static analysis (level 5) with a baseline for pre-existing findings.
- Test suite now runs against SQLite, MySQL and PostgreSQL in CI, plus a code
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,20 @@ Category::fixTree();
Category::fixSubtree($root);
```

#### Customising the query

If your model defines a global scope that interferes with the integrity checks
(for example one that adds columns or self-join-unsafe `where` clauses), pass a
callback to customise the query used by `isBroken()`, `countErrors()`,
`getTotalErrors()`, `fixTree()` and `fixSubtree()`. The most common use is
dropping a global scope:

```php
Category::isBroken(fn ($query) => $query->withoutGlobalScopes());

Category::fixTree(null, fn ($query) => $query->withoutGlobalScopes());
```

### Scoping

Imagine you have `Menu` and `MenuItem` models with a one-to-many relationship, where
Expand Down
79 changes: 54 additions & 25 deletions src/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Lunar\Nestedset;

use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
Expand All @@ -25,18 +26,28 @@ class QueryBuilder extends Builder
*/
public function getNodeData(mixed $id, bool $required = false): array
{
$query = $this->toBase();
$lftName = $this->model->getLftName();
$rgtName = $this->model->getRgtName();

$query->where($this->model->getKeyName(), '=', $id);

$data = $query->first([$this->model->getLftName(),
$this->model->getRgtName()]);
// Select lft/rgt explicitly rather than passing them to first(), which
// is ignored when a global scope has already set the query columns
// (e.g. via addSelect). select() resets the columns so we always get
// exactly these two, regardless of any scope on the model.
$data = (array) $this->toBase()
->select([$lftName, $rgtName])
->where($this->model->getKeyName(), '=', $id)
->first();

if (! $data && $required) {
throw new ModelNotFoundException;
}

return (array) $data;
// Return lft/rgt in a known order and nothing else. A global scope may
// otherwise reorder or add columns, breaking getPlainNodeData()'s
// positional [$lft, $rgt] destructuring (see issue #513).
return $data
? [$lftName => $data[$lftName], $rgtName => $data[$rgtName]]
: [];
}

/**
Expand Down Expand Up @@ -301,6 +312,9 @@ public function withDepth(string $as = 'depth'): static

$query = $this->model
->newScopedQuery('_d')
// Carry over any global scopes removed from the outer query so the
// depth subquery counts the same set of ancestors.
->withoutGlobalScopes($this->removedScopes)
->toBase()
->selectRaw('count(1) - 1')
->from($this->model->getTable().' as '.$alias)
Expand Down Expand Up @@ -494,7 +508,10 @@ protected function columnPatch(string $col, array $params): Expression
extract($params);

/** @var int $height */
if ($height > 0) {
// Use >= 0 so that a height of 0 renders as "+0" rather than being
// concatenated onto the column (e.g. `"_rgt"0`), which is invalid SQL
// on stricter drivers such as SQL Server.
if ($height >= 0) {
$height = '+'.$height;
}

Expand All @@ -507,7 +524,7 @@ protected function columnPatch(string $col, array $params): Expression
/** @var int $rgt */
/** @var int $from */
/** @var int $to */
if ($distance > 0) {
if ($distance >= 0) {
$distance = '+'.$distance;
}

Expand All @@ -521,23 +538,27 @@ protected function columnPatch(string $col, array $params): Expression
/**
* Get statistics of errors of the tree.
*
* The optional callback receives each check's query builder, allowing the
* caller to customise it — for example to drop a global scope that would
* otherwise interfere with the integrity checks.
*
* @since 2.0
*/
public function countErrors(): array
public function countErrors(?Closure $callback = null): array
{
$checks = [];

// Check if lft and rgt values are ok
$checks['oddness'] = $this->getOdnessQuery();
$checks['oddness'] = $this->getOdnessQuery($callback);

// Check if lft and rgt values are unique
$checks['duplicates'] = $this->getDuplicatesQuery();
$checks['duplicates'] = $this->getDuplicatesQuery($callback);

// Check if parent_id is set correctly
$checks['wrong_parent'] = $this->getWrongParentQuery();
$checks['wrong_parent'] = $this->getWrongParentQuery($callback);

// Check for nodes that have missing parent
$checks['missing_parent'] = $this->getMissingParentQuery();
$checks['missing_parent'] = $this->getMissingParentQuery($callback);

$query = $this->query->newQuery();

Expand All @@ -550,10 +571,11 @@ public function countErrors(): array
return (array) $query->first();
}

protected function getOdnessQuery(): BaseQueryBuilder
protected function getOdnessQuery(?Closure $callback = null): BaseQueryBuilder
{
return $this->model
->newNestedSetQuery()
->when($callback, $callback)
->toBase()
->whereNested(function (BaseQueryBuilder $inner) {
[$lft, $rgt] = $this->wrappedColumns();
Expand All @@ -563,7 +585,7 @@ protected function getOdnessQuery(): BaseQueryBuilder
});
}

protected function getDuplicatesQuery(): BaseQueryBuilder
protected function getDuplicatesQuery(?Closure $callback = null): BaseQueryBuilder
{
$table = $this->wrappedTable();
$keyName = $this->wrappedKey();
Expand All @@ -576,6 +598,7 @@ protected function getDuplicatesQuery(): BaseQueryBuilder

$query = $this->model
->newNestedSetQuery($firstAlias)
->when($callback, $callback)
->toBase()
->from($this->query->raw("{$table} as {$waFirst}, {$table} {$waSecond}"))
->whereRaw("{$waFirst}.{$keyName} < {$waSecond}.{$keyName}")
Expand All @@ -591,7 +614,7 @@ protected function getDuplicatesQuery(): BaseQueryBuilder
return $this->model->applyNestedSetScope($query, $secondAlias);
}

protected function getWrongParentQuery(): BaseQueryBuilder
protected function getWrongParentQuery(?Closure $callback = null): BaseQueryBuilder
{
$table = $this->wrappedTable();
$keyName = $this->wrappedKey();
Expand All @@ -610,6 +633,7 @@ protected function getWrongParentQuery(): BaseQueryBuilder

$query = $this->model
->newNestedSetQuery('c')
->when($callback, $callback)
->toBase()
->from($this->query->raw("{$table} as {$waChild}, {$table} as {$waParent}, $table as {$waInterm}"))
->whereRaw("{$waChild}.{$parentIdName}={$waParent}.{$keyName}")
Expand All @@ -629,12 +653,13 @@ protected function getWrongParentQuery(): BaseQueryBuilder
return $query;
}

protected function getMissingParentQuery(): BaseQueryBuilder
protected function getMissingParentQuery(?Closure $callback = null): BaseQueryBuilder
{
return $this->model
->newNestedSetQuery()
->when($callback, $callback)
->toBase()
->whereNested(function (BaseQueryBuilder $inner) {
->whereNested(function (BaseQueryBuilder $inner) use ($callback) {
$grammar = $this->query->getGrammar();

$table = $this->wrappedTable();
Expand All @@ -645,6 +670,7 @@ protected function getMissingParentQuery(): BaseQueryBuilder

$existsCheck = $this->model
->newNestedSetQuery()
->when($callback, $callback)
->toBase()
->selectRaw('1')
->from($this->query->raw("{$table} as {$wrappedAlias}"))
Expand All @@ -663,19 +689,19 @@ protected function getMissingParentQuery(): BaseQueryBuilder
*
* @since 2.0
*/
public function getTotalErrors(): int
public function getTotalErrors(?Closure $callback = null): int
{
return array_sum($this->countErrors());
return array_sum($this->countErrors($callback));
}

/**
* Get whether the tree is broken.
*
* @since 2.0
*/
public function isBroken(): bool
public function isBroken(?Closure $callback = null): bool
{
return $this->getTotalErrors() > 0;
return $this->getTotalErrors($callback) > 0;
}

/**
Expand All @@ -684,9 +710,11 @@ public function isBroken(): bool
* Nodes with invalid parent are saved as roots.
*
* @param null|NodeTrait|Model $root
* @param null|Closure $callback Customise the query used to load nodes,
* e.g. to drop an interfering global scope.
* @return int The number of changed nodes
*/
public function fixTree($root = null): int
public function fixTree($root = null, ?Closure $callback = null): int
{
$columns = [
$this->model->getKeyName(),
Expand All @@ -697,6 +725,7 @@ public function fixTree($root = null): int

$dictionary = $this->model
->newNestedSetQuery()
->when($callback, $callback)
->when($root, function (self $query) use ($root) {
return $query->whereDescendantOf($root);
})
Expand All @@ -711,9 +740,9 @@ public function fixTree($root = null): int
/**
* @param NodeTrait|Model $root
*/
public function fixSubtree($root): int
public function fixSubtree($root, ?Closure $callback = null): int
{
return $this->fixTree($root);
return $this->fixTree($root, $callback);
}

/**
Expand Down
Loading
Loading