├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── facet-filter.php ├── database └── migrations │ └── create_facetrows_table.php ├── demo.gif └── src ├── Builders └── FacetQueryBuilder.php ├── Collections └── FacettableCollection.php ├── Facades └── FacetFilter.php ├── FacetFilter.php ├── FacetFilterServiceProvider.php ├── Indexer.php ├── Models ├── Facet.php └── FacetRow.php └── Traits ├── Facettable.php └── HasFacetCache.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes will be documented in this file. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) mgussekloo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Facet Filter 2 | 3 | This package provides simple facet filtering (sometimes called Faceted Search or Faceted Navigation) in Laravel projects. It helps narrow down query results based on the attributes of your models. 4 | 5 | - Free, no dependencies 6 | - Easy to use in any project 7 | - Easy to customize 8 | - There's a [demo project](https://github.com/mgussekloo/Facet-Demo) to get you started 9 | 10 | ![Demo](https://raw.githubusercontent.com/mgussekloo/laravel-facet-filter/master/demo.gif) 11 | 12 | ### Contributing 13 | 14 | Please contribute to this package, either by creating a pull request or reporting an issue. 15 | 16 | ### Installation 17 | 18 | This package can be installed through [Composer](https://packagist.org/packages/mgussekloo/laravel-facet-filter). 19 | 20 | ``` bash 21 | composer require mgussekloo/laravel-facet-filter 22 | ``` 23 | 24 | ## Prepare your project 25 | 26 | ### Update your models 27 | 28 | Add a Facettable trait and a facetDefinitions() method to models that should support facet filtering. 29 | 30 | ``` php 31 | use Illuminate\Database\Eloquent\Factories\HasFactory; 32 | use Illuminate\Database\Eloquent\Model; 33 | 34 | use Mgussekloo\FacetFilter\Traits\Facettable; 35 | 36 | class Product extends Model 37 | { 38 | use HasFactory; 39 | use Facettable; 40 | 41 | public static function facetDefinitions() 42 | { 43 | // Return an array of definitions 44 | return [ 45 | [ 46 | 'title' => 'Main color', // The title will be used for the parameter. 47 | 'fieldname' => 'color' // Model property from which to get the values. 48 | ], 49 | [ 50 | 'title' => 'Sizes', 51 | 'fieldname' => 'sizes.name' // Use dot notation to get the value from related models. 52 | ] 53 | ]; 54 | } 55 | } 56 | 57 | 58 | ``` 59 | 60 | ### Publish and run the migrations 61 | 62 | For larger datasets you must build an index of all facets beforehand. If you're absolutely certain you don't need an index, skip to [filtering collections](#filtering-collections). 63 | 64 | ``` bash 65 | php artisan vendor:publish --tag="facet-filter-migrations" 66 | php artisan migrate 67 | ``` 68 | 69 | ### Build the index 70 | 71 | Now you can start building the index. There's a simple Indexer included, you just need to configure it to run once, periodically or whenever a relevant part of your data changes. 72 | 73 | ``` php 74 | use Mgussekloo\FacetFilter\Indexer; 75 | 76 | $products = Product::with(['sizes'])->get(); // get some products 77 | 78 | $indexer = new Indexer(); 79 | 80 | $indexer->resetIndex(); // clear the entire index or... 81 | $indexer->resetRows($products); // clear only the models that you know have changed 82 | 83 | $indexer->buildIndex($products); // process the models 84 | ``` 85 | 86 | ## Get results 87 | 88 | ### Apply the facet filter to a query 89 | 90 | ``` php 91 | $filter = request()->all(); // use the request parameters 92 | $filter = ['main-color' => ['green']]; // (or provide your own array) 93 | 94 | $products = Product::facetFilter($filter)->get(); 95 | ``` 96 | 97 | ## Build the frontend 98 | 99 | ``` php 100 | $facets = Product::getFacets(); 101 | 102 | /* You can filter and sort like any regular Laravel collection. */ 103 | $singleFacet = $facets->firstWhere('fieldname', 'color'); 104 | 105 | /* Find out stuff about the facet. */ 106 | $paramName = $singleFacet->getParamName(); // "main-color" 107 | $options = $singleFacet->getOptions(); 108 | 109 | /* 110 | Options look like this: 111 | (object)[ 112 | 'value' => 'Red', 113 | 'selected' => false, 114 | 'total' => 3, 115 | 'slug' => 'color_red', 116 | 'http_query' => 'main-color%5B1%5D=red&sizes%5B0%5D=small' 117 | ] 118 | */ 119 | ``` 120 | 121 | ### Basic frontend example 122 | 123 | Here's a simple [demo project](https://github.com/mgussekloo/Facet-Demo) that demonstrates a basic frontend. 124 | 125 | ``` html 126 |
127 |
128 | @foreach ($facets as $facet) 129 |

