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: }
384: