mgussekloo / laravel-facet-filter

Facet filtering for Laravel projects, fully customizable, batteries included.
MIT License
47 stars 5 forks source link

Is it possible to work with an EAV structure? #4

Closed georgiarnaudov closed 1 month ago

georgiarnaudov commented 1 month ago

Hi! Thanks for the great work you've done!

I didn't see a discussions tab, that's why I'm opening an issue.

Is it possible to build a faceted filter by using somewhat of a EAV structure. I've got the following schema:

I've started by returning the attributes that I want in the facetDefinitions method:

public static function facetDefinitions(): array
    {
        return Attribute::all()
            ->map(fn($attribute) => [
                'title' => $attribute->frontend_label,
                'fieldname' => 'variations.attribute_option.value'
            ])
            ->toArray();
    }

Next, I've built the index using the following code:

public function handle()
    {
        $products = Product::with(['variations'])->get(); 

        $indexer = new Indexer();
        $indexer->resetIndex(); 
        $indexer->buildIndex($products);
    }

The index is successfully stored in the database.

The issue comes when I try to show the facets on the frontend. Whenever I loop over them the getOptions() method returns exactly the same options and if you try to select on, all of the options are being selected. Here's a snippet from the Livewire component:

@foreach ($facets as $key => $facet)
                    <x-mobile-filter-accordion 
                             wire:key="attribute_{{ $facet->getParamName() }}_{{$key}}"
                             title="{{ $facet->title }}"
                    >
                        <div>
                            @foreach ($facet->getOptions() as $option)
                                <div
                                    wire:key="{{$facet->getParamName()}}_option_{{$option->value}}"
                                    class="group flex cursor-pointer items-center justify-start gap-2 text-sm text-gray-600"
                                >
                                    <input type="checkbox"
                                           wire:model="filter.{{ $facet->getParamName() }}"
                                           id="{{ $option->slug }}"
                                           value="{{ $option->value }}"
                                           class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-600"
                                    />

                                    <label for="{{ $option->slug }}" class="{{ $option->selected ? 'selected' : '' }}">
                                        {{ $option->value }} ({{ $option->total }})
                                    </label>
                                </div>
                            @endforeach
                        </div>
                    </x-mobile-filter-accordion>
                @endforeach

I'm not sure if I've done something completely wrong, or just the structure of the database is not compatible for this kind of operations. I've thought about using separate tables, but I need to have flexibility and create attributes on the fly.

Would appreciate if you suggest something. Thanks in advance!

mgussekloo commented 1 month ago

Thanks! I'd start by checking the facet_rows table to see if that looks correct. I'm not sure I understand how you specify which attribute_options should be linked to the Facet being indexed? (But maybe I misunderstand).

If this is the issue, you could solve it by customizing the Indexing of your facets. You'll have to update the library (composer update), otherwise you won't have the buildValues() method. Also have a look at the (also updated) demo project that includes an example of a custom indexer: https://github.com/mgussekloo/Facet-Demo

Let me know if you come up with a solution, or if I can make the library better for your use case.

Start by adding the attribute_id to the facet definition;

public static function facetDefinitions(): array
    {
        return Attribute::all()
            ->map(fn($attribute) => [
                'title' => $attribute->frontend_label,
                'fieldname' => 'id', // this is just a placeholder
                'attribute_id' => $attribute->id
            ])
            ->toArray();
    }

Customize the indexing, e.g.:

class MyCustomIndexer extends \Mgussekloo\FacetFilter\Indexer {
    public function buildValues($facet, $model) {
        $values = parent::buildValues($facet, $model);

        if (isset($facet->attribute_id)) {
// i don't know how your relationships are set up; in any case you need to filter the attribute options for this facet
$values = $model->attribute_options->where('attribute_id', $facet->attribute_id)->pluck('value');
        }

        return $values;
    }
}
georgiarnaudov commented 1 month ago

Hey, thanks for adding a way to build the values! I've achieved the needed result using a few custom custom classes.

Here's how I did it:

  1. I wanted to have a reasonable slug for the facet, so I created a custom class. Now I can have something like App\Models\Product.attribute.color which reads great for me.
<?php

namespace App;

use Illuminate\Support\Str;
use Mgussekloo\FacetFilter\Builders\FacetQueryBuilder;
use Mgussekloo\FacetFilter\Models\Facet;

class AttributeFacet extends Facet
{
    public function getParamName(): string
    {
        return Str::slug($this->fieldname);
    }

    public function getSlug(): string
    {
        return implode('.', [$this->subject_type, 'attribute', $this->fieldname]);
    }

