diff --git a/src/Builder.php b/src/Builder.php index 578bc510..7921dbf5 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -3,11 +3,19 @@ namespace Laravel\Scout; use Illuminate\Container\Container; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\Paginator; +use Illuminate\Support\Arr; +use Illuminate\Support\LazyCollection; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use Laravel\Scout\Contracts\PaginatesEloquentModels; use Laravel\Scout\Contracts\PaginatesEloquentModelsUsingDatabase; +use Illuminate\Contracts\Support\Arrayable; +use Closure; class Builder { @@ -16,7 +24,7 @@ class Builder /** * The model instance. * - * @var \Illuminate\Database\Eloquent\Model + * @var Model */ public $model; @@ -30,14 +38,14 @@ class Builder /** * Optional callback before search execution. * - * @var \Closure|null + * @var Closure|null */ public $callback; /** * Optional callback before model query execution. * - * @var \Closure|null + * @var Closure|null */ public $queryCallback; @@ -90,19 +98,52 @@ class Builder */ public $options = []; + /** + * All clause operators supported by Meilisearch + * + * @var string[] + */ + public $operators = [ + '=', '!=', '>', '>=', '<', '<=', 'TO', 'EXISTS', 'IN', 'NOT', 'AND', 'OR', + ]; + + /** @var null */ + protected $useNewMeilisearchQueryBuilder = false; + + /** + * Transition method to enable new search methods + * + * @return $this + */ + public function enableMeilisearchNewQueryBuilder($status = true) + { + $this->useNewMeilisearchQueryBuilder = (bool) $status; + return $this; + } + + /** + * Transition method + * + * @return bool|null + */ + public function isNewSearchEngineAcive() + { + return $this->useNewMeilisearchQueryBuilder; + } + /** * Create a new search builder instance. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param Model $model * @param string $query - * @param \Closure|null $callback + * @param Closure|null $callback * @param bool $softDelete * @return void */ public function __construct($model, $query, $callback = null, $softDelete = false) { - $this->model = $model; - $this->query = $query; + $this->model = $model; + $this->query = $query; $this->callback = $callback; if ($softDelete) { @@ -124,45 +165,478 @@ public function within($index) } /** - * Add a constraint to the search query. + * Add a full sub-select to the query. * - * @param string $field + * @param Closure|string|array $column + * @param mixed $operator * @param mixed $value + * @param string $boolean * @return $this + * */ - public function where($field, $value) + public function where($column, $operator = null, $value = null, $boolean = 'and') { - $this->wheres[$field] = $value; + + // Unless we're using Meilisearch engine we're going to use original version + if (!method_exists($this, 'isNewSearchEngineAcive') || !$this->isNewSearchEngineAcive()) { + $this->wheres[$column] = $operator; + return $this; + } + + // This is the version rewritten to handle all filters on a more native Laravel's way + // supporting natively all operators and nested queries natively with Meilisearch + + // If the column is an array, we will assume it is an array of key-value pairs + // and can add them each as a where clause. We will maintain the boolean we + // received when the method was called and pass it into the nested filter. + + if (is_array($column)) { + return $this->addArrayOfWheres($column, $boolean); + } + + // Here we will make some assumptions about the operator. If only 2 values are + // passed to the method, we will assume that the operator is an equals sign + // and keep going. Otherwise, we'll require the operator to be passed in. + [$value, $operator] = $this->prepareValueAndOperator($value, $operator, func_num_args() === 2); + + // If the column is actually a Closure instance, we will assume the developer + // wants to begin a nested where statement which is wrapped in parentheses. + // We will add that Closure to the query and return back out immediately. + if ($column instanceof Closure && is_null($operator)) { + return $this->whereNested($column, $boolean); + } + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + // If the value is "null", we will just assume the developer wants to add a + // where null clause to the query. So, we will allow a short-cut here to + // that method for convenience so the developer doesn't have to check. + if (is_null($value)) { + return $this->whereNull($column, $boolean, $operator !== '='); + } + + $type = 'Basic'; + + // Now that we are working with just a simple query we can put the elements + // in our array and add the query binding to our array of bindings that + // will be bound to each SQL statements when it is finally executed. + $this->wheres[] = compact( + 'type', 'column', 'operator', 'value', 'boolean' + ); return $this; } /** - * Add a "where in" constraint to the search query. + * Add a "where in" clause to the query. + * + * @param string $column + * @param mixed $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereIn($column, $values, $boolean = 'and', $not = false) + { + + if (!method_exists($this, 'isNewSearchEngineAcive') || !$this->isNewSearchEngineAcive()) { + $this->whereIns[$column] = $values; + return $this; + } + + $type = $not ? 'NotIn' : 'In'; + + // Next, if the value is Arrayable we need to cast it to its raw array form so we + // have the underlying array value instead of an Arrayable object which is not + // able to be added as a binding, etc. We will then add to the wheres array. + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } elseif (!is_array($values)) { + $values = [$values]; + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + + if (is_array($values) && count($values) !== count(Arr::flatten($values, 1))) { + throw new InvalidArgumentException('Nested arrays may not be passed to whereIn method.'); + } + + return $this; + } + + /** + * Add an "or where in" clause to the query. + * + * @param Expression|string $column + * @param mixed $values + * @return $this + */ + public function orWhereIn($column, $values) + { + return $this->whereIn($column, $values, 'or'); + } + + /** + * Add a "where not in" clause to the query. + * + * @param string $column + * @param mixed $values + * @param string $boolean + * @return $this + */ + public function whereNotIn($column, $values, $boolean = 'and') + { + if (!method_exists($this, 'isNewSearchEngineAcive') || !$this->isNewSearchEngineAcive()) { + $this->whereNotIns[$column] = $values; + + return $this; + } + + return $this->whereIn($column, $values, $boolean, true); + } + + /** + * Add an exists clause to the query. * - * @param string $field - * @param array $values + * @param string $column + * @param string $boolean + * @param bool $not * @return $this */ - public function whereIn($field, array $values) + public function addWhereExists($column, $boolean = 'and', $not = false) { - $this->whereIns[$field] = $values; + $type = $not ? 'NotExists' : 'Exists'; + + $this->wheres[] = compact('type', 'column', 'boolean'); return $this; } /** - * Add a "where not in" constraint to the search query. + * Add a where not exists clause to the query. + * + * @param string $column + * @param string $boolean + * @return $this + */ + public function whereExists($column, $boolean = 'and') + { + return $this->addWhereExists($column, $boolean); + } + + /** + * Add an or exists clause to the query. + * + * @param string $column + * @return $this + */ + public function orWhereExists($column) + { + return $this->whereExists($column, 'or'); + } + + /** + * Add a where not exists clause to the query. * - * @param string $field - * @param array $values + * @param string $column * @return $this */ - public function whereNotIn($field, array $values) + public function whereNotExists($column, $boolean = 'and') { - $this->whereNotIns[$field] = $values; + return $this->addWhereExists($column, $boolean, true); + } + + /** + * Add a where not exists clause to the query. + * + * @param string $column + * @return $this + */ + public function orWhereNotExists($column) + { + return $this->addWhereExists($column, 'or', true); + } + + /** + * Add a where between statement to the query. + * + * @param string $column + * @param array $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereBetween($column, $values, $boolean = 'and', $not = false) + { + + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } elseif (!is_array($values)) { + $values = [$values]; + } + + if (count($values) !== 2 || count($values, COUNT_RECURSIVE) !== 2) { + throw new InvalidArgumentException('Between only supports an array with two values.'); + } + + // We're adding this case to be consistent accessing arrays with 0 and 1 indexes + $values = array_values($values); + + $type = 'between'; + + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); return $this; + + } + + /** + * Add an "or where" clause to the query. + * + * @param Closure|string|array $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhere($column, $operator = null, $value = null) + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + return $this->where($column, $operator, $value, 'or'); + } + + /** + * Add a "filter null" clause to the query. + * + * @param string $columns + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereNull($columns, $boolean = 'and', $not = false) + { + $type = $not ? 'NotNull' : 'Null'; + + foreach (Arr::wrap($columns) as $column) { + $this->wheres[] = compact('type', 'column', 'boolean'); + } + + return $this; + } + + /** + * Add an "or where null" clause to the query. + * + * @param string|array $column + * @return $this + */ + public function orWhereNull($column) + { + return $this->whereNull($column, 'or'); + } + + /** + * Add a "where not null" clause to the query. + * + * @param string|array $columns + * @param string $boolean + * @return $this + */ + public function whereNotNull($columns, $boolean = 'and') + { + return $this->whereNull($columns, $boolean, true); + } + + /** + * Add a "where not in" clause to the query. + * + * @param string $column + * @return $this + */ + public function addWhereIsEmpty($column, $boolean = 'and', $not = false) + { + $type = $not ? 'IsNotEmpty' : 'IsEmpty'; + + $this->wheres[] = compact('type', 'column', 'boolean'); + + return $this; + } + + /** + * Add a "where IS EMPTY" clause to the query. + * + * @param string $column + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereIsEmpty($column, $boolean = 'and', $not = false) + { + return $this->addWhereIsEmpty($column, $boolean, $not); + } + + /** + * Add an or IS EMPTY clause to the query. + * + * @param string $column + * @return $this + */ + public function orWhereIsEmpty($column) + { + return $this->addWhereIsEmpty($column, 'or'); + } + + /** + * + * Add an or IS NOT EMPTY clause to the query. + * + * @param string $column + * @param string $boolean + * @return $this + */ + public function whereIsNotEmpty($column, $boolean = 'and') + { + return $this->addWhereIsEmpty($column, $boolean, true); + } + + /** + * Create a new query instance for nested filter condition. + * + * @return Builder + */ + public function forNestedWhere() + { + return $this->newQuery(); + } + + /** + * Add a nested filter statement to the query. + * + * @param Closure $callback + * @param string $boolean + * @return $this + */ + public function whereNested(Closure $callback, $boolean = 'and') + { + $callback($query = $this->forNestedWhere()); + + return $this->addNestedWhereQuery($query, $boolean); + } + + /** + * Determine if the given operator is supported. + * + * @param string $operator + * @return bool + */ + protected function invalidOperator($operator) + { + return !is_string($operator) || (!in_array(strtolower($operator), $this->operators, true)); + } + + /** + * Add an array of where clauses to the query. + * + * @param array $column + * @param string $boolean + * @param string $method + * @return $this + */ + protected function addArrayOfWheres($column, $boolean, $method = 'where') + { + + return $this->whereNested(function ($query) use ($column, $method, $boolean) { + foreach ($column as $key => $value) { + if (is_numeric($key) && is_array($value)) { + $query->{$method}(...array_values($value)); + } else { + $query->{$method}($key, '=', $value, $boolean); + } + } + }, $boolean); + + } + + /** + * Determine if the given operator and value combination is legal. + * + * Prevents using Null values with invalid operators. + * + * @param string $operator + * @param mixed $value + * @return bool + */ + protected function invalidOperatorAndValue($operator, $value) + { + return is_null($value) && in_array($operator, $this->operators) && !in_array($operator, ['=', '<>', '!=']); + } + + /** + * Prepare the value and operator for a filter clause. + * + * @param string $value + * @param string $operator + * @param bool $useDefault + * @return array + * + * @throws InvalidArgumentException + */ + public function prepareValueAndOperator($value, $operator, $useDefault = false) + { + if ($useDefault) { + return [$operator, '=']; + } elseif ($this->invalidOperatorAndValue($operator, $value)) { + throw new InvalidArgumentException('Illegal operator and value combination.'); + } + + return [$value, $operator]; + } + + /** + * Add another query builder as a nested where to the query builder. + * + * @param Builder $query + * @param string $boolean + * @return $this + */ + public function addNestedWhereQuery($query, $boolean = 'and') + { + + if (count($query->wheres)) { + $type = 'Nested'; + + $this->wheres[] = compact('type', 'query', 'boolean'); + } + + return $this; + } + + /** + * Get a new instance of the query builder. + * + * @return Builder + */ + public function newQuery() + { + + $query = new self( + $this->model, + null, + null, + in_array('Illuminate\Database\Eloquent\SoftDeletes', class_uses($this->model)) + ); + + // Inherit actual usage flag + $query->enableMeilisearchNewQueryBuilder($this->isNewSearchEngineAcive()); + + return $query; } /** @@ -212,7 +686,7 @@ public function take($limit) public function orderBy($column, $direction = 'asc') { $this->orders[] = [ - 'column' => $column, + 'column' => $column, 'direction' => strtolower($direction) == 'asc' ? 'asc' : 'desc', ]; @@ -243,9 +717,9 @@ public function options(array $options) public function when($value, $callback, $default = null) { if ($value) { - return $callback($this, $value) ?: $this; + return $callback($this, $value) ? : $this; } elseif ($default) { - return $default($this, $value) ?: $this; + return $default($this, $value) ? : $this; } return $this; @@ -254,7 +728,7 @@ public function when($value, $callback, $default = null) /** * Pass the query to a given callback. * - * @param \Closure $callback + * @param Closure $callback * @return $this */ public function tap($callback) @@ -298,7 +772,7 @@ public function keys() /** * Get the first result from the search. * - * @return \Illuminate\Database\Eloquent\Model + * @return Model */ public function first() { @@ -308,7 +782,7 @@ public function first() /** * Get the results of the search. * - * @return \Illuminate\Database\Eloquent\Collection + * @return Collection */ public function get() { @@ -318,7 +792,7 @@ public function get() /** * Get the results of the search as a "lazy collection" instance. * - * @return \Illuminate\Support\LazyCollection + * @return LazyCollection */ public function cursor() { @@ -340,23 +814,24 @@ public function simplePaginate($perPage = null, $pageName = 'page', $page = null if ($engine instanceof PaginatesEloquentModels) { return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { - return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', + $this->query); } - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ? : Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?: $this->model->getPerPage(); + $perPage = $perPage ? : $this->model->getPerPage(); $results = $this->model->newCollection($engine->map( $this, $rawResults = $engine->paginate($this, $perPage, $page), $this->model )->all()); $paginator = Container::getInstance()->makeWith(Paginator::class, [ - 'items' => $results, - 'perPage' => $perPage, + 'items' => $results, + 'perPage' => $perPage, 'currentPage' => $page, - 'options' => [ - 'path' => Paginator::resolveCurrentPath(), + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($rawResults)); @@ -379,21 +854,22 @@ public function simplePaginateRaw($perPage = null, $pageName = 'page', $page = n if ($engine instanceof PaginatesEloquentModels) { return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); } elseif ($engine instanceof PaginatesEloquentModelsUsingDatabase) { - return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', + $this->query); } - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ? : Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?: $this->model->getPerPage(); + $perPage = $perPage ? : $this->model->getPerPage(); $results = $engine->paginate($this, $perPage, $page); $paginator = Container::getInstance()->makeWith(Paginator::class, [ - 'items' => $results, - 'perPage' => $perPage, + 'items' => $results, + 'perPage' => $perPage, 'currentPage' => $page, - 'options' => [ - 'path' => Paginator::resolveCurrentPath(), + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], ])->hasMorePagesWhen(($perPage * $page) < $engine->getTotalCount($results)); @@ -419,21 +895,21 @@ public function paginate($perPage = null, $pageName = 'page', $page = null) return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); } - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ? : Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?: $this->model->getPerPage(); + $perPage = $perPage ? : $this->model->getPerPage(); $results = $this->model->newCollection($engine->map( $this, $rawResults = $engine->paginate($this, $perPage, $page), $this->model )->all()); return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ - 'items' => $results, - 'total' => $this->getTotalCount($rawResults), - 'perPage' => $perPage, + 'items' => $results, + 'total' => $this->getTotalCount($rawResults), + 'perPage' => $perPage, 'currentPage' => $page, - 'options' => [ - 'path' => Paginator::resolveCurrentPath(), + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], ])->appends('query', $this->query); @@ -457,19 +933,19 @@ public function paginateRaw($perPage = null, $pageName = 'page', $page = null) return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); } - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ? : Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?: $this->model->getPerPage(); + $perPage = $perPage ? : $this->model->getPerPage(); $results = $engine->paginate($this, $perPage, $page); return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ - 'items' => $results, - 'total' => $this->getTotalCount($results), - 'perPage' => $perPage, + 'items' => $results, + 'total' => $this->getTotalCount($results), + 'perPage' => $perPage, 'currentPage' => $page, - 'options' => [ - 'path' => Paginator::resolveCurrentPath(), + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, ], ])->appends('query', $this->query); diff --git a/src/Engines/MeilisearchEngine.php b/src/Engines/MeilisearchEngine.php index eb8f4d28..33e1f9d1 100644 --- a/src/Engines/MeilisearchEngine.php +++ b/src/Engines/MeilisearchEngine.php @@ -2,12 +2,16 @@ namespace Laravel\Scout\Engines; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\LazyCollection; +use InvalidArgumentException; use Laravel\Scout\Builder; use Laravel\Scout\Jobs\RemoveableScoutCollection; use Meilisearch\Client as MeilisearchClient; use Meilisearch\Contracts\IndexesQuery; -use Meilisearch\Meilisearch; +use Meilisearch\Exceptions\ApiException; use Meilisearch\Search\SearchResult; class MeilisearchEngine extends Engine @@ -15,7 +19,7 @@ class MeilisearchEngine extends Engine /** * The Meilisearch client. * - * @var \Meilisearch\Client + * @var MeilisearchClient */ protected $meilisearch; @@ -29,23 +33,23 @@ class MeilisearchEngine extends Engine /** * Create a new MeilisearchEngine instance. * - * @param \Meilisearch\Client $meilisearch + * @param MeilisearchClient $meilisearch * @param bool $softDelete * @return void */ public function __construct(MeilisearchClient $meilisearch, $softDelete = false) { $this->meilisearch = $meilisearch; - $this->softDelete = $softDelete; + $this->softDelete = $softDelete; } /** * Update the given model in the index. * - * @param \Illuminate\Database\Eloquent\Collection $models + * @param Collection $models * @return void * - * @throws \Meilisearch\Exceptions\ApiException + * @throws ApiException */ public function update($models) { @@ -71,7 +75,7 @@ public function update($models) ); })->filter()->values()->all(); - if (! empty($objects)) { + if (!empty($objects)) { $index->addDocuments($objects, $models->first()->getScoutKeyName()); } } @@ -79,7 +83,7 @@ public function update($models) /** * Remove the given model from the index. * - * @param \Illuminate\Database\Eloquent\Collection $models + * @param Collection $models * @return void */ public function delete($models) @@ -104,10 +108,12 @@ public function delete($models) */ public function search(Builder $builder) { +// dd($this->filters($builder)); + return $this->performSearch($builder, array_filter([ - 'filter' => $this->filters($builder), + 'filter' => $this->filters($builder), 'hitsPerPage' => $builder->limit, - 'sort' => $this->buildSortFromOrderByClauses($builder), + 'sort' => $this->buildSortFromOrderByClauses($builder), ])); } @@ -123,23 +129,23 @@ public function search(Builder $builder) public function paginate(Builder $builder, $perPage, $page) { return $this->performSearch($builder, array_filter([ - 'filter' => $this->filters($builder), + 'filter' => $this->filters($builder), 'hitsPerPage' => (int) $perPage, - 'page' => $page, - 'sort' => $this->buildSortFromOrderByClauses($builder), + 'page' => $page, + 'sort' => $this->buildSortFromOrderByClauses($builder), ])); } /** * Perform the given search on the engine. * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @param array $searchParams * @return mixed */ protected function performSearch(Builder $builder, array $searchParams = []) { - $meilisearch = $this->meilisearch->index($builder->index ?: $builder->model->searchableAs()); + $meilisearch = $this->meilisearch->index($builder->index ? : $builder->model->searchableAs()); $searchParams = array_merge($builder->options, $searchParams); @@ -160,7 +166,7 @@ protected function performSearch(Builder $builder, array $searchParams = []) $searchResultClass = class_exists(SearchResult::class) ? SearchResult::class - : \Meilisearch\Search\SearchResult; + : SearchResult; return $result instanceof $searchResultClass ? $result->getRaw() : $result; } @@ -169,51 +175,200 @@ protected function performSearch(Builder $builder, array $searchParams = []) } /** - * Get the filter array for the query. + * @param mixed $value + * @return string + */ + protected function formatFilterValues($value) + { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return (filter_var($value, FILTER_VALIDATE_INT)) + ? sprintf('%s', $value) + : sprintf('"%s"', $value); + } + + /** + * @param $column string + * @param $value mixed + * @param $operator null|string + * @return string + */ + protected function parseFilterExpressions($column, $value, $operator = null) + { + + if ($operator === 'Exists') { + return sprintf('%s EXISTS', $column,); + } + + if (in_array($operator, ['Null', 'NotNull'])) { + return sprintf('%s %s', + $column, + $operator === 'Null' ? "IS NULL" : "IS NOT NULL" + ); + } + + // Note: Meilisearch does not treat null values as empty. To match null fields, use the IS NULL operator. + if (in_array($operator, ['IsEmpty', 'IsNotEmpty'])) { + return sprintf('%s %s', + $column, + $operator === 'IsEmpty' ? "IS EMPTY" : "IS NOT EMPTY" + ); + } + + if (is_array($value)) { + + // Meilisearch uses "TO" operator as equivalent to >= AND <= + if ($operator === 'between') { + return sprintf('%s %s TO %s', + $column, + $this->formatFilterValues($value[0]), + $this->formatFilterValues($value[1]), + ); + } + + // Where IN/NOT IN + if (in_array($operator, ['In', 'NotIn'])) { + + return sprintf('%s %s [%s]', + $column, + $operator === 'In' ? "IN" : "NOT IN", + implode(', ', collect($value)->map(fn($v) => $this->formatFilterValues($v))->toArray()) + ); + } + } + + if (empty($operator)) { + $operator = '='; + } + + return sprintf('%s%s%s', $column, $operator, $this->formatFilterValues($value)); + } + + /** + * Get the filter expression to be used with the query * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @return string */ protected function filters(Builder $builder) { - $filters = collect($builder->wheres)->map(function ($value, $key) { - if (is_bool($value)) { - return sprintf('%s=%s', $key, $value ? 'true' : 'false'); + + // Transition check, original version + if (!method_exists($builder, 'isNewSearchEngineAcive') || !$builder->isNewSearchEngineAcive()) { + + $filters = collect($builder->wheres)->map(function ($value, $key) { + if (is_bool($value)) { + return sprintf('%s=%s', $key, $value ? 'true' : 'false'); + } + + return is_numeric($value) + ? sprintf('%s=%s', $key, $value) + : sprintf('%s="%s"', $key, $value); + }); + + $whereInOperators = [ + 'whereIns' => 'IN', + 'whereNotIns' => 'NOT IN', + ]; + + foreach ($whereInOperators as $property => $operator) { + if (property_exists($builder, $property)) { + foreach ($builder->{$property} as $key => $values) { + $filters->push(sprintf('%s %s [%s]', $key, $operator, collect($values)->map(function ($value) { + if (is_bool($value)) { + return sprintf('%s', $value ? 'true' : 'false'); + } + + return filter_var($value, FILTER_VALIDATE_INT) !== false + ? sprintf('%s', $value) + : sprintf('"%s"', $value); + })->values()->implode(', '))); + } + } + } + + return $filters->values()->implode(' AND '); + + } + + // New rewritten version + if (!is_array($builder->wheres) || empty($builder->wheres)) { + return ''; + } + + $stack = []; + + foreach ($builder->wheres as $expression) { + + if (!empty($stack)) { + $stack[] = strtoupper($expression['boolean']); } - return is_numeric($value) - ? sprintf('%s=%s', $key, $value) - : sprintf('%s="%s"', $key, $value); - }); - - $whereInOperators = [ - 'whereIns' => 'IN', - 'whereNotIns' => 'NOT IN', - ]; - - foreach ($whereInOperators as $property => $operator) { - if (property_exists($builder, $property)) { - foreach ($builder->{$property} as $key => $values) { - $filters->push(sprintf('%s %s [%s]', $key, $operator, collect($values)->map(function ($value) { - if (is_bool($value)) { - return sprintf('%s', $value ? 'true' : 'false'); - } - - return filter_var($value, FILTER_VALIDATE_INT) !== false - ? sprintf('%s', $value) - : sprintf('"%s"', $value); - })->values()->implode(', '))); + $type = $expression['type']; + + // Nested "( Expression )" + if ($type === 'Nested' && array_key_exists('query', $expression)) { + + // Recursive nested expression + $stack[] = "(".$this->filters($expression['query']).")"; + + } else { + + // With NotNull/Null expressions we're only need column name + $value = $expression['value'] ?? $expression['values'] ?? null; + $column = $expression['column']; + + if ($type === 'Basic' && array_key_exists('operator', $expression)) { + + // Only with a Basic expression is where we need to use an operator + $operator = $expression['operator']; + $stack[] = $this->parseFilterExpressions($column, $value, $operator); + + } else { + + // I'm using "between" in lowercase to be consistent with \Illuminate\Database\Query\Builder + if ($type === 'between') { + + $stack[] = $this->parseFilterExpressions($column, $value, $type); + + } elseif ($type === 'Exists') { + + $stack[] = $this->parseFilterExpressions($column, $value, $type); + + } elseif (in_array($type, ['IsEmpty', 'IsNotEmpty'])) { + + $stack[] = $this->parseFilterExpressions($column, $value, $type); + + } elseif (in_array($type, ['In', 'NotIn'])) { + + $stack[] = $this->parseFilterExpressions($column, $value, $type); + + } elseif (in_array($type, ['Null', 'NotNull'])) { + + $stack[] = $this->parseFilterExpressions($column, null, $type); + + } else { + + throw new InvalidArgumentException("{$type} expression not supported"); + + } + } + } + } - return $filters->values()->implode(' AND '); + return implode(' ', $stack); + } /** * Get the sort array for the query. * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @return array */ protected function buildSortFromOrderByClauses(Builder $builder): array @@ -254,14 +409,14 @@ public function mapIds($results) public function mapIdsFrom($results, $key) { return count($results['hits']) === 0 - ? collect() - : collect($results['hits'])->pluck($key)->values(); + ? collect() + : collect($results['hits'])->pluck($key)->values(); } /** * Get the results of the query as a Collection of primary keys. * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @return \Illuminate\Support\Collection */ public function keys(Builder $builder) @@ -274,10 +429,10 @@ public function keys(Builder $builder) /** * Map the given results to instances of the given model. * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @param mixed $results - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Database\Eloquent\Collection + * @param Model $model + * @return Collection */ public function map(Builder $builder, $results, $model) { @@ -301,10 +456,10 @@ public function map(Builder $builder, $results, $model) /** * Map the given results to instances of the given model via a lazy collection. * - * @param \Laravel\Scout\Builder $builder + * @param Builder $builder * @param mixed $results - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Support\LazyCollection + * @param Model $model + * @return LazyCollection */ public function lazyMap(Builder $builder, $results, $model) { @@ -312,7 +467,7 @@ public function lazyMap(Builder $builder, $results, $model) return LazyCollection::make($model->newCollection()); } - $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all(); + $objectIds = collect($results['hits'])->pluck($model->getScoutKeyName())->values()->all(); $objectIdPositions = array_flip($objectIds); return $model->queryScoutModelsByIds( @@ -338,7 +493,7 @@ public function getTotalCount($results) /** * Flush all of the model's records from the engine. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param Model $model * @return void */ public function flush($model) @@ -355,7 +510,7 @@ public function flush($model) * @param array $options * @return mixed * - * @throws \Meilisearch\Exceptions\ApiException + * @throws ApiException */ public function createIndex($name, array $options = []) { @@ -369,7 +524,7 @@ public function createIndex($name, array $options = []) * @param array $options * @return array * - * @throws \Meilisearch\Exceptions\ApiException + * @throws ApiException */ public function updateIndexSettings($name, array $options = []) { @@ -382,7 +537,7 @@ public function updateIndexSettings($name, array $options = []) * @param string $name * @return mixed * - * @throws \Meilisearch\Exceptions\ApiException + * @throws ApiException */ public function deleteIndex($name) { @@ -414,12 +569,12 @@ public function deleteAllIndexes() /** * Determine if the given model uses soft deletes. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param Model $model * @return bool */ protected function usesSoftDelete($model) { - return in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($model)); + return in_array(SoftDeletes::class, class_uses_recursive($model)); } /** diff --git a/tests/Unit/MeilisearchEngineTest.php b/tests/Unit/MeilisearchEngineTest.php index 87887417..6575f95b 100644 --- a/tests/Unit/MeilisearchEngineTest.php +++ b/tests/Unit/MeilisearchEngineTest.php @@ -121,7 +121,7 @@ public function test_search_sends_correct_parameters_to_meilisearch() 'filter' => 'foo=1 AND bar=2', ]); - $engine = new MeilisearchEngine($client); + $engine = new MeilisearchEngine($client); $builder = new Builder(new SearchableModel(), 'mustang', function ($meilisearch, $query, $options) { $options['filter'] = 'foo=1 AND bar=2'; @@ -135,12 +135,12 @@ public function test_search_includes_at_least_scoutKeyName_in_attributesToRetrie $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('search')->with('mustang', [ - 'filter' => 'foo=1 AND bar=2', + 'filter' => 'foo=1 AND bar=2', 'attributesToRetrieve' => ['id', 'foo'], ]); - $engine = new MeilisearchEngine($client); - $builder = new Builder(new SearchableModel(), 'mustang', function ($meilisearch, $query, $options) { + $engine = new MeilisearchEngine($client); + $builder = new Builder(new SearchableModel(), 'mustang', function ($meilisearch, $query, $options) { $options['filter'] = 'foo=1 AND bar=2'; return $meilisearch->search($query, $options); @@ -160,16 +160,17 @@ public function test_submitting_a_callable_search_with_search_method_returns_arr return $meilisearch->search($query, $options); } ); - $client = m::mock(Client::class); + $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); - $index->shouldReceive('search')->with($query, ['filter' => 'foo=1'])->andReturn(new SearchResult($expectedResult = [ - 'hits' => [], - 'page' => 1, - 'hitsPerPage' => $builder->limit, - 'totalPages' => 1, - 'totalHits' => 0, + $index->shouldReceive('search')->with($query, + ['filter' => 'foo=1'])->andReturn(new SearchResult($expectedResult = [ + 'hits' => [], + 'page' => 1, + 'hitsPerPage' => $builder->limit, + 'totalPages' => 1, + 'totalHits' => 0, 'processingTimeMs' => 1, - 'query' => 'mustang', + 'query' => 'mustang', ])); $engine = new MeilisearchEngine($client); @@ -189,16 +190,16 @@ public function test_submitting_a_callable_search_with_raw_search_method_works() return $meilisearch->rawSearch($query, $options); } ); - $client = m::mock(Client::class); + $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->with($query, ['filter' => 'foo=1'])->andReturn($expectedResult = [ - 'hits' => [], - 'page' => 1, - 'hitsPerPage' => $builder->limit, - 'totalPages' => 1, - 'totalHits' => 0, + 'hits' => [], + 'page' => 1, + 'hitsPerPage' => $builder->limit, + 'totalPages' => 1, + 'totalHits' => 0, 'processingTimeMs' => 1, - 'query' => $query, + 'query' => $query, ]); $engine = new MeilisearchEngine($client); @@ -214,7 +215,7 @@ public function test_map_ids_returns_empty_collection_if_no_hits() $results = $engine->mapIdsFrom([ 'totalHits' => 0, - 'hits' => [], + 'hits' => [], ], 'id'); $this->assertEquals(0, count($results)); @@ -227,22 +228,22 @@ public function test_map_ids_returns_correct_values_of_primary_key() $results = $engine->mapIdsFrom([ 'totalHits' => 5, - 'hits' => [ + 'hits' => [ [ 'some_field' => 'something', - 'id' => 1, + 'id' => 1, ], [ 'some_field' => 'foo', - 'id' => 2, + 'id' => 2, ], [ 'some_field' => 'bar', - 'id' => 3, + 'id' => 3, ], [ 'some_field' => 'baz', - 'id' => 4, + 'id' => 4, ], ], ], 'id'); @@ -257,7 +258,7 @@ public function test_map_ids_returns_correct_values_of_primary_key() public function test_returns_primary_keys_when_custom_array_order_present() { - $engine = m::mock(MeilisearchEngine::class); + $engine = m::mock(MeilisearchEngine::class); $builder = m::mock(Builder::class); $model = m::mock(stdClass::class); @@ -269,12 +270,14 @@ public function test_returns_primary_keys_when_custom_array_order_present() $engine ->shouldReceive('search') ->once() - ->andReturn([]); + ->andReturn([]) + ; $engine ->shouldReceive('mapIdsFrom') ->once() - ->with([], 'custom_key'); + ->with([], 'custom_key') + ; $engine->keys($builder); } @@ -291,7 +294,7 @@ public function test_map_correctly_maps_results_to_models() $results = $engine->map($builder, [ 'totalHits' => 1, - 'hits' => [ + 'hits' => [ ['id' => 1], ], ], $model); @@ -317,7 +320,7 @@ public function test_map_method_respects_order() $results = $engine->map($builder, [ 'totalHits' => 4, - 'hits' => [ + 'hits' => [ ['id' => 1], ['id' => 2], ['id' => 4], @@ -346,7 +349,7 @@ public function test_lazy_map_correctly_maps_results_to_models() $results = $engine->lazyMap($builder, [ 'totalHits' => 1, - 'hits' => [ + 'hits' => [ ['id' => 1], ], ], $model); @@ -372,7 +375,7 @@ public function test_lazy_map_method_respects_order() $results = $engine->lazyMap($builder, [ 'totalHits' => 4, - 'hits' => [ + 'hits' => [ ['id' => 1], ['id' => 2], ['id' => 4], @@ -393,10 +396,12 @@ public function test_a_model_is_indexed_with_a_custom_meilisearch_key() { $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); - $index->shouldReceive('addDocuments')->once()->with([[ - 'meilisearch-key' => 'my-meilisearch-key.5', - 'id' => 5, - ]], 'meilisearch-key'); + $index->shouldReceive('addDocuments')->once()->with([ + [ + 'meilisearch-key' => 'my-meilisearch-key.5', + 'id' => 5, + ] + ], 'meilisearch-key'); $engine = new MeilisearchEngine($client); $engine->update(Collection::make([new MeilisearchCustomKeySearchableModel(['id' => 5])])); @@ -425,17 +430,17 @@ public function test_update_empty_searchable_array_does_not_add_documents_to_ind public function test_pagination_correct_parameters() { $perPage = 5; - $page = 2; + $page = 2; $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('search')->with('mustang', [ - 'filter' => 'foo=1', + 'filter' => 'foo=1', 'hitsPerPage' => $perPage, - 'page' => $page, + 'page' => $page, ]); - $engine = new MeilisearchEngine($client); + $engine = new MeilisearchEngine($client); $builder = new Builder(new SearchableModel(), 'mustang', function ($meilisearch, $query, $options) { $options['filter'] = 'foo=1'; @@ -447,18 +452,18 @@ public function test_pagination_correct_parameters() public function test_pagination_sorted_parameter() { $perPage = 5; - $page = 2; + $page = 2; $client = m::mock(Client::class); $client->shouldReceive('index')->with('table')->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('search')->with('mustang', [ - 'filter' => 'foo=1', + 'filter' => 'foo=1', 'hitsPerPage' => $perPage, - 'page' => $page, - 'sort' => ['name:asc'], + 'page' => $page, + 'sort' => ['name:asc'], ]); - $engine = new MeilisearchEngine($client); + $engine = new MeilisearchEngine($client); $builder = new Builder(new SearchableModel(), 'mustang', function ($meilisearch, $query, $options) { $options['filter'] = 'foo=1'; @@ -502,7 +507,7 @@ public function test_performing_search_without_callback_works() $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->andReturn([]); - $engine = new MeilisearchEngine($client); + $engine = new MeilisearchEngine($client); $builder = new Builder(new SearchableModel(), ''); $engine->search($builder); } @@ -515,7 +520,28 @@ public function test_where_conditions_are_applied() $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'foo="bar" AND key="value"', + 'filter' => 'foo="bar" AND key="value"', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_conditions_are_applied_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder + ->enableMeilisearchNewQueryBuilder() + ->where('foo', '=', 'bar') + ->where('key', 'value') + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo="bar" AND key="value"', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -533,7 +559,30 @@ public function test_where_in_conditions_are_applied() $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2]', + 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2]', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_in_conditions_are_applied_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder + ->enableMeilisearchNewQueryBuilder() + ->where('foo', '=', 'bar') + ->where('bar', 'baz') + ->whereIn('qux', [1, 2]) + ->whereIn('quux', [1, 2]) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2]', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -552,7 +601,31 @@ public function test_where_not_in_conditions_are_applied() $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', + 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_not_in_conditions_are_applied_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder + ->enableMeilisearchNewQueryBuilder() + ->where('foo', '=', 'bar') + ->where('bar', 'baz') + ->whereIn('qux', [1, 2]) + ->whereIn('quux', [1, 2]) + ->whereNotIn('eaea', 3) // It could receive non-arrays... + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo="bar" AND bar="baz" AND qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -568,7 +641,28 @@ public function test_where_in_conditions_are_applied_without_other_conditions() $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'qux IN [1, 2] AND quux IN [1, 2]', + 'filter' => 'qux IN [1, 2] AND quux IN [1, 2]', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_in_conditions_are_applied_without_other_conditions_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder + ->enableMeilisearchNewQueryBuilder() + ->whereIn('qux', [1, 2]) + ->whereIn('quux', [1, 2]) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'qux IN [1, 2] AND quux IN [1, 2]', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -585,7 +679,28 @@ public function test_where_not_in_conditions_are_applied_without_other_condition $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', + 'filter' => 'qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_not_in_conditions_are_applied_without_other_conditions_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->whereIn('qux', [1, 2]) + ->whereIn('quux', [1, 2]) + ->whereNotIn('eaea', [3]) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'qux IN [1, 2] AND quux IN [1, 2] AND eaea NOT IN [3]', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -602,7 +717,128 @@ public function test_empty_where_in_conditions_are_applied_correctly() $client = m::mock(Client::class); $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ - 'filter' => 'foo="bar" AND bar="baz" AND qux IN []', + 'filter' => 'foo="bar" AND bar="baz" AND qux IN []', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_empty_where_in_conditions_are_applied_correctly_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->where('foo', 'bar') + ->where('bar', 'baz') + ->whereIn('qux', []) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo="bar" AND bar="baz" AND qux IN []', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_new_query_builder_custom_boolean_clauses() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->where([ + ['foo', 'bar'], + ['bar', 'baz'], + ]) + ->orWhere(function ($query) { + $query->where('name', 'Foals') + ->orWhere('name', 'Radiohead') + ; + }) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => '(foo="bar" AND bar="baz") OR (name="Foals" OR name="Radiohead")', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_where_exists_and_null_not_null_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->whereExists('foo') + ->whereNotNull('bar') + ->whereIsNotEmpty('eaea') + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo EXISTS AND bar IS NOT NULL AND eaea IS NOT EMPTY', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_between_equivalent_operator_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->whereBetween('foo', [1, 100]) + ->where('power', '>=', 9000) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => 'foo 1 TO 100 AND power>=9000', + 'hitsPerPage' => $builder->limit, + ]))->andReturn([]); + + $engine = new MeilisearchEngine($client); + $engine->search($builder); + } + + public function test_combining_multiple_scopes_using_new_query_builder() + { + $builder = new Builder(new SearchableModel(), ''); + + $builder->enableMeilisearchNewQueryBuilder() + ->where([ + ['foo', '!=', 'bar'], + ['bar', '<', 3], + ['power', '>', 900] + ]) + ->orWhere([ + ['name', 'John'], + ['lastname', '=', 'Doe'], + ]) + ->orWhere(function ($query) { + $query->where('name', 'Edgar') + ->orWhere('lastname', 'Pimienta') + ; + }) + ; + + $client = m::mock(Client::class); + $client->shouldReceive('index')->once()->andReturn($index = m::mock(Indexes::class)); + $index->shouldReceive('rawSearch')->once()->with($builder->query, array_filter([ + 'filter' => '(foo!="bar" AND bar<3 AND power>900) OR (name="John" AND lastname="Doe") OR (name="Edgar" OR lastname="Pimienta")', 'hitsPerPage' => $builder->limit, ]))->andReturn([]); @@ -613,8 +849,8 @@ public function test_empty_where_in_conditions_are_applied_correctly() public function test_engine_returns_hits_entry_from_search_response() { $this->assertTrue(3 === (new MeilisearchEngine(m::mock(Client::class)))->getTotalCount([ - 'totalHits' => 3, - ])); + 'totalHits' => 3, + ])); } public function test_delete_all_indexes_works_with_pagination()