├── 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 | 
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 |
164 | {{ $option->value }} ({{ $option->total }})
165 |
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 | }
--------------------------------------------------------------------------------