    public function constrainQueryWithFilter($query, $filter): FacetQueryBuilder
    {
        $facetName = $this->getParamName();

        $selectedValues = (isset($filter[$facetName]))
            ? collect($filter[$facetName])->values()
            : collect([]);

        $rows = $this->rows ?? collect();

        // if you have selected ALL, it is the same as selecting none
        $allValues = $rows->pluck('value')->filter()->unique()->values();
        if ($allValues->diff($selectedValues)->isEmpty()) {
            $selectedValues = collect([]);
        }

        // if you must filter
        if ($selectedValues->isNotEmpty()) {
            $query->whereHas('facetrows', function ($query) use ($selectedValues): void {
                $query->select('id')->where('facet_slug', $this->getSlug())->whereIn('value', $selectedValues->toArray());
            });
        }

        return $query;
    }
}

Something to consider maybe is to set the $rows value to an empty collection as a fallback, since some the facets may not be used in the index, therefore there will be an error because the $rows will be null and you can't pluck on null.

This was true in my case, since I'm generating the attributes based on what's in the database, but not exactly what is associated with a product.

  1. Here's how I ended up generating the facetDefinitions
public static function facetDefinitions(): array
    {
        $attributeIdsInUse = ProductVariationValue::query()
            ->distinct('attribute_id')
            ->groupBy('attribute_id')
            ->pluck('attribute_id');

        $attributeFacets = Attribute::query()
            ->whereIn('id', $attributeIdsInUse)
            ->get()
            ->map(fn($attribute) => [
                'title' => $attribute->frontend_label,
                'fieldname' => $attribute->name,
                'related_id' => $attribute->id,
                'facet_class' => AttributeFacet::class
            ])
            ->toArray();

        return [
            [
                'title' => 'Цена',
                'fieldname' => 'finalPrice'
            ],

            ... $attributeFacets,
        ];
    }
  1. The last part of puzzle was to generate a proper index. The flexibility of a custom facet class is great, thanks again!
<?php

namespace App;

use Mgussekloo\FacetFilter\Indexer;

class ProductIndexer extends Indexer
{
    public function buildValues($facet, $model)
    {
        $values = parent::buildValues($facet, $model);

        if ($facet->getSlug() == 'App\Models\Product.finalPrice') {
            foreach ($values as $index => $value) {
                if ($value > 0 && $value <= 10) {
                    $values[$index] = '0-10';
                }
                if ($value >= 10 && $value <= 100) {
                    $values[$index] = '10-100';
                }
                if ($value > 100) {
                    $values[$index] = 'Скъпо';
                }
            }
        }

        if ($facet instanceof AttributeFacet) {
            $values = $values->merge(
                $model->attributeValuesForRepeater
                    ->filter(fn($variation) => $variation->attribute_id === $facet->related_id)
                    ->map(fn($variation) => $variation->attributeValue->value)
            );
        }

        return $values;
    }
}

Everything looks fine and works as expected. There're 2 things that bog my mind though:

https://github.com/user-attachments/assets/94178e59-7aa2-4984-88cd-521bf6786f2c

mgussekloo commented 1 month ago

Great work, thanks! I'll add the $rows fallback to an empty collection, good catch!

For images, I think easiest would be to just have the custom facet class handle this, maybe overwriting getOptions() method?

class CustomFacet extends Facet {

public function getOptions(): Collection
    {
        if (is_null($this->options)) {
$this->options = parent::getOptions()->map(function($option) {
if ($option->value == 'duck') {
$option->image = 'duck.jpg';
return $option;
}
});

For pagination, I don't have a "good" answer yet. For my own project I select() the bare minimum columns to enable FacetFiltering, then further load() the relationships / columns for the current page only. In my case I have ~2000 products and ~10 facets, and this works fast enough.

georgiarnaudov commented 1 month ago

Hey, thanks for the suggestion! Was thinking something like that would work. Here's what I've implemented:

public function getOptions(): Collection
    {
        if (is_null($this->options)) {
            $attributeValues = AttributeValue::query()->with('media')->get();

            $this->options = parent::getOptions()->map(function ($option) use ($attributeValues) {
                $attributeValue = $attributeValues->where('label', '=', $option->value)->first();

                $option->image = !is_null($attributeValue) ?
                    $attributeValue->getFirstMediaUrl('attribute-value-images') :
                    null;

                return $option;
            });
        }

        return $this->options;
    }

It's not the best since I'm making one more query, but it does the job for now.

Regarding pagination I would try out and see if it works for the project.

Thanks again for taking the time to answer and support! Closing, since everything works.

georgiarnaudov commented 1 month ago

Sorry for posting once again, but forgot to ask just out of curiosity how do you handle category pages? In my case products can be part of multiple categories and vice versa, so when I'm on a category page I just query the relationship and then apply the FacetFilter.

mgussekloo commented 1 month ago

No problem, I'm happy someone benefits from this code!

For my project I apply the facet filter to the end of a query that includes some other stuff, e.g.

 $items = Products::whereNotIn('hoviproductid', $illegal_ids)
            ->where('category_id', $catId)
            ->facetsMatchFilter($this->filter)            
            ->get();

This results in the facets only taking into account products in the correct category. Hope this helps.