Skip to content

Commit 4b55765

Browse files
authored
Merge pull request #36 from efureev/Healthy
feat: add Checkers
2 parents 581edb7 + 9f80ff2 commit 4b55765

35 files changed

+473
-2357
lines changed

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## [5.1.0](https://github.com/efureev/laravel-trees/compare/v5.0.0...v5.1.0) (2025-03-08)
4+
5+
### Added
6+
7+
- Healthy Checkers
8+
9+
### Removed
10+
11+
- Remove `Healthy` trait
12+
313
## [5.0.0-rc1](https://github.com/efureev/laravel-trees/compare/v4.0.0...v5.0.0-rc1) (2024-04-01)
414

515
### Added
@@ -24,7 +34,7 @@
2434

2535
### Removed
2636

27-
- Removed support `Laravel 10.*`, `9.*`, `8.*`
37+
- Removed support `Laravel 10.*`, `9.*`, `8.*```
2838
- Removed support PHP `8.0`, `8.1`
2939

3040
## [3.8.2](https://github.com/efureev/laravel-trees/compare/v3.8.1...v3.8.2) (2023-09-11)

docs/HealthAndFix.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,35 @@
44

55
You can check whether a tree is broken (i.e. has some structural errors):
66

7-
> Use trait `Healthy` with `QueryBuilderV2`
7+
> Use helper class `HealthyChecker`
88
99
```php
10-
$bool = Category::isBroken();
10+
$checker = new HealthyChecker(Category::class);
11+
$broken = $checker->isBroken();
12+
```
13+
14+
Or use a specific checker:
15+
16+
```php
17+
$checker = new DuplicatesCheck(Category::class);
18+
$errorsCount = $checker->check();
1119
```
1220

1321
It is possible to get error statistics:
1422

1523
```php
16-
$data = Category::countErrors();
24+
$checker = new HealthyChecker(Category::class);
25+
$checker->check();
1726
```
1827

19-
It returns an array with following keys:
28+
List of checkers:
2029

21-
- `oddness` - the number of nodes that have wrong set of `lft` and `rgt` values
22-
- `duplicates` - the number of nodes that have same `lft` or `rgt` values
23-
- `wrong_parent` - the number of nodes that have invalid `parent_id` value that doesn't correspond to `lft` and `rgt`
30+
- `OddnessCheck` - the number of nodes that have wrong set of `lft` and `rgt` values
31+
- `DuplicatesCheck` - the number of nodes that have same `lft` or `rgt` values
32+
- `WrongParentCheck` - the number of nodes that have invalid `parent_id` value that doesn't correspond to `lft` and `rgt`
2433
values
25-
- `missing_parent` - the number of nodes that have `parent_id` pointing to node that doesn't exists
34+
- `MissingParentCheck` - the number of nodes that have `parent_id` pointing to node that doesn't exists
35+
2636

2737
## Fixing tree
2838

src/Healthy/AbstractCheck.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Query\Builder;
9+
10+
abstract readonly class AbstractCheck
11+
{
12+
protected Model $model;
13+
14+
public function __construct(Model|string $model)
15+
{
16+
if ($model instanceof Model) {
17+
$model = $model::class;
18+
}
19+
20+
$this->model = instance($model);
21+
}
22+
23+
abstract protected function query(): Builder;
24+
25+
public function check(): int
26+
{
27+
return $this->query()->count();
28+
}
29+
}

