Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.84% |
137 / 146 |
|
52.94% |
9 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Engine | |
93.84% |
137 / 146 |
|
52.94% |
9 / 17 |
52.63 | |
0.00% |
0 / 1 |
| newSearchQuery | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| processString | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
13.15 | |||
| loadStopWords | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
| update | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
| delete | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| search | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| paginate | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| performSearch | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
7 | |||
| applyQueryScopes | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
12 | |||
| mapIds | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getRecord | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| map | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| lazyMap | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getTotalCount | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| flush | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| createIndex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| deleteIndex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Baril\Sqlout; |
| 4 | |
| 5 | use Closure; |
| 6 | use Exception; |
| 7 | use Illuminate\Contracts\Support\Arrayable; |
| 8 | use Illuminate\Support\Collection; |
| 9 | use Laravel\Scout\Engines\Engine as ScoutEngine; |
| 10 | use Laravel\Scout\Builder; |
| 11 | |
| 12 | class Engine extends ScoutEngine |
| 13 | { |
| 14 | protected function newSearchQuery($model) |
| 15 | { |
| 16 | $query = SearchIndex::query(); |
| 17 | $searchModel = $query->getModel() |
| 18 | ->setConnection($model->getConnectionName()) |
| 19 | ->setTable($model->searchableAs()); |
| 20 | return $query->setModel($searchModel); |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * Apply the filters to the indexed content or search terms, tokenize it |
| 25 | * and stem the words. |
| 26 | * |
| 27 | * @param string $content |
| 28 | * @return string |
| 29 | */ |
| 30 | protected function processString($content) |
| 31 | { |
| 32 | // Apply custom filters: |
| 33 | foreach (config('scout.sqlout.filters', []) as $filter) { |
| 34 | if (is_callable($filter)) { |
| 35 | $content = call_user_func($filter, $content); |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // Tokenize: |
| 40 | $words = preg_split(config('scout.sqlout.token_delimiter', '/[\s]+/'), $content); |
| 41 | |
| 42 | // Remove stopwords & short words: |
| 43 | $minLength = config('scout.sqlout.minimum_length', 0); |
| 44 | $stopwords = $this->loadStopWords(); |
| 45 | $words = (new Collection($words))->reject(function ($word) use ($minLength, $stopwords) { |
| 46 | return mb_strlen($word) < $minLength || in_array($word, $stopwords); |
| 47 | })->all(); |
| 48 | |
| 49 | // Stem: |
| 50 | $stemmer = config('scout.sqlout.stemmer'); |
| 51 | if ($stemmer) { |
| 52 | if (is_string($stemmer) && class_exists($stemmer) && method_exists($stemmer, 'stem')) { |
| 53 | $stemmer = [new $stemmer(), 'stem']; |
| 54 | } |
| 55 | if (is_object($stemmer) && method_exists($stemmer, 'stem')) { |
| 56 | foreach ($words as $k => $word) { |
| 57 | $words[$k] = $stemmer->stem($word); |
| 58 | } |
| 59 | } elseif (is_callable($stemmer)) { |
| 60 | foreach ($words as $k => $word) { |
| 61 | $words[$k] = call_user_func($stemmer, $word); |
| 62 | } |
| 63 | } else { |
| 64 | throw new Exception('Invalid stemmer!'); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // Return result: |
| 69 | return implode(' ', $words); |
| 70 | } |
| 71 | |
| 72 | protected function loadStopWords() |
| 73 | { |
| 74 | $stopwords = config('scout.sqlout.stopwords', []); |
| 75 | if (is_iterable($stopwords)) { |
| 76 | return $stopwords; |
| 77 | } |
| 78 | |
| 79 | $file = $stopwords; |
| 80 | if (!file_exists($file)) { |
| 81 | throw new Exception("Can't import stop words from $file"); |
| 82 | } |
| 83 | |
| 84 | $stream = fopen($file, 'r'); |
| 85 | $firstline = trim(fgets($stream)); |
| 86 | |
| 87 | if (trim($firstline) == '<?php') { |
| 88 | return require $file; |
| 89 | } |
| 90 | |
| 91 | $stopwords = [$firstline]; |
| 92 | while (false !== ($word = fgets($stream))) { |
| 93 | $stopwords[] = trim($word); |
| 94 | } |
| 95 | return $stopwords; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Update the given model in the index. |
| 100 | * |
| 101 | * @param \Illuminate\Database\Eloquent\Collection $models |
| 102 | * @return void |
| 103 | */ |
| 104 | public function update($models) |
| 105 | { |
| 106 | $models->each(function ($model) { |
| 107 | $type = $model->getMorphClass(); |
| 108 | $id = $model->getKey(); |
| 109 | $this->newSearchQuery($model)->where('record_type', $type)->where('record_id', $id)->delete(); |
| 110 | |
| 111 | $data = $model->toSearchableArray(); |
| 112 | foreach (array_filter($data) as $field => $content) { |
| 113 | $this->newSearchQuery($model)->create([ |
| 114 | 'record_type' => $type, |
| 115 | 'record_id' => $id, |
| 116 | 'field' => $field, |
| 117 | 'weight' => $model->getSearchWeight($field), |
| 118 | 'content' => $this->processString($content), |
| 119 | ]); |
| 120 | } |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Remove the given model from the index. |
| 126 | * |
| 127 | * @param \Illuminate\Database\Eloquent\Collection $models |
| 128 | * @return void |
| 129 | */ |
| 130 | public function delete($models) |
| 131 | { |
| 132 | if (!$models->count()) { |
| 133 | return; |
| 134 | } |
| 135 | $this->newSearchQuery($models->first()) |
| 136 | ->where('record_type', $models->first()->getMorphClass()) |
| 137 | ->whereIn('record_id', $models->modelKeys()) |
| 138 | ->delete(); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Perform the given search on the engine. |
| 143 | * |
| 144 | * @param \Laravel\Scout\Builder $builder |
| 145 | * @return mixed |
| 146 | */ |
| 147 | public function search(Builder $builder) |
| 148 | { |
| 149 | return $this->performSearch($builder, array_filter([ |
| 150 | 'hitsPerPage' => $builder->limit, |
| 151 | ])); |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Perform the given search on the engine. |
| 156 | * |
| 157 | * @param \Laravel\Scout\Builder $builder |
| 158 | * @param int $perPage |
| 159 | * @param int $page |
| 160 | * @return mixed |
| 161 | */ |
| 162 | public function paginate(Builder $builder, $perPage, $page) |
| 163 | { |
| 164 | return $this->performSearch($builder, [ |
| 165 | 'hitsPerPage' => $perPage, |
| 166 | 'page' => $page - 1, |
| 167 | ]); |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Perform the given search on the engine. |
| 172 | * |
| 173 | * @param \Laravel\Scout\Builder $builder |
| 174 | * @param array $options |
| 175 | * @return mixed |
| 176 | */ |
| 177 | protected function performSearch(Builder $builder, array $options = []) |
| 178 | { |
| 179 | $mode = $builder->mode ?? config('scout.sqlout.default_mode'); |
| 180 | $terms = $this->processString($builder->query); |
| 181 | |
| 182 | // Creating search query: |
| 183 | $query = $this->newSearchQuery($builder->model) |
| 184 | ->with('record') |
| 185 | ->where('record_type', $builder->model->getMorphClass()) |
| 186 | ->whereRaw("match(content) against (? $mode)", [$terms]) |
| 187 | ->groupBy('record_type') |
| 188 | ->groupBy('record_id') |
| 189 | ->selectRaw("sum(weight * (match(content) against (? $mode))) as _score", [$terms]) |
| 190 | ->addSelect(['record_type', 'record_id']); |
| 191 | |
| 192 | // Order clauses: |
| 193 | if (!$builder->orders) { |
| 194 | $builder->orderByScore(); |
| 195 | } |
| 196 | if ($builder->orders) { |
| 197 | foreach ($builder->orders as $i => $order) { |
| 198 | if ($order['column'] == '_score') { |
| 199 | $query->orderBy($order['column'], $order['direction']); |
| 200 | continue; |
| 201 | } |
| 202 | $alias = 'sqlout_reserved_order_' . $i; |
| 203 | $subQuery = $builder->model->newQuery() |
| 204 | ->select([ |
| 205 | $builder->model->getKeyName() . " as {$alias}_id", |
| 206 | $order['column'] . " as {$alias}_order", |
| 207 | ]); |
| 208 | $query->joinSub($subQuery, $alias, function ($join) use ($alias) { |
| 209 | $join->on('record_id', '=', $alias . '_id'); |
| 210 | }); |
| 211 | $query->orderBy($alias . '_order', $order['direction']); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | $query->whereHasMorph('record', get_class($builder->model), function ($query) use ($builder) { |
| 216 | $this->applyQueryScopes($builder, $query); |
| 217 | }); |
| 218 | |
| 219 | // Applying limit/offset: |
| 220 | if ($options['hitsPerPage'] ?? null) { |
| 221 | $query->limit($options['hitsPerPage']); |
| 222 | if ($options['page'] ?? null) { |
| 223 | $offset = $options['page'] * $options['hitsPerPage']; |
| 224 | $query->offset($offset); |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // Performing a first query to determine the total number of hits: |
| 229 | $countQuery = $query->getQuery() |
| 230 | ->cloneWithout(['groups', 'orders', 'offset', 'limit']) |
| 231 | ->cloneWithoutBindings(['order']); |
| 232 | $results = ['nbHits' => $countQuery->count($countQuery->getConnection()->raw('distinct record_id'))]; |
| 233 | |
| 234 | // Preparing the actual query: |
| 235 | $results['query'] = $query->with('record'); |
| 236 | |
| 237 | return $results; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * @param \Baril\Sqlout\Builder $builder |
| 242 | * @param \Illuminate\Database\Eloquent\Query\Builder $query |
| 243 | * @return void |
| 244 | */ |
| 245 | protected function applyQueryScopes($builder, $query) |
| 246 | { |
| 247 | $softDeleted = $builder->wheres['__soft_deleted'] ?? null; |
| 248 | if (!method_exists($builder->model, 'bootSoftDeletes')) { |
| 249 | // Model is not soft deletable |
| 250 | } elseif (is_null($softDeleted)) { |
| 251 | $query->withTrashed(); |
| 252 | } elseif ($softDeleted) { |
| 253 | $query->onlyTrashed(); |
| 254 | } else { |
| 255 | $query->withoutTrashed(); |
| 256 | } |
| 257 | unset($builder->wheres['__soft_deleted']); |
| 258 | foreach ($builder->wheres as $field => $value) { |
| 259 | if (is_array($value) || $value instanceof Arrayable) { |
| 260 | $query->whereIn($field, $value); |
| 261 | } else { |
| 262 | $query->where($field, $value); |
| 263 | } |
| 264 | } |
| 265 | foreach ($builder->whereIns ?? [] as $field => $values) { |
| 266 | $query->whereIn($field, $values); |
| 267 | } |
| 268 | foreach ($builder->whereNotIns ?? [] as $field => $values) { |
| 269 | $query->whereNotIn($field, $values); |
| 270 | } |
| 271 | foreach ($builder->scopes as $scope) { |
| 272 | if ($scope instanceof Closure) { |
| 273 | $scope($query); |
| 274 | } else { |
| 275 | list($method, $parameters) = $scope; |
| 276 | $query->$method(...$parameters); |
| 277 | } |
| 278 | } |
| 279 | if ($builder->queryCallback) { |
| 280 | $callback = $builder->queryCallback; |
| 281 | $callback($query); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Pluck and return the primary keys of the given results. |
| 287 | * |
| 288 | * @param mixed $results |
| 289 | * @return \Illuminate\Support\Collection |
| 290 | */ |
| 291 | public function mapIds($results) |
| 292 | { |
| 293 | return $results['query']->pluck('record_id')->values(); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Extract the Model from the search hit. |
| 298 | * |
| 299 | * @param SearchIndex $hit |
| 300 | * @return \Illuminate\Database\Eloquent\Model |
| 301 | */ |
| 302 | protected function getRecord($hit) |
| 303 | { |
| 304 | $hit->record->_score = $hit->_score; |
| 305 | return $hit->record; |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Map the given results to instances of the given model. |
| 310 | * |
| 311 | * @param \Laravel\Scout\Builder $builder |
| 312 | * @param mixed $results |
| 313 | * @param \Illuminate\Database\Eloquent\Model $model |
| 314 | * @return \Illuminate\Database\Eloquent\Collection |
| 315 | */ |
| 316 | public function map(Builder $builder, $results, $model) |
| 317 | { |
| 318 | $models = $results['query']->get()->map(function ($hit) { |
| 319 | return $this->getRecord($hit); |
| 320 | })->all(); |
| 321 | return $model->newCollection($models); |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Map the given results to instances of the given model via a lazy collection. |
| 326 | * |
| 327 | * @param \Laravel\Scout\Builder $builder |
| 328 | * @param mixed $results |
| 329 | * @param \Illuminate\Database\Eloquent\Model $model |
| 330 | * @return \Illuminate\Support\LazyCollection |
| 331 | */ |
| 332 | public function lazyMap(Builder $builder, $results, $model) |
| 333 | { |
| 334 | return $results['query']->lazy()->map(function ($hit) { |
| 335 | return $this->getRecord($hit); |
| 336 | }); |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Get the total count from a raw result returned by the engine. |
| 341 | * |
| 342 | * @param mixed $results |
| 343 | * @return int |
| 344 | */ |
| 345 | public function getTotalCount($results) |
| 346 | { |
| 347 | return $results['nbHits']; |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Flush all of the model's records from the engine. |
| 352 | * |
| 353 | * @param \Illuminate\Database\Eloquent\Model $model |
| 354 | * @return void |
| 355 | */ |
| 356 | public function flush($model) |
| 357 | { |
| 358 | $this->newSearchQuery($model)->where('record_type', $model->getMorphClass())->delete(); |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Create a search index. |
| 363 | * |
| 364 | * @param string $name |
| 365 | * @param array $options |
| 366 | * @return mixed |
| 367 | */ |
| 368 | public function createIndex($name, array $options = []) |
| 369 | { |
| 370 | // |
| 371 | } |
| 372 | |
| 373 | /** |
| 374 | * Delete a search index. |
| 375 | * |
| 376 | * @param string $name |
| 377 | * @return mixed |
| 378 | */ |
| 379 | public function deleteIndex($name) |
| 380 | { |
| 381 | // |
| 382 | } |
| 383 | } |