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