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 da859b5..be12688 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,20 +175,53 @@ 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 { - // 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->newRelationshipQuery()->where($parentAttribute, $parentNullValue); + $nonNullParentQuery = $this->newRelationshipQuery()->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 +243,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->newRelationshipQuery()->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 @@ -322,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; @@ -392,7 +477,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;