src/Healthy/DuplicatesCheck.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Fureev\Trees\QueryBuilderV2;
8+
use Illuminate\Database\Query\Builder;
9+
10+
final readonly class DuplicatesCheck extends AbstractCheck
11+
{
12+
protected function query(): Builder
13+
{
14+
$table = $this->model->wrappedTable();
15+
$keyName = $this->model->wrappedKey();
16+
17+
$firstAlias = 'c1';
18+
$secondAlias = 'c2';
19+
20+
$waFirst = $this->model->getQuery()->getGrammar()->wrapTable($firstAlias);
21+
$waSecond = $this->model->getQuery()->getGrammar()->wrapTable($secondAlias);
22+
23+
$isMulti = $this->model->isMulti();
24+
25+
/** @var QueryBuilderV2 $query */
26+
$query = $this->model->newNestedSetQuery($firstAlias);
27+
28+
$query
29+
->toBase()
30+
->from($this->model->getQuery()->raw("$table as $waFirst, $table as $waSecond"))
31+
->whereRaw("$waFirst.$keyName <> $waSecond.$keyName")
32+
->when(
33+
$isMulti,
34+
function (Builder $q) use ($waFirst, $waSecond) {
35+
$tid = (string)$this->model->treeAttribute();
36+
$q->whereRaw("$waFirst.$tid = $waSecond.$tid");
37+
}
38+
)
39+
->whereNested(
40+
function (Builder $inner) use ($waFirst, $waSecond, $query) {
41+
[
42+
$lft,
43+
$rgt,
44+
] = $query->wrappedColumns();
45+
46+
$inner
47+
->orWhereRaw("$waFirst.$lft=$waSecond.$lft")
48+
->orWhereRaw("$waFirst.$rgt=$waSecond.$rgt")
49+
->orWhereRaw("$waFirst.$lft=$waSecond.$rgt")
50+
->orWhereRaw("$waFirst.$rgt=$waSecond.$lft");
51+
}
52+
);
53+
54+
return $this->model->applyNestedSetScope($query, $secondAlias)->getQuery();
55+
}
56+
}

src/Healthy/HealthyChecker.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Illuminate\Database\Eloquent\Model;
8+
9+
final readonly class HealthyChecker
10+
{
11+
private Model $model;
12+
private array $checkers;
13+
14+
public function __construct(Model|string $model)
15+
{
16+
if ($model instanceof Model) {
17+
$model = $model::class;
18+
}
19+
20+
$this->model = instance($model);
21+
22+
$this->checkers = [
23+
OddnessCheck::class,
24+
DuplicatesCheck::class,
25+
WrongParentCheck::class,
26+
// MissingParentCheck::class,
27+
];
28+
}
29+
30+
private function checkOne(string $checker): int
31+
{
32+
/** @var AbstractCheck $checker */
33+
$checker = instance($checker, $this->model);
34+
35+
return $checker->check();
36+
}
37+
38+
/**
39+
* Get statistics of errors of the tree.
40+
*/
41+
public function check(): array
42+
{
43+
$checks = [];
44+
45+
foreach ($this->checkers as $checker) {
46+
$checks[class_basename($checker)] = $this->checkOne($checker);
47+
}
48+
49+
return $checks;
50+
}
51+
52+
/**
53+
* Get the number of total errors of the tree.
54+
*/
55+
public function getTotalErrors(): int
56+
{
57+
return (int)array_sum($this->check());
58+
}
59+
60+
/**
61+
* Get whether the tree is broken.
62+
*/
63+
public function isBroken(): bool
64+
{
65+
return $this->getTotalErrors() > 0;
66+
}
67+
}

src/Healthy/MissingParentCheck.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Fureev\Trees\QueryBuilderV2;
8+
use Illuminate\Database\Query\Builder;
9+
10+
final readonly class MissingParentCheck extends AbstractCheck
11+
{
12+
protected function query(): Builder
13+
{
14+
/** @var QueryBuilderV2 $builder */
15+
$builder = $this->model->newNestedSetQuery();
16+
17+
return $builder
18+
->toBase()
19+
->whereNested(
20+
function (Builder $inner) use ($builder) {
21+
$table = $builder->wrappedTable();
22+
$keyName = $builder->wrappedKey();
23+
24+
$grammar = $builder->getGrammar();
25+
26+
$parentIdName = $grammar->wrap((string)$this->model->parentAttribute());
27+
$alias = 'p';
28+
$wrappedAlias = $grammar->wrapTable($alias);
29+
30+
$builder
31+
->toBase()
32+
->selectRaw('1')
33+
->from($this->model->getQuery()->raw("$table as $wrappedAlias"))
34+
->whereRaw("$table.$parentIdName = $wrappedAlias.$keyName")
35+
->limit(1);
36+
37+
$this->model->applyNestedSetScope($builder, $alias);
38+
39+
40+
$inner
41+
->whereRaw("$parentIdName is not null")
42+
->addWhereExistsQuery($builder->getQuery(), 'and', true);
43+
}
44+
);
45+
}
46+
}

