Skip to content
This repository has been archived by the owner on Apr 1, 2024. It is now read-only.

Commit

Permalink
Merge pull request #3 from dark4ce/master
Browse files Browse the repository at this point in the history
Add orWhereFuzzy option and query logic update
  • Loading branch information
mattkingshott authored Nov 22, 2021
2 parents 54e5318 + 5bf847d commit 82bbdf4
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 44 deletions.
2 changes: 1 addition & 1 deletion src/Macros/OrderByFuzzy.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class OrderByFuzzy
public static function make(Builder $builder, $fields) : Builder
{
foreach ((array) $fields as $field) {
$builder->orderBy('relevance_' . str_replace('.', '_', $field), 'desc');
$builder->orderBy('fuzzy_relevance_' . str_replace('.', '_', $field), 'desc');
}

return $builder;
Expand Down
134 changes: 111 additions & 23 deletions src/Macros/WhereFuzzy.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<?php declare(strict_types = 1);
<?php declare(strict_types=1);

namespace Quest\Macros;

use Illuminate\Database\Query\Builder;
use Quest\Matchers\ExactMatcher;
use Illuminate\Support\Facades\DB;
use Quest\Matchers\AcronymMatcher;
use Quest\Matchers\InStringMatcher;
use Quest\Matchers\StudlyCaseMatcher;
use Illuminate\Database\Query\Builder;
use Quest\Matchers\StartOfWordsMatcher;
use Quest\Matchers\StartOfStringMatcher;
use Quest\Matchers\TimesInStringMatcher;
Expand All @@ -22,52 +22,140 @@ class WhereFuzzy
*
**/
protected static array $matchers = [
ExactMatcher::class => 100,
StartOfStringMatcher::class => 50,
AcronymMatcher::class => 42,
ExactMatcher::class => 100,
StartOfStringMatcher::class => 50,
AcronymMatcher::class => 42,
ConsecutiveCharactersMatcher::class => 40,
StartOfWordsMatcher::class => 35,
StudlyCaseMatcher::class => 32,
InStringMatcher::class => 30,
TimesInStringMatcher::class => 8,
StartOfWordsMatcher::class => 35,
StudlyCaseMatcher::class => 32,
InStringMatcher::class => 30,
TimesInStringMatcher::class => 8,
];

/**
* Construct a fuzzy search expression.
*
**/
public static function make(Builder $builder, $field, $value): Builder
{
$value = static::escapeValue($value);
$nativeField = '`' . str_replace('.', '`.`', trim($field, '` ')) . '`';

if (!is_array($builder->columns) || empty($builder->columns)) {
$builder->columns = ['*'];
}
$builder
->addSelect([static::pipeline($field, $nativeField, $value)])
->having('fuzzy_relevance_' . str_replace('.', '_', $field), '>', 0);

static::calculateTotalRelevanceColumn($builder);
return $builder;
}

/**
* Construct a fuzzy search expression.
*
**/
public static function make(Builder $builder, $field, $value) : Builder
public static function makeOr(Builder $builder, $field, $value): Builder
{
$value = str_replace(['"', "'", '`'], '', $value);

$native = '`' . str_replace('.', '`.`', trim($field, '` ')) . '`';
$value = substr(DB::connection()->getPdo()->quote($value), 1, -1);

if (! is_array($builder->columns) || empty($builder->columns)) {
$value = static::escapeValue($value);
$nativeField = '`' . str_replace('.', '`.`', trim($field, '` ')) . '`';

if (!is_array($builder->columns) || empty($builder->columns)) {
$builder->columns = ['*'];
}
$builder
->addSelect([static::pipeline($field, $nativeField, $value)])
->orHaving('fuzzy_relevance_' . str_replace('.', '_', $field), '>', 0);

static::calculateTotalRelevanceColumn($builder);

return $builder
->addSelect(static::pipeline($field, $native, $value))
->orderBy('relevance_' . str_replace('.', '_', $field), 'desc')
->having('relevance_' . str_replace('.', '_', $field), '>', 0);
return $builder;
}

/**
* Manage relevance columns SUM for total relevance ORDER
* Searches all relevance columns and parses the relevance expressions to create the total relevance column
* and creates the order statement for it
* @param $builder
* @return bool
*/
protected static function calculateTotalRelevanceColumn($builder): bool
{
if (!empty($builder->columns)) {
$existingRelevanceColumns = [];
$sumColumnIdx = null;
// search for fuzzy_relevance_* columns and _fuzzy_relevance_ position
foreach ($builder->columns as $as => $column) {
if ($column instanceof Expression) {
if (stripos($column->getValue(), 'AS fuzzy_relevance_')) {
$matches = [];
preg_match('/AS (fuzzy_relevance_.*)$/', $column->getValue(), $matches);
if (!empty($matches[1])) {
$existingRelevanceColumns[$as] = $matches[1];
}
} elseif (stripos($column->getValue(), 'AS _fuzzy_relevance_')) {
$sumColumnIdx = $as;
}
}
}
// glue together all relevance expresions under _fuzzy_relevance_ column
$relevanceTotalColumn = '';
foreach ($existingRelevanceColumns as $as => $column) {
$relevanceTotalColumn .= (!empty($relevanceTotalColumn) ? ' + ' : '')
. '('
. str_ireplace(' AS ' . $column, '', $builder->columns[$as]->getValue())
. ')';
}
$relevanceTotalColumn .= ' AS _fuzzy_relevance_';

if (is_null($sumColumnIdx)) {
// no sum column yet, just add this one
$builder
->addSelect([new Expression($relevanceTotalColumn)]);
} else {
// update the existing one
$builder->columns[$sumColumnIdx] = new Expression($relevanceTotalColumn);
}
// only add the _fuzzy_relevance_ ORDER once
if (
!$builder->orders
|| ($builder->orders
&& array_search('_fuzzy_relevance_',
array_column($builder->orders, 'column')
) === false
)
) {
$builder->orderBy('_fuzzy_relevance_', 'desc');
}
return true;
}
return false;
}

/**
* Escape value input for fuzzy search
* @param $value
* @return false|string
*/
protected static function escapeValue($value)
{
$value = str_replace(['"', "'", '`'], '', $value);
$value = substr(DB::connection()->getPdo()->quote($value), 1, -1);
return $value;
}

/**
* Execute each of the pattern matching classes to generate the required SQL.
*
**/
protected static function pipeline($field, $native, $value) : Expression
protected static function pipeline($field, $native, $value): Expression
{
$sql = collect(static::$matchers)->map(
fn($multiplier, $matcher) =>
(new $matcher($multiplier))->buildQueryString("COALESCE($native, '')", $value)
fn($multiplier, $matcher) => (new $matcher($multiplier))->buildQueryString("COALESCE($native, '')", $value)
);

return DB::raw($sql->implode(' + ') . ' AS relevance_' . str_replace('.', '_', $field));
return DB::raw($sql->implode(' + ') . ' AS fuzzy_relevance_' . str_replace('.', '_', $field));
}
}
27 changes: 22 additions & 5 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?php declare(strict_types = 1);
<?php declare(strict_types=1);

namespace Quest;

use Quest\Macros\WhereFuzzy;
use Quest\Macros\OrderByFuzzy;
use Illuminate\Database\Query\Builder;
use Quest\Macros\OrderByFuzzy;
use Quest\Macros\WhereFuzzy;
use Illuminate\Support\ServiceProvider as Provider;

class ServiceProvider extends Provider
Expand All @@ -14,9 +14,26 @@ class ServiceProvider extends Provider
* Bootstrap any application services.
*
**/
public function boot() : void
public function boot(): void
{
Builder::macro('orderByFuzzy', fn($fields) => OrderByFuzzy::make($this, $fields));
Builder::macro('whereFuzzy', fn($field, $value) => WhereFuzzy::make($this, $field, $value));

Builder::macro('whereFuzzy', function ($field, $value = null) {
// check if first param is a closure and execute it if it is, passing the current builder as parameter
// so when $query->orWhereFuzzy, $query will be the current query builder, not a new instance
if ($field instanceof \Closure) {
$field($this);
return $this;
}
// if $query->orWhereFuzzy is called in the closure, or directly by the query builder, do this
return WhereFuzzy::make($this, $field, $value);
});
Builder::macro('orWhereFuzzy', function ($field, $value = null) {
if ($field instanceof \Closure) {
$field($this);
return $this;
}
return WhereFuzzy::makeOr($this, $field, $value);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function up() : void
Schema::create('users', function(Blueprint $table) {
$table->bigIncrements('id');
$table->string('name');
$table->string('nickname');
$table->string('country');
});
}
Expand Down
61 changes: 46 additions & 15 deletions tests/PackageTest.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php declare(strict_types = 1);
<?php declare(strict_types=1);

namespace Quest\Tests;

Expand All @@ -13,12 +13,12 @@ class PackageTest extends TestCase
* Setup the test environment.
*
**/
protected function setUp() : void
protected function setUp(): void
{
parent::setUp();

app()['config']->set('database.default', 'mysql');
app()['config']->set('database.connections.mysql', [
$dbSetup = [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
Expand All @@ -33,22 +33,22 @@ protected function setUp() : void
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
]);
];
app()['config']->set('database.connections.mysql', $dbSetup);

(new ServiceProvider(app()))->boot();

$this->loadMigrationsFrom(__DIR__ . '/../support/migrations');

DB::table('users')->truncate();

DB::table('users')->insert(['name' => 'John Doe', 'country' => 'United States']);
DB::table('users')->insert(['name' => 'Jane Doe', 'country' => 'United Kingdom']);
DB::table('users')->insert(['name' => 'Fred Doe', 'country' => 'France']);
DB::table('users')->insert(['name' => 'William Doe', 'country' => 'Italy']);
DB::table('users')->insert(['name' => 'John Doe', 'nickname' => 'jndoe', 'country' => 'United States']);
DB::table('users')->insert(['name' => 'Jane Doe', 'nickname' => 'jndoe', 'country' => 'United Kingdom']);
DB::table('users')->insert(['name' => 'Fred Doe', 'nickname' => 'fredrick', 'country' => 'France']);
DB::table('users')->insert(['name' => 'William Doe', 'nickname' => 'willy', 'country' => 'Italy']);
}



/** @test */
public function it_can_perform_a_fuzzy_search_and_receive_one_result()
{
Expand All @@ -61,7 +61,6 @@ public function it_can_perform_a_fuzzy_search_and_receive_one_result()
}



/** @test */
public function it_can_perform_a_fuzzy_search_and_receive_multiple_results()
{
Expand All @@ -75,7 +74,6 @@ public function it_can_perform_a_fuzzy_search_and_receive_multiple_results()
}



/** @test */
public function it_can_perform_a_fuzzy_search_and_paginate_multiple_results()
{
Expand All @@ -93,7 +91,6 @@ public function it_can_perform_a_fuzzy_search_and_paginate_multiple_results()
}



/** @test */
public function it_can_perform_a_fuzzy_search_across_multiple_fields()
{
Expand All @@ -107,7 +104,6 @@ public function it_can_perform_a_fuzzy_search_across_multiple_fields()
}



/** @test */
public function it_can_order_a_fuzzy_search_by_one_field()
{
Expand All @@ -123,7 +119,6 @@ public function it_can_order_a_fuzzy_search_by_one_field()
}



/** @test */
public function it_can_order_a_fuzzy_search_by_multiple_fields()
{
Expand All @@ -139,7 +134,6 @@ public function it_can_order_a_fuzzy_search_by_multiple_fields()
}



/** @test */
public function it_can_perform_an_eloquent_fuzzy_search()
{
Expand All @@ -149,4 +143,41 @@ public function it_can_perform_an_eloquent_fuzzy_search()
$this->assertCount(1, $results);
$this->assertEquals('Jane Doe', $results->first()->name);
}

/** @test */
public function it_can_perform_an_eloquent_fuzzy_or_search()
{
$results = User::whereFuzzy(function ($query) {
$query->orWhereFuzzy('name', 'jndoe');
$query->orWhereFuzzy('nickname', 'jndoe');
})
->get();

$this->assertEquals('John Doe', $results->first()->name);
}
/** @test */
public function it_can_perform_an_eloquent_fuzzy_or_search_with_order()
{
$results = User::whereFuzzy(function ($query) {
$query->orWhereFuzzy('name', 'jad');
$query->orWhereFuzzy('nickname', 'jndoe');
})
->orderByFuzzy('name')
->get();

$this->assertEquals('Jane Doe', $results->first()->name);
}

/** @test */
public function it_can_perform_an_eloquent_fuzzy_and_search_with_fuzzy_order()
{
$results = User::whereFuzzy(function ($query) {
$query->whereFuzzy('name', 'jad');
$query->whereFuzzy('nickname', 'jndoe');
})
->orderByFuzzy('name')
->get();

$this->assertEquals('Jane Doe', $results->first()->name);
}
}

0 comments on commit 82bbdf4

Please sign in to comment.