Skip to content

Commit

Permalink
Add support for fuzzy searching, result grouping and labelling, and r…
Browse files Browse the repository at this point in the history
…elevance ordering (#7)
  • Loading branch information
bennothommo authored Jul 23, 2024
1 parent cba33e7 commit ebabfa9
Show file tree
Hide file tree
Showing 13 changed files with 624 additions and 48 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,18 @@ Property | Description

### Record handler

Each search handler may also provide a result handler to finely tune how you wish to display or filter the results. At its most simplest, the record handler simply expects an array to be returned for each record that contains 4 properties:
Each search handler may also provide a result handler to finely tune how you wish to display or filter the results. At its most simplest, the record handler simply expects an array to be returned for each record that contains 3 properties:

- `title`: The title of the result.
- `image`: The path to a corresponding image for the result.
- `description`: Additional context for the result.
- `url`: The URL that the result will point to.

It may also optionally provide the following properties to provide more context:

- `group`: The group that this result belongs to, when using grouped results.
- `label`: The label of the result, which may provide more context or grouping for results.
- `image`: The path to a corresponding image for the result.

You may, of course, define additional properties in your array.

The record handler can be configured in a number of different ways.
Expand Down
10 changes: 5 additions & 5 deletions behaviors/Searchable.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use Winter\Storm\Support\Facades\Config;
use Winter\Storm\Database\Traits\SoftDelete;
use Illuminate\Support\Collection as BaseCollection;
use Laravel\Scout\Builder;
use Winter\Search\Classes\Builder;
use Laravel\Scout\Scout;
use Laravel\Scout\Searchable as BaseSearchable;

Expand Down Expand Up @@ -133,7 +133,7 @@ public function queueRemoveFromSearch($models)
*
* @param string $query
* @param \Closure $callback
* @return \Laravel\Scout\Builder
* @return \Winter\Search\Classes\Builder
*/
public static function search($query = '', $callback = null)
{
Expand All @@ -154,7 +154,7 @@ public static function search($query = '', $callback = null)
*
* @param string $query
* @param \Closure $callback
* @return \Laravel\Scout\Builder
* @return \Winter\Search\Classes\Builder
*/
public function doSearch($query = '', $callback = null)
{
Expand Down Expand Up @@ -227,7 +227,7 @@ public static function removeAllFromSearch()
/**
* Get the requested models from an array of object IDs.
*
* @param \Laravel\Scout\Builder $builder
* @param \Winter\Search\Classes\Builder $builder
* @param array $ids
* @return mixed
*/
Expand All @@ -239,7 +239,7 @@ public function getScoutModelsByIds(Builder $builder, array $ids)
/**
* Get a query builder for retrieving the requested models from an array of object IDs.
*
* @param \Laravel\Scout\Builder $builder
* @param \Winter\Search\Classes\Builder $builder
* @param array $ids
* @return mixed
*/
Expand Down
6 changes: 3 additions & 3 deletions behaviors/halcyon/Searchable.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Winter\Search\Behaviors\Halcyon;

use Cms\Classes\Theme;
use Laravel\Scout\Builder;
use Winter\Search\Classes\Builder;
use Illuminate\Support\Collection as BaseCollection;
use Winter\Search\Behaviors\Searchable as BaseSearchable;
use Winter\Search\Classes\HalcyonModelObserver;
Expand Down Expand Up @@ -70,7 +70,7 @@ public static function makeAllSearchable($chunk = null)
/**
* Get the requested models from an array of object IDs.
*
* @param \Laravel\Scout\Builder $builder
* @param \Winter\Search\Classes\Builder $builder
* @param array $ids
* @return mixed
*/
Expand All @@ -82,7 +82,7 @@ public function getScoutModelsByIds(Builder $builder, array $ids)
/**
* Get a query builder for retrieving the requested models from an array of object IDs.
*
* @param \Laravel\Scout\Builder $builder
* @param \Winter\Search\Classes\Builder $builder
* @param array $ids
* @return mixed
*/
Expand Down
108 changes: 108 additions & 0 deletions classes/Builder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace Winter\Search\Classes;

use Laravel\Scout\Builder as BaseBuilder;

class Builder extends BaseBuilder
{
public function getWithRelevance(?callable $relevanceCalculator = null)
{
$collection = $this->engine()->get($this);

$relevanceCalculator = $relevanceCalculator ?? \Closure::fromCallable([$this, 'relevanceCalculator']);

return $collection->map(function ($model) use ($relevanceCalculator) {
$model->relevance = $relevanceCalculator($model, $this->wordifyQuery($this->query));
return $model;
})->sortByDesc('relevance');
}

public function firstRelevant(?callable $relevanceCalculator = null)
{
$collection = $this->engine()->get($this);

$relevanceCalculator = $relevanceCalculator ?? \Closure::fromCallable([$this, 'relevanceCalculator']);

return $collection->map(function ($model) use ($relevanceCalculator) {
$model->relevance = $relevanceCalculator($model, $this->wordifyQuery($this->query));
return $model;
})->sortByDesc('relevance')->first();
}

/**
* Calculates the relevance of a model to a query.
*
* @param \Winter\Storm\Database\Model|\Winter\Storm\Halcyon\Model $model
* @param string $query
* @return float|int
*/
public function relevanceCalculator($model, array $queryWords)
{
// Get ranking map
$rankingMap = $this->getRankingMap($model);

$relevance = 0;
$multiplier = 2;

// Go through and find each word in the searchable fields, with the first word being the most important, and
// each word thereafter being less important
foreach ($rankingMap as $field => $rank) {
foreach ($queryWords as $query) {
$multiplier /= 2;

if (stripos($model->{$field}, $query) !== false) {
// Count matches and multiply by rank
$relevance += (
(substr_count(strtolower($model->{$field}), strtolower($query)) * $rank)
* $multiplier
);
}
}
}

return $relevance;
}

/**
* Gets a ranking map of the searchable fields.
*
* Searchable fields are ordered by descending importance, with the most important field first. It applies ranking
* based on a double sequence.
*
* If no searchable fields are provided, this will return `false`.
*
* @return int[]|false
*/
protected function getRankingMap($model)
{
if (!$model->propertyExists('searchable')) {
return false;
}

$searchable = array_reverse($model->searchable);
$rankingMap = [];
$rank = 1;

foreach ($searchable as $field) {
$rankingMap[$field] = $rank;
$rank *= 2;
}

return array_reverse($rankingMap, true);
}

/**
* Convert a query string into an array of applicable words.
*
* This will strip all stop words and punctuation from the query string, then split each word into an array.
*/
protected function wordifyQuery($query): array
{
$query = preg_replace('/[% ]+/', ' ', strtolower($query));

return array_map(function ($word) {
return trim($word, ' .,');
}, preg_split('/ +/', $query));
}
}
Loading

0 comments on commit ebabfa9

Please sign in to comment.