src/Healthy/OddnessCheck.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Fureev\Trees\QueryBuilderV2;
8+
use Illuminate\Database\Query\Builder;
9+
10+
final readonly class OddnessCheck extends AbstractCheck
11+
{
12+
protected function query(): Builder
13+
{
14+
/** @var QueryBuilderV2 $builder */
15+
$builder = $this->model->newNestedSetQuery();
16+
17+
return $builder
18+
->toBase()
19+
->whereNested(
20+
function (Builder $inner) use ($builder) {
21+
[
22+
$lft,
23+
$rgt,
24+
] = $builder->wrappedColumns();
25+
26+
$inner
27+
->whereRaw("$lft >= $rgt")
28+
->orWhereRaw("($rgt - $lft) % 2 = 0");
29+
}
30+
);
31+
}
32+
}

src/Healthy/WrongParentCheck.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Fureev\Trees\Healthy;
6+
7+
use Fureev\Trees\QueryBuilderV2;
8+
use Illuminate\Database\Query\Builder;
9+
10+
final readonly class WrongParentCheck extends AbstractCheck
11+
{
12+
protected function query(): Builder
13+
{
14+
$table = $this->model->wrappedTable();
15+
$keyName = $this->model->wrappedKey();
16+
17+
$grammar = $this->model->getQuery()->getGrammar();
18+
19+
$parentIdName = $grammar->wrap((string)$this->model->parentAttribute());
20+
21+
$parentAlias = 'p';
22+
$childAlias = 'c';
23+
$intermAlias = 'i';
24+
25+
$waParent = $grammar->wrapTable($parentAlias);
26+
$waChild = $grammar->wrapTable($childAlias);
27+
$waInterm = $grammar->wrapTable($intermAlias);
28+
29+
$isMulti = $this->model->isMulti();
30+
31+
/** @var QueryBuilderV2 $query */
32+
$query = $this->model->newNestedSetQuery($childAlias);
33+
34+
$query
35+
->toBase()
36+
->from($this->model->getQuery()->raw("$table as $waChild, $table as $waParent, $table as $waInterm"))
37+
->when(
38+
$isMulti,
39+
function (Builder $q) use ($waChild, $waParent, $waInterm) {
40+
$tid = (string)$this->model->treeAttribute();
41+
$q
42+
->whereRaw("$waChild.$tid = $waParent.$tid")
43+
->whereRaw("$waInterm.$tid = $waParent.$tid");
44+
}
45+
)
46+
->whereRaw("$waChild.$parentIdName=$waParent.$keyName")
47+
->whereRaw("$waInterm.$keyName <> $waParent.$keyName")
48+
->whereRaw("$waInterm.$keyName <> $waChild.$keyName")
49+
->whereNested(
50+
function (Builder $inner) use ($waInterm, $waChild, $waParent, $query) {
51+
[
52+
$lft,
53+
$rgt,
54+
] = $query->wrappedColumns();
55+
56+
$inner
57+
->whereRaw("$waChild.$lft not between $waParent.$lft and $waParent.$rgt")
58+
->orWhereRaw("$waChild.$lft between $waInterm.$lft and $waInterm.$rgt")
59+
->whereRaw("$waInterm.$lft between $waParent.$lft and $waParent.$rgt");
60+
}
61+
);
62+
63+
return $this->model->applyNestedSetScope(
64+
$this->model->applyNestedSetScope($query, $parentAlias),
65+
$intermAlias
66+
)->getQuery();
67+
}
68+
}

0 commit comments

Comments
 (0)