+ return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
+ }
+
+ /**
+ * For the given search query, apply the queries for handling the regular search terms.
+ */
+ protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, Entity $entity): void
+ {
+ $terms = $options->searches;
+ if (count($terms) === 0) {
+ return;
+ }
+
+ $scoredTerms = $this->getTermAdjustments($options);
+ $scoreSelect = $this->selectForScoredTerms($scoredTerms);
+
+ $subQuery = DB::table('search_terms')->select([
+ 'entity_id',
+ 'entity_type',
+ DB::raw($scoreSelect['statement']),
+ ]);
+
+ $subQuery->addBinding($scoreSelect['bindings'], 'select');
+
+ $subQuery->where('entity_type', '=', $entity->getMorphClass());
+ $subQuery->where(function (Builder $query) use ($terms) {
+ foreach ($terms as $inputTerm) {
+ $query->orWhere('term', 'like', $inputTerm . '%');
+ }
+ });
+ $subQuery->groupBy('entity_type', 'entity_id');
+
+ $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
+ $entityQuery->addSelect('s.score');
+ $entityQuery->orderBy('score', 'desc');
+ }
+
+ /**
+ * Create a select statement, with prepared bindings, for the given
+ * set of scored search terms.
+ *
+ * @param array<string, float> $scoredTerms
+ *
+ * @return array{statement: string, bindings: string[]}
+ */
+ protected function selectForScoredTerms(array $scoredTerms): array
+ {
+ // Within this we walk backwards to create the chain of 'if' statements
+ // so that each previous statement is used in the 'else' condition of
+ // the next (earlier) to be built. We start at '0' to have no score
+ // on no match (Should never actually get to this case).
+ $ifChain = '0';
+ $bindings = [];
+ foreach ($scoredTerms as $term => $score) {
+ $ifChain = 'IF(term like ?, score * ' . (float) $score . ', ' . $ifChain . ')';
+ $bindings[] = $term . '%';
+ }
+
+ return [
+ 'statement' => 'SUM(' . $ifChain . ') as score',
+ 'bindings' => array_reverse($bindings),
+ ];
+ }
+
+ /**
+ * For the terms in the given search options, query their popularity across all
+ * search terms then provide that back as score adjustment multiplier applicable
+ * for their rarity. Returns an array of float multipliers, keyed by term.
+ *
+ * @return array<string, float>
+ */
+ protected function getTermAdjustments(SearchOptions $options): array
+ {
+ if (isset($this->termAdjustmentCache[$options])) {
+ return $this->termAdjustmentCache[$options];
+ }
+
+ $termQuery = SearchTerm::query()->toBase();
+ $whenStatements = [];
+ $whenBindings = [];
+
+ foreach ($options->searches as $term) {
+ $whenStatements[] = 'WHEN term LIKE ? THEN ?';
+ $whenBindings[] = $term . '%';
+ $whenBindings[] = $term;
+
+ $termQuery->orWhere('term', 'like', $term . '%');
+ }
+
+ $case = 'CASE ' . implode(' ', $whenStatements) . ' END';
+ $termQuery->selectRaw($case . ' as term', $whenBindings);
+ $termQuery->selectRaw('COUNT(*) as count');
+ $termQuery->groupByRaw($case, $whenBindings);
+
+ $termCounts = $termQuery->pluck('count', 'term')->toArray();
+ $adjusted = $this->rawTermCountsToAdjustments($termCounts);
+
+ $this->termAdjustmentCache[$options] = $adjusted;
+
+ return $this->termAdjustmentCache[$options];
+ }
+
+ /**
+ * Convert counts of terms into a relative-count normalised multiplier.
+ *
+ * @param array<string, int> $termCounts
+ *
+ * @return array<string, int>
+ */
+ protected function rawTermCountsToAdjustments(array $termCounts): array
+ {
+ if (empty($termCounts)) {
+ return [];
+ }
+
+ $multipliers = [];
+ $max = max(array_values($termCounts));
+
+ foreach ($termCounts as $term => $count) {
+ $percent = round($count / $max, 5);
+ $multipliers[$term] = 1.3 - $percent;
+ }
+
+ return $multipliers;