130 |

{{ $facet->title }}

131 | 132 | @foreach ($facet->getOptions() as $option) 133 | {{ $option->value }} ({{ $option->total }})
134 | @endforeach 135 |


136 | @endforeach 137 |
138 |
139 | @foreach ($products as $product) 140 |

141 |

{{ $product->name }} ({{ $product->sizes->pluck('name')->join(', ') }})

142 | {{ $product->color }}

143 |

144 | @endforeach 145 |
146 |
147 | ``` 148 | 149 | ### Livewire example 150 | 151 | This is how it could look like with Livewire. 152 | 153 | ``` html 154 |

Colors

155 | @foreach ($facet->getOptions() as $option) 156 |
157 | 163 | 166 |
167 | @endforeach 168 | ``` 169 | 170 | ## Customization 171 | 172 | ### Advanced indexing 173 | 174 | Extend the [Indexer](src/Indexer.php) to customize behavior, e.g. to save a "range bracket" value instead of a "individual price" value to the index. 175 | 176 | ``` php 177 | class MyCustomIndexer extends \Mgussekloo\FacetFilter\Indexer { 178 | public function buildValues($facet, $model) { 179 | $values = parent::buildValues($facet, $model); 180 | 181 | if ($facet->fieldname == 'price') { 182 | 183 | if ($model->price > 1000) { 184 | return 'Expensive'; 185 | } 186 | if ($model->price > 500) { 187 | return '500 - 1000'; 188 | } 189 | if ($model->price > 250) { 190 | return '250 - 500'; 191 | } 192 | return '0 - 250'; 193 | } 194 | 195 | return $values; 196 | } 197 | } 198 | ``` 199 | 200 | ### Incremental indexing for large datasets 201 | 202 | ``` php 203 | $perPage = 1000; $currentPage = Cache::get('facetIndexingPage', 1); 204 | 205 | $products = Product::with(['sizes'])->paginate($perPage, ['*'], 'page', $currentPage); 206 | $indexer = new Indexer($products); 207 | 208 | if ($currentPage == 1) { 209 | $indexer->resetIndex(); 210 | } 211 | 212 | $indexer->buildIndex(); 213 | 214 | if ($products->hasMorePages()) {} 215 | // next iteration, increase currentPage with one 216 | } 217 | ``` 218 | 219 | ### Custom facets 220 | 221 | Provide custom attributes and an optional custom [Facet class](src/Models/Facet.php) in the facet definitions. 222 | 223 | ``` php 224 | public static function facetDefinitions() 225 | { 226 | return [ 227 | [ 228 | 'title' => 'Main color', 229 | 'description' => 'The main color.', // optional custom attribute, you could use $facet->description when creating the frontend... 230 | 'related_id' => 23, // ... or use $facet->related_id with your custom indexer 231 | 'fieldname' => 'color', 232 | 'facet_class' => CustomFacet::class // optional Facet class with custom logic 233 | ] 234 | ]; 235 | } 236 | ``` 237 | 238 | ### Filtering collections 239 | 240 | It's possible to apply facet filtering to a collection, without building an index. Models with the Facettable trait return a FacettableCollection which has an indexlessFacetFilter() method. 241 | It's slower than filtering with an index, though. 242 | 243 | ``` php 244 | $products = Product::all(); // returns a "FacettableCollection" 245 | $products = $products->indexlessFacetFilter($filter); 246 | 247 | // the second (optional) parameter lets you specify which indexer to use when indexing values from models 248 | 249 | $indexer = new App\MyCustomIndexer(); 250 | $products = Product::all()->indexlessFacetFilter($filter, $indexer); 251 | ``` 252 | 253 | ## Notes on caching 254 | 255 | By default Facet Filter caches some heavy operations through the non-persistent 'array' cache driver. It's recommended you write your own persistent caching solution that can take into account anything influencing the results being filtered; users being logged in or not, any other search constraints outside the facet filter, etc. 256 | 257 | That being said, you can configure a peristent cache driver through `config/facet-filter.php`. If you not only want to cache facet retrieval from the db, but also the actual models being retrieved for a particular filter, use the withCache() method. 258 | 259 | ```php 260 | // do not clear the result count cache before facet filtering (only useful if using a persistent caching driver) 261 | Product::withCache()->facetFilter($filter)->get(); 262 | 263 | // using collection-based facet filtering 264 | Projects::all()->withCache()->indexlessFacetFilter($filter); 265 | ``` 266 | 267 | The default Indexer clears the cache automatically when rebuilding the index. To do it manually: 268 | 269 | ```php 270 | FacetFilter::forgetCache(); // clears all result counts for all facets, and all facet rows 271 | ``` 272 | 273 | ## Config 274 | 275 | ``` php 276 | 'classes' => [ 277 | 'facet' => Mgussekloo\FacetFilter\Models\Facet::class, 278 | 'facetrow' => Mgussekloo\FacetFilter\Models\FacetRow::class, 279 | ], 280 | 281 | 'table_names' => [ 282 | 'facetrows' => 'facetrows', 283 | ], 284 | 285 | 'cache' => [ 286 | 'expiration_time' => \DateInterval::createFromDateString('24 hours'), 287 | 'key' => 'mgussekloo.facetfilter.cache', 288 | 'store' => 'array', 289 | ], 290 | ``` 291 | 292 | 293 | ## License 294 | 295 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 296 | 297 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mgussekloo/laravel-facet-filter", 3 | "description": "Simple facet filtering in Laravel projects, hassle free.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "facet", 7 | "filter", 8 | "search", 9 | "navigation", 10 | "laravel", 11 | "faceted search", 12 | "smart filter" 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "Mgussekloo\\FacetFilter\\": "src/" 17 | } 18 | }, 19 | "authors": [ 20 | { 21 | "name": "Martijn Gussekloo", 22 | "email": "1426964+mgussekloo@users.noreply.github.com" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.0", 27 | "spatie/laravel-package-tools": "^1.9.2" 28 | }, 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "Mgussekloo\\FacetFilter\\FacetFilterServiceProvider" 33 | ] 34 | } 35 | }, 36 | "minimum-stability": "stable", 37 | "prefer-stable": true, 38 | "require-dev": { 39 | "laravel/pint": "^1.6", 40 | "orchestra/testbench": "^8.0", 41 | "nunomaduro/larastan": "^2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /config/facet-filter.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'facet' => Mgussekloo\FacetFilter\Models\Facet::class, 7 | 'facetrow' => Mgussekloo\FacetFilter\Models\FacetRow::class, 8 | ], 9 | 10 | 'table_names' => [ 11 | 'facetrows' => 'facetrows', 12 | ], 13 | 14 | 'cache' => [ 15 | 'expiration_time' => \DateInterval::createFromDateString('24 hours'), 16 | 'key' => 'mgussekloo.facetfilter.cache', 17 | 'store' => 'array', 18 | ], 19 | 20 | ]; -------------------------------------------------------------------------------- /database/migrations/create_facetrows_table.php: -------------------------------------------------------------------------------- 1 | id(); 19 | 20 | $table->string('facet_slug'); 21 | $table->foreignId('subject_id'); 22 | $table->string('value')->nullable(); 23 | 24 | $table->timestamps(); 25 | 26 | $table->index(['facet_slug', 'value', 'subject_id']); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | $tableNames = config('facet-filter.table_names'); 38 | Schema::dropIfExists($tableNames['facetrows']); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgussekloo/laravel-facet-filter/124b64cb2cd82191bab604c8e8770c0695912f7c/demo.gif -------------------------------------------------------------------------------- /src/Builders/FacetQueryBuilder.php: -------------------------------------------------------------------------------- 1 | facetSubjectType = $this->model::class; 23 | $this->facetFilter = $this->facetSubjectType::getFilterFromArr($filter); 24 | 25 | return $this; 26 | } 27 | 28 | // Alias of facetfilter 29 | public function facetsMatchFilter($filter = []) 30 | { 31 | return $this->facetFilter($filter); 32 | } 33 | 34 | /** 35 | * By default, we perform new calculations to get facet row counts for every query. 36 | * But, if you KNOW you're doing the same query anyway, you may override this. 37 | */ 38 | public function withCache($cache=true) { 39 | $this->useFacetCache=$cache; 40 | return $this; 41 | } 42 | 43 | /** 44 | * Get the results, but first constrain the query with matching facets. 45 | * We save the base query, to use it later to calculate the results in each facet. 46 | */ 47 | public function get($columns = ['*']) 48 | { 49 | // If we're not doing any facet filtering, just bail. 50 | if (is_null($this->facetFilter)) { 51 | return parent::get($columns); 52 | } 53 | 54 | // Save the unconstrained query 55 | FacetFilter::setLastQuery($this->facetSubjectType, $this); 56 | 57 | // Constrain the query 58 | $this->constrainQueryWithFilter($this->facetFilter); 59 | 60 | // Get the result 61 | $result = parent::get($columns); 62 | 63 | return $result; 64 | } 65 | 66 | // Constrain the query with the facets and filter 67 | public function constrainQueryWithFilter($filter, $shouldApplyFilter=true) 68 | { 69 | $shouldApplyFilter = ($shouldApplyFilter) ? $filter : false; 70 | $facets = FacetFilter::getFacets($this->facetSubjectType, $shouldApplyFilter); 71 | 72 | foreach ($facets as $facet) { 73 | $facet->constrainQueryWithFilter($this, $filter); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Collections/FacettableCollection.php: -------------------------------------------------------------------------------- 1 | useFacetCache=$cache; 17 | return $this; 18 | } 19 | 20 | /** 21 | * Experimental: Filter a collection, bypassing the database index entirely 22 | */ 23 | public function indexlessFacetFilter($filter, $indexer=null) 24 | { 25 | 26 | $subjectType = $this->first()::class; 27 | 28 | $facets = FacetFilter::getFacets($subjectType, $filter, false); 29 | 30 | if ($facets->isEmpty()) { 31 | return $this; 32 | } 33 | 34 | if (is_null($indexer)) { 35 | $indexer = new Indexer(); 36 | } elseif (is_string($indexer)) { 37 | $indexer = new $indexer(); 38 | } 39 | 40 | $filter = $facets->first()->filter; 41 | 42 | if (!$this->useFacetCache) { 43 | FacetFilter::resetIdsInFilteredQuery($subjectType); 44 | } 45 | 46 | // build the facet rows 47 | $all_rows = FacetFilter::cache('facetRows', $subjectType); 48 | 49 | if ($all_rows === false) { 50 | $all_rows = []; 51 | 52 | foreach ($facets as $facet) { 53 | $_rows = []; 54 | // build the facetrows 55 | foreach ($this as $model) { 56 | $values = $indexer->buildValues($facet, $model); 57 | if (!is_array($values)) { 58 | $values = [$values]; 59 | } 60 | 61 | foreach ($values as $value) { 62 | $arr = (object)$indexer->buildRow($facet, $model, $value); 63 | $_rows[] = $arr; 64 | } 65 | } 66 | $all_rows[$facet->getSlug()] = collect($_rows); 67 | } 68 | 69 | FacetFilter::cache('facetRows', $subjectType, $all_rows); 70 | } 71 | 72 | // load the rows 73 | foreach ($facets as $facet) { 74 | $rows = $all_rows[$facet->getSlug()]; 75 | $facet->setRows($rows); 76 | } 77 | 78 | $all_ids = FacetFilter::cacheIdsInFilteredQuery($subjectType, $filter); 79 | if ($all_ids === false) { 80 | $all_ids = $this->pluck('id')->toArray(); 81 | FacetFilter::cacheIdsInFilteredQuery($subjectType, $filter, $all_ids); 82 | } 83 | 84 | if (empty(array_filter(array_values($filter)))) { 85 | return $this; 86 | } 87 | 88 | foreach ($facets as $facet) { 89 | // now start filtering 90 | $facetName = $facet->getParamName(); 91 | 92 | $selectedValues = (isset($filter[$facetName])) 93 | ? collect($filter[$facetName])->values() 94 | : collect([]); 95 | 96 | // if you have selected ALL, it is the same as selecting none 97 | if ($selectedValues->isNotEmpty()) { 98 | $allValues = $rows->pluck('value')->filter()->unique()->values(); 99 | if ($allValues->diff($selectedValues)->isEmpty()) { 100 | $selectedValues = collect([]); 101 | } 102 | } 103 | 104 | // if you must filter 105 | if ($selectedValues->isNotEmpty()) { 106 | $facet->included_ids = $facet->rows->whereIn('value', $selectedValues)->pluck('subject_id')->toArray(); 107 | } else { 108 | $facet->included_ids = $all_ids; 109 | } 110 | } 111 | 112 | // all facets are done, prepare the last-query caches and correct option counts 113 | 114 | $included_ids_known = FacetFilter::cacheIdsInFilteredQuery($subjectType, $filter); 115 | 116 | $included_ids = null; 117 | foreach ($facets as $facet) { 118 | $facetName = $facet->getParamName(); 119 | 120 | if (isset($filter[$facetName])) { 121 | $filterWithoutFacet = array_merge($filter, [$facetName => []]); 122 | if (false === FacetFilter::cacheIdsInFilteredQuery($subjectType, $filterWithoutFacet)) { 123 | 124 | $otherFacets = $facets->reject(function($f) use ($facetName) { 125 | return $facetName == $f->getParamName(); 126 | }); 127 | 128 | $ids = null; 129 | foreach ($otherFacets as $f) { 130 | $ids = (!is_null($ids)) ? array_intersect($ids, $f->included_ids) : $f->included_ids; 131 | }; 132 | 133 | FacetFilter::cacheIdsInFilteredQuery($subjectType, array_merge($filter, [$facet->getParamName() => []]), $ids); 134 | } 135 | } 136 | 137 | if ($included_ids_known === false) { 138 | $included_ids = (!is_null($included_ids)) ? array_intersect($included_ids, $facet->included_ids) : $facet->included_ids; 139 | } 140 | } 141 | 142 | if ($included_ids_known === false) { 143 | FacetFilter::cacheIdsInFilteredQuery($subjectType, $filter, $included_ids); 144 | } else { 145 | $included_ids = $included_ids_known; 146 | } 147 | 148 | return $this->whereIn('id', $included_ids); 149 | } 150 | } -------------------------------------------------------------------------------- /src/Facades/FacetFilter.php: -------------------------------------------------------------------------------- 1 | map->setFilter($filter); 39 | } 40 | 41 | return self::$facets[$subjectType]; 42 | } 43 | 44 | /** 45 | * Get all the rows for a number of facets. This is an expensive operation, 46 | * because we may load 1000's of rows for each facet. 47 | */ 48 | public function loadRows($subjectType, $facets) 49 | { 50 | $rows = self::cache('facetRows', $subjectType); 51 | if ($rows === false) { 52 | $rows = DB::table('facetrows') 53 | ->whereIn('facet_slug', $facets->map->getSlug()) 54 | ->select('facet_slug', 'subject_id', 'value') 55 | ->get()->groupBy('facet_slug'); 56 | self::cache('facetRows', $subjectType, $rows); 57 | } 58 | 59 | foreach ($facets as $facet) { 60 | $slug = $facet->getSlug(); 61 | if (isset($rows[$slug])) { 62 | $facet->setRows($rows[$slug]); 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * Remember the last query for a model class, without eager loaded relations. 69 | * We use this query as basis to run queries for each facet, calculating the number 70 | * of results the facet options would have. 71 | */ 72 | public function setLastQuery(string $subjectType, $query): void 73 | { 74 | $newQuery = clone $query; 75 | $newQuery->withOnly([]); 76 | 77 | $query = $newQuery->getQuery(); 78 | if ($query->limit > 0) { 79 | $newQuery->limit(null); 80 | $query->offset = null; 81 | } 82 | 83 | self::$lastQueries[$subjectType] = $newQuery; 84 | 85 | if (!$newQuery->useFacetCache) { 86 | self::resetIdsInFilteredQuery($subjectType); 87 | } 88 | } 89 | 90 | /** 91 | * Retrieve the last query for a model class or return false. 92 | */ 93 | public function getLastQuery(string $subjectType) 94 | { 95 | if (isset(self::$lastQueries[$subjectType])) { 96 | return clone self::$lastQueries[$subjectType]; 97 | } 98 | 99 | return false; 100 | } 101 | 102 | /** 103 | * Retrieve the ids in the last query for a model class, without filter set for a single facet 104 | */ 105 | public function getIdsInLastQueryWithoutFacet($facet) 106 | { 107 | $facetName = $facet->getParamName(); 108 | $filterWithoutFacet = array_merge($facet->filter, [$facetName => []]); 109 | 110 | $ids = self::cacheIdsInFilteredQuery($facet->subject_type, $filterWithoutFacet); 111 | 112 | if ($ids === false) { 113 | if ($lastQuery = self::getLastQuery($facet->subject_type)) { 114 | $lastQuery->constrainQueryWithFilter($filterWithoutFacet, false); 115 | $ids = self::cacheIdsInFilteredQuery($facet->subject_type, $filterWithoutFacet, $lastQuery->pluck('id')->toArray()); 116 | } 117 | } 118 | 119 | return $ids; 120 | } 121 | 122 | /** 123 | * Get a filter array that has the facet parameters for a certain subject (model) as keys 124 | * merged with $arr. 125 | */ 126 | public function getFilterFromArr($subjectType, $arr = []): array 127 | { 128 | $emptyFilter = self::getEmptyFilter($subjectType); 129 | 130 | $arr = array_map(function ($item): array { 131 | if (! is_array($item)) { 132 | return [$item]; 133 | } 134 | 135 | return $item; 136 | }, (array) $arr); 137 | 138 | $filter = array_replace($emptyFilter, array_intersect_key(array_filter($arr), $emptyFilter)); 139 | 140 | return $filter; 141 | } 142 | 143 | /** 144 | * Same as above, but the values are empty. 145 | */ 146 | public function getEmptyFilter(string $subjectType): array 147 | { 148 | return $subjectType::getFacets()->mapWithKeys(fn ($facet) => [$facet->getParamName() => []])->toArray(); 149 | } 150 | 151 | /** 152 | * Remember which model id's were in a filtered query for the combination 153 | * "model class" and "filter". This should avoid running the same query 154 | * twice when calculating the number of results for each facet. 155 | */ 156 | public function cacheIdsInFilteredQuery(string $subjectType, $filter, $ids = null) 157 | { 158 | asort($filter); 159 | ksort($filter); 160 | $cacheKey = $subjectType . '.' . json_encode($filter, JSON_THROW_ON_ERROR); 161 | 162 | return self::cache('idsInFilteredQuery', $cacheKey, $ids); 163 | } 164 | 165 | /** 166 | * Forget the model id's that were in a filtered query, so we can 167 | * start fresh. 168 | */ 169 | public function resetIdsInFilteredQuery(string $subjectType): void 170 | { 171 | self::forgetCache('idsInFilteredQuery', $subjectType); 172 | 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/FacetFilterServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-facet-filter') 14 | ->hasConfigFile() 15 | ->hasMigrations(['create_facetrows_table']); 16 | } 17 | 18 | public function registeringPackage(): void 19 | { 20 | $this->app->bind('facetfilter', fn () => new FacetFilter()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Indexer.php: -------------------------------------------------------------------------------- 1 | models = $models; 18 | $this->facetClass = config('facet-filter.classes.facet'); 19 | $this->facetRowClass = config('facet-filter.classes.facetrow'); 20 | 21 | } 22 | 23 | public function buildRow($facet, $model, $value) 24 | { 25 | return [ 26 | 'facet_slug' => $facet->getSlug(), 27 | 'subject_id' => $model->id, 28 | 'value' => $value, 29 | ]; 30 | } 31 | 32 | public function buildValues($facet, $model) 33 | { 34 | $values = []; 35 | 36 | if (isset($facet->fieldname)) { 37 | $fields = explode('.', (string) $facet->fieldname); 38 | 39 | if (count($fields) == 1) { 40 | $values = collect([$model->{$fields[0]}]); 41 | } else { 42 | $last_key = array_key_last($fields); 43 | 44 | $values = collect([$model]); 45 | foreach ($fields as $key => $field) { 46 | $values = $values->pluck($field); 47 | if ($key !== $last_key) { 48 | $values = $values->flatten(1); 49 | } 50 | } 51 | } 52 | 53 | return $values->toArray(); 54 | } 55 | 56 | return $values; 57 | } 58 | 59 | public function insertRows($rows) 60 | { 61 | $chunks = array_chunk($rows, 1000); 62 | foreach ($chunks as $chunk) { 63 | $this->facetRowClass::insert(array_values($chunk)); 64 | } 65 | } 66 | 67 | public function resetRows($models = null): self 68 | { 69 | if (is_null($models) || $models->isEmpty()) { 70 | return $this->resetIndex(); 71 | } 72 | 73 | foreach ($models as $model) { 74 | FacetFilter::getFacets($model::class, false, false)->each(function ($facet) use ($model) { 75 | $this->facetRowClass::where('subject_id', $model->id) 76 | ->where('facet_slug', $facet->getSlug()) 77 | ->delete(); 78 | }); 79 | } 80 | 81 | FacetFilter::forgetCache(); 82 | 83 | return $this; 84 | } 85 | 86 | public function resetIndex() 87 | { 88 | $this->facetRowClass::truncate(); 89 | 90 | FacetFilter::forgetCache(); 91 | 92 | return $this; 93 | } 94 | 95 | public function buildIndex($models = null) 96 | { 97 | if (!is_null($models)) { 98 | $this->models = $models; 99 | } 100 | 101 | if (! is_null($this->models) && $this->models->isNotEmpty()) { 102 | $subjectType = $this->models->first()::class; 103 | 104 | $facets = FacetFilter::getFacets($subjectType, false, false); 105 | 106 | $now = now(); 107 | $rows = []; 108 | foreach ($this->models as $model) { 109 | foreach ($facets as $facet) { 110 | $values = $this->buildValues($facet, $model); 111 | 112 | if (!is_array($values)) { 113 | $values = [$values]; 114 | } 115 | 116 | foreach ($values as $value) { 117 | if (is_null($value)) { 118 | continue; 119 | } 120 | 121 | $uniqueKey = implode('.', [$facet->getSlug(), $model->id, $value]); 122 | $row = $this->buildRow($facet, $model, $value); 123 | $row = array_merge([ 124 | 'created_at' => $now, 125 | // 'updated_at' => null, 126 | ], $row); 127 | $rows[$uniqueKey] = $row; 128 | } 129 | } 130 | } 131 | 132 | $this->insertRows($rows); 133 | } 134 | 135 | return $this; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Models/Facet.php: -------------------------------------------------------------------------------- 1 | filter = []; 32 | $this->rows = collect([]); 33 | 34 | foreach ($definition as $key => $value) { 35 | $this->$key = $value; 36 | } 37 | } 38 | 39 | // return the option objects for this facet 40 | public function getOptions(): Collection 41 | { 42 | if (is_null($this->options)) { 43 | $facetName = $this->getParamName(); 44 | $subjectType = $this->subject_type; 45 | 46 | // find out totals of the values in this facet 47 | // *within* the current query / filter operation. 48 | // in short: apply all the filters EXCEPT the one involving this facet. 49 | 50 | // https://stackoverflow.com/questions/27550841/calculating-product-counts-efficiently-in-faceted-search-with-php-mysql 51 | 52 | $idsInFilteredQuery = FacetFilter::getIdsInLastQueryWithoutFacet($this); 53 | 54 | $rows = $this->rows; 55 | if ($idsInFilteredQuery) { 56 | $rows = $this->rows->filter(function($row) use ($idsInFilteredQuery) { 57 | return in_array($row->subject_id, $idsInFilteredQuery); 58 | }); 59 | } 60 | 61 | $values = array_count_values($rows->pluck('value')->filter()->toArray()); 62 | 63 | $selectedValues = false; 64 | if (!empty($this->filter[$facetName])) { 65 | $selectedValues = $this->filter[$facetName]; 66 | } 67 | 68 | $options = collect([]); 69 | 70 | $slugBase = Str::slug($this->fieldname ?? $this->title); 71 | foreach ($values as $value => $total) { 72 | $options->push((object) [ 73 | 'value' => $value, 74 | 'selected' => ($selectedValues) ? in_array($value, $selectedValues) : false, 75 | 'total' => $total, 76 | 'slug' => sprintf( '%s_%s', $slugBase, Str::slug($value) ), 77 | 'http_query' => $this->getHttpQuery($value), 78 | ]); 79 | } 80 | 81 | $this->options = $options; 82 | } 83 | 84 | return $this->options; 85 | } 86 | 87 | // return the options objects, but remove the ones leading to zero results 88 | public function getNonMissingOptions(): Collection 89 | { 90 | return $this->getOptions()->filter(fn ($value) => $value->total); 91 | } 92 | 93 | // constrain the given query to this facet's filtered values 94 | public function constrainQueryWithFilter($query, $filter): FacetQueryBuilder 95 | { 96 | $facetName = $this->getParamName(); 97 | 98 | $selectedValues = (isset($filter[$facetName])) 99 | ? collect($filter[$facetName])->values() 100 | : collect([]); 101 | 102 | $rows = $this->rows ?? collect(); 103 | 104 | // if you have selected ALL, it is the same as selecting none 105 | if ($selectedValues->isNotEmpty()) { 106 | $allValues = $rows->pluck('value')->filter()->unique()->values(); 107 | if ($allValues->diff($selectedValues)->isEmpty()) { 108 | $selectedValues = collect([]); 109 | } 110 | } 111 | 112 | // if you must filter 113 | if ($selectedValues->isNotEmpty()) { 114 | $ids = $rows->whereIn('value', $selectedValues)->pluck('subject_id')->toArray(); 115 | $query->whereIntegerInRaw('id', $ids); 116 | } 117 | 118 | return $query; 119 | } 120 | 121 | public function getHttpQuery($value): string 122 | { 123 | $facetName = $this->getParamName(); 124 | 125 | $arr = $this->filter; 126 | if (isset($arr[$facetName])) { 127 | if (empty($arr[$facetName])) { 128 | $arr[$facetName][] = $value; 129 | // } elseif (count(array_intersect($arr[$facetName], [$value])) == 1) { 130 | } elseif (in_array($value, $arr[$facetName])) { 131 | $arr[$facetName] = array_diff($arr[$facetName], [$value]); 132 | } else { 133 | $arr[$facetName][] = $value; 134 | } 135 | } 136 | 137 | $arr = array_filter($arr); 138 | return http_build_query($arr, '', '&', PHP_QUERY_RFC3986); 139 | } 140 | 141 | // return the title (or fieldname) to use for the http query param 142 | public function getParamName(): string 143 | { 144 | $param = $this->title ?? $this->fieldname; 145 | return Str::slug($param); 146 | } 147 | 148 | // get this facet's unique slug (used for indexing) 149 | public function getSlug(): string 150 | { 151 | return implode('.', [$this->subject_type, $this->fieldname ?? strtolower($this->title) ]); 152 | } 153 | 154 | // set the filter for this facet 155 | public function setFilter($filter) 156 | { 157 | $this->filter = $filter; 158 | } 159 | 160 | // set the facetrows for this facet 161 | public function setRows($rows) 162 | { 163 | $this->rows = $rows; 164 | } 165 | } -------------------------------------------------------------------------------- /src/Models/FacetRow.php: -------------------------------------------------------------------------------- 1 | belongsTo(Facet::class); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Traits/Facettable.php: -------------------------------------------------------------------------------- 1 | map(function ($definition) use ($subjectType, $facetClass) { 42 | return array_merge([ 43 | 'subject_type' => $subjectType, 44 | 'facet_class' => $facetClass, 45 | ], $definition); 46 | })->filter(); 47 | 48 | // Instantiate models 49 | $facets = []; 50 | foreach ($definitions as $definition) { 51 | $facets[] = new $definition['facet_class']($definition); 52 | } 53 | 54 | return collect($facets); 55 | } 56 | 57 | // get the facet models 58 | public static function getFacets($filter = null, $load = true): Collection 59 | { 60 | return FacetFilter::getFacets(self::class, $filter, $load); 61 | } 62 | 63 | public function facetrows() 64 | { 65 | $facetRowClass = config('facet-filter.classes.facetrow'); 66 | return $this->hasMany($facetRowClass, 'subject_id'); 67 | } 68 | 69 | public function newEloquentBuilder($query): FacetQueryBuilder 70 | { 71 | return new FacetQueryBuilder($query); 72 | } 73 | 74 | public static function getFilterFromArr($arr = []) 75 | { 76 | return FacetFilter::getFilterFromArr(self::class, $arr); 77 | } 78 | 79 | 80 | public static function filterCollection($models, $filter, $indexer=null) 81 | { 82 | return FacetFilter::filterCollection($models, $filter, $indexer); 83 | } 84 | 85 | public static function forgetCache() 86 | { 87 | return FacetFilter::forgetCache('idsInFilteredQuery', self::class); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Traits/HasFacetCache.php: -------------------------------------------------------------------------------- 1 | cacheExpirationTime = config('facet-filter.cache.expiration_time') ?: \DateInterval::createFromDateString('24 hours'); 16 | $this->cacheKey = config('facet-filter.cache.key'); 17 | $this->cache = $this->getCacheStoreFromConfig(); 18 | } 19 | 20 | public function getCacheStoreFromConfig() { 21 | // where 'default' means to use config(cache.default) 22 | $cacheDriver = config('facet-filter.cache.store', 'default'); 23 | 24 | // when 'default' is specified, no action is required since we already have the default instance 25 | if ($cacheDriver === 'default') { 26 | return Cache::store(); 27 | } 28 | 29 | // if an undefined cache store is specified, fallback to 'array' which is Laravel's closest equiv to 'none' 30 | if (! \array_key_exists($cacheDriver, config('cache.stores'))) { 31 | $cacheDriver = 'array'; 32 | } 33 | 34 | return Cache::store($cacheDriver); 35 | } 36 | 37 | public function cache($key, $subkey, $toRemember = null) { 38 | $cacheKey = $this->cacheKey . '.' . $key; 39 | 40 | if (is_null($subkey)) { 41 | return $this->cache->get($cacheKey); 42 | } 43 | 44 | $arr = []; 45 | 46 | if ($this->cache->has($cacheKey)) { 47 | $arr = $this->cache->get($cacheKey); 48 | } 49 | 50 | if (!is_null($toRemember)) { 51 | $arr[$subkey] = $toRemember; 52 | $this->cache->put($cacheKey, $arr, $this->cacheExpirationTime); 53 | } 54 | 55 | return isset($arr[$subkey]) ? $arr[$subkey] : false; 56 | } 57 | 58 | public function forgetCache($key = null, $subkey = null) 59 | { 60 | $keys = []; 61 | 62 | if (is_null($key)) { 63 | $keys = ['facetRows', 'idsInFilteredQuery']; 64 | } 65 | 66 | if (is_string($key)) { 67 | $keys = [$key]; 68 | } 69 | 70 | foreach ($keys as $key) { 71 | $cacheKey = $this->cacheKey . '.' . $key; 72 | 73 | if (is_null($subkey)) { 74 | $this->cache->forget($cacheKey); 75 | } else { 76 | $arr = $this->cache->get($cacheKey) ?? []; 77 | 78 | foreach ($arr as $index => $ids) { 79 | if (str_starts_with($index, $subkey)) { 80 | unset($arr[$index]); 81 | } 82 | } 83 | 84 | $this->cache->put($cacheKey, $arr, $this->cacheExpirationTime); 85 | } 86 | } 87 | 88 | return; 89 | } 90 | 91 | } --------------------------------------------------------------------------------