From c88174f954418e464497e1ba717247339a78637f Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Thu, 14 May 2026 16:31:03 +0200 Subject: [PATCH 1/3] fix(select-tree): scope root and child queries to subtrees when modifyQueryUsing is set --- src/SelectTree.php | 68 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/src/SelectTree.php b/src/SelectTree.php index da859b5..12744dc 100644 --- a/src/SelectTree.php +++ b/src/SelectTree.php @@ -171,18 +171,36 @@ protected function setUp(): void protected function buildTree(): Collection { - // Start with two separate query builders - $nullParentQuery = $this->getQuery()->clone()->where($this->getParentAttribute(), $this->getParentNullValue()); - $nonNullParentQuery = $this->getQuery()->clone()->whereNot($this->getParentAttribute(), $this->getParentNullValue()); + $parentAttribute = $this->getParentAttribute(); + $parentNullValue = $this->getParentNullValue(); + $keyName = $this->getQuery()->getModel()->getKeyName(); + + $nullParentQuery = $this->getQuery()->clone()->where($parentAttribute, $parentNullValue); + $nonNullParentQuery = $this->getQuery()->clone()->whereNot($parentAttribute, $parentNullValue); - // If we're not at the root level and a modification callback is provided, apply it to null query if ($this->modifyQueryUsing) { - $nullParentQuery = $this->evaluate($this->modifyQueryUsing, ['query' => $nullParentQuery]); + $rootQuery = $nullParentQuery->clone(); + $modified = $this->evaluate($this->modifyQueryUsing, ['query' => $rootQuery]); + $rootQuery = $modified ?? $rootQuery; + + if ($this->withTrashed) { + $rootQuery->withTrashed($this->withTrashed); + } + + $rootIds = $rootQuery->pluck($keyName)->all(); + $subtreeIds = $this->collectIdsInSubtrees($rootIds); + + if ($subtreeIds === []) { + return $this->buildTreeFromResults(collect()); + } + + $nullParentQuery->whereIn($keyName, $subtreeIds); + $nonNullParentQuery->whereIn($keyName, $subtreeIds); } - // If we're at the child level and a modification callback is provided, apply it to non null query if ($this->modifyChildQueryUsing) { - $nonNullParentQuery = $this->evaluate($this->modifyChildQueryUsing, ['query' => $nonNullParentQuery]); + $modified = $this->evaluate($this->modifyChildQueryUsing, ['query' => $nonNullParentQuery]); + $nonNullParentQuery = $modified ?? $nonNullParentQuery; } if ($this->withTrashed) { @@ -204,6 +222,42 @@ protected function buildTree(): Collection return $this->buildTreeFromResults($combinedResults); } + /** + * @param array $rootIds + * @return array + */ + protected function collectIdsInSubtrees(array $rootIds): array + { + if ($rootIds === []) { + return []; + } + + $parentAttribute = $this->getParentAttribute(); + $keyName = $this->getQuery()->getModel()->getKeyName(); + $allIds = collect($rootIds); + $frontier = array_values(array_unique($rootIds, SORT_REGULAR)); + + while ($frontier !== []) { + $childQuery = $this->getQuery()->clone()->whereIn($parentAttribute, $frontier); + + if ($this->withTrashed) { + $childQuery->withTrashed($this->withTrashed); + } + + $children = $childQuery->pluck($keyName)->all(); + $newIds = array_values(array_diff($children, $allIds->all())); + + if ($newIds === []) { + break; + } + + $frontier = $newIds; + $allIds = $allIds->merge($newIds); + } + + return $allIds->unique()->values()->all(); + } + private function buildTreeFromResults($results, $parent = null): Collection { // Assign the parent's null value to the $parent variable if it's not null or default to empty string From c1b1a936403cfdc81d2150a945b007d8214085fb Mon Sep 17 00:00:00 2001 From: CodeWithDennis <23448484+CodeWithDennis@users.noreply.github.com> Date: Thu, 14 May 2026 14:35:20 +0000 Subject: [PATCH 2/3] Fix styling --- src/SelectTree.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SelectTree.php b/src/SelectTree.php index 12744dc..c5ff22b 100644 --- a/src/SelectTree.php +++ b/src/SelectTree.php @@ -446,7 +446,7 @@ public function append(Closure|array|null $append = null): static } elseif (is_null($this->append)) { // Avoid throwing an exception in case $append is explicitly set to null, or a Closure evaluates to null. } else { - throw new \InvalidArgumentException('The provided append value must be an array with "name" and "value" keys.'); + throw new InvalidArgumentException('The provided append value must be an array with "name" and "value" keys.'); } return $this; From 644cb4c06134e28ff636497e9054530fd5add7bb Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Fri, 15 May 2026 12:38:52 +0200 Subject: [PATCH 3/3] feat(select-tree): add scopeRelationshipQueryUsing for shared tree constraints --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++---- src/SelectTree.php | 37 ++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 70e4359..5150e48 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,72 @@ SelectTree::make('category_id') ->query(fn() => Category::query(), 'name', 'parent_id') ``` -## Custom Query +## Query scoping -Customize the parent query +### `scopeRelationshipQueryUsing` + +Runs on **every** query used to build the tree: walking descendants, loading root rows, and loading child rows. Put **tenant or locale filters**, **`select()`**, **`with()`**, and any constraint that must hold for **all** nodes here. ```php +use CodeWithDennis\FilamentSelectTree\SelectTree; +use Illuminate\Database\Eloquent\Builder; + SelectTree::make('categories') - ->relationship(relationship: 'categories', titleAttribute: 'name', parentAttribute: 'parent_id', modifyQueryUsing: fn($query) => $query)); + ->scopeRelationshipQueryUsing( + fn (Builder $query): Builder => $query->where('language_code', app()->getLocale()) + ) + ->relationship('categories', 'name', 'parent_id'); ``` -Customize the child query +Add tenant or team scoping the same way (e.g. `whereBelongsTo($tenant)` or `where('tenant_id', …)`). + +If you narrow columns, include at least the tree key, parent column, title attribute, and any column used by `withKey()`: ```php +use CodeWithDennis\FilamentSelectTree\SelectTree; +use Illuminate\Database\Eloquent\Builder; + +SelectTree::make('categories') + ->scopeRelationshipQueryUsing( + fn (Builder $query): Builder => $query->select(['id', 'parent_id', 'name']) + ) + ->relationship('categories', 'name', 'parent_id'); +``` + +### `modifyQueryUsing` + +Optional. When set, it runs **only** on a clone of the **root** query to decide which root records seed each subtree (then IDs are clamped with `whereIn`). It is **not** merged into descendant queries—using root-only conditions as if they applied globally would contradict child rows and hide branches. + +Use it for extra filters on which nodes count as roots (e.g. only published roots), and keep shared rules in `scopeRelationshipQueryUsing`. + +```php +use CodeWithDennis\FilamentSelectTree\SelectTree; +use Illuminate\Database\Eloquent\Builder; + +SelectTree::make('categories') + ->relationship( + relationship: 'categories', + titleAttribute: 'name', + parentAttribute: 'parent_id', + modifyQueryUsing: fn (Builder $query): Builder => $query->where('is_featured', true), + ); +``` + +### `modifyChildQueryUsing` + +Runs only on the **non-root** query (rows whose parent is not the null parent value), after subtree clamping when `modifyQueryUsing` is used. + +```php +use CodeWithDennis\FilamentSelectTree\SelectTree; +use Illuminate\Database\Eloquent\Builder; + SelectTree::make('categories') - ->relationship(relationship: 'categories', titleAttribute: 'name', parentAttribute: 'parent_id', modifyChildQueryUsing: fn($query) => $query)); + ->relationship( + relationship: 'categories', + titleAttribute: 'name', + parentAttribute: 'parent_id', + modifyChildQueryUsing: fn (Builder $query): Builder => $query->orderBy('sort_order'), + ); ``` ## Methods diff --git a/src/SelectTree.php b/src/SelectTree.php index 12744dc..b4eff5c 100644 --- a/src/SelectTree.php +++ b/src/SelectTree.php @@ -72,6 +72,12 @@ class SelectTree extends Field implements HasAffixActions protected ?Closure $modifyChildQueryUsing = null; + /** + * Runs on every tree query (descendant walk and both final loads). Use for tenant, locale, + * narrowed columns, eager loads, etc. Put shared constraints here instead of {@see modifyQueryUsing}. + */ + protected ?Closure $scopeRelationshipQueryUsing = null; + protected Closure|int $defaultOpenLevel = 0; protected string $direction = 'auto'; @@ -169,14 +175,29 @@ protected function setUp(): void $this->treeKey('treeKey-'.rand()); } + /** + * Fresh {@see getQuery()} clone with {@see scopeRelationshipQueryUsing} applied when set. + */ + protected function newRelationshipQuery(): Builder + { + $query = $this->getQuery()->clone(); + + if ($this->scopeRelationshipQueryUsing) { + $modified = $this->evaluate($this->scopeRelationshipQueryUsing, ['query' => $query]); + $query = $modified ?? $query; + } + + return $query; + } + protected function buildTree(): Collection { $parentAttribute = $this->getParentAttribute(); $parentNullValue = $this->getParentNullValue(); $keyName = $this->getQuery()->getModel()->getKeyName(); - $nullParentQuery = $this->getQuery()->clone()->where($parentAttribute, $parentNullValue); - $nonNullParentQuery = $this->getQuery()->clone()->whereNot($parentAttribute, $parentNullValue); + $nullParentQuery = $this->newRelationshipQuery()->where($parentAttribute, $parentNullValue); + $nonNullParentQuery = $this->newRelationshipQuery()->whereNot($parentAttribute, $parentNullValue); if ($this->modifyQueryUsing) { $rootQuery = $nullParentQuery->clone(); @@ -238,7 +259,7 @@ protected function collectIdsInSubtrees(array $rootIds): array $frontier = array_values(array_unique($rootIds, SORT_REGULAR)); while ($frontier !== []) { - $childQuery = $this->getQuery()->clone()->whereIn($parentAttribute, $frontier); + $childQuery = $this->newRelationshipQuery()->whereIn($parentAttribute, $frontier); if ($this->withTrashed) { $childQuery->withTrashed($this->withTrashed); @@ -376,6 +397,16 @@ public function relationship(string $relationship, string $titleAttribute, strin return $this; } + /** + * @param ?Closure(array{query: Builder}): (Builder|null) $callback + */ + public function scopeRelationshipQueryUsing(?Closure $callback): static + { + $this->scopeRelationshipQueryUsing = $callback; + + return $this; + } + public function query(Builder|Closure|null $query, string $titleAttribute, string $parentAttribute, ?Closure $modifyQueryUsing = null, ?Closure $modifyChildQueryUsing = null): static { $this->query = $query;