Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

SearchService::normalizeQuery()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 25
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 16
nc 2
nop 1
dl 0
loc 25
ccs 16
cts 16
cp 1
crap 2
rs 8.8571
c 0
b 0
f 0
1
<?php
2
3
namespace Elgg\Search;
4
5
use Doctrine\DBAL\Query\Expression\CompositeExpression;
6
use Elgg\Config;
7
use Elgg\Database;
8
use Elgg\Database\Clauses\AnnotationWhereClause;
9
use Elgg\Database\Clauses\AttributeWhereClause;
10
use Elgg\Database\Clauses\MetadataWhereClause;
11
use Elgg\Database\Clauses\PrivateSettingWhereClause;
12
use Elgg\Database\QueryBuilder;
13
use Elgg\PluginHooksService;
14
use ElggBatch;
15
use ElggEntity;
16
use InvalidParameterException;
17
18
/**
19
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
20
 *
21
 * Use elgg_search() instead.
22
 *
23
 * @todo   Implement type/subtype normalization into types => subtypes pairs and add logic to search for multiple
24
 *         subtypes
25
 *
26
 * @access private
27
 * @since  3.0
28
 */
29
class SearchService {
30
31
	/**
32
	 * @var Config
33
	 */
34
	private $config;
35
36
	/**
37
	 * @var PluginHooksService
38
	 */
39
	private $hooks;
40
41
	/**
42
	 * @var Database
43
	 */
44
	private $db;
45
46
	/**
47
	 * Constructor
48
	 *
49
	 * @param \Elgg\Config             $config Config
50
	 * @param \Elgg\PluginHooksService $hooks  Hook registration service
51
	 * @param Database                 $db     Database
52
	 */
53 35
	public function __construct(Config $config, PluginHooksService $hooks, Database $db) {
54 35
		$this->config = $config;
55 35
		$this->hooks = $hooks;
56 35
		$this->db = $db;
57 35
	}
58
59
	/**
60
	 * Returns search results as an array of entities, as a batch, or a count,
61
	 * depending on parameters given.
62
	 *
63
	 * @param array $options Search parameters
64
	 *                       Accepts all options supported by {@link elgg_get_entities()}
65
	 *
66
	 * @option string $query         Search query
67
	 * @option string $type          Entity type. Required if no search type is set
68
	 * @option string $search_type   Custom search type. Required if no type is set
69
	 * @option array  $fields        An array of fields to search in
70
	 * @option string $sort          An array containing 'property', 'property_type', 'direction' and 'signed'
71
	 * @option bool   $partial_match Allow partial matches, e.g. find 'elgg' when search for 'el'
72
	 * @option bool   $tokenize      Break down search query into tokens,
73
	 *                               e.g. find 'elgg has been released' when searching for 'elgg released'
74
	 *
75
	 * @return ElggBatch|ElggEntity[]|int|false
76
	 * @throws InvalidParameterException
77
	 *
78
	 * @see    elgg_get_entities()
79
	 */
80 197
	public function search(array $options = []) {
81 197
		$options = $this->prepareSearchOptions($options);
82
83 197
		$query_parts = elgg_extract('query_parts', $options);
84 197
		$fields = elgg_extract('fields', $options);
85
86 197
		if (empty($query_parts) || empty(array_filter($fields))) {
87 5
			return false;
88
		}
89
90 192
		$entity_type = elgg_extract('type', $options, 'all', false);
91 192
		$entity_subtype = elgg_extract('subtype', $options);
92 192
		$search_type = elgg_extract('search_type', $options, 'entities');
93
94 192
		if ($entity_type !== 'all' && !in_array($entity_type, Config::getEntityTypes())) {
95
			throw new InvalidParameterException("'$entity_type' is not a valid entity type");
96
		}
97
98 192
		$options = $this->hooks->trigger('search:options', $entity_type, $options, $options);
99 192
		if ($entity_subtype) {
100 12
			$options = $this->hooks->trigger('search:options', "$entity_type:$entity_subtype", $options, $options);
101
		}
102
103 192
		$options = $this->hooks->trigger('search:options', $search_type, $options, $options);
104
105 192
		if ($this->hooks->hasHandler('search:results', $search_type)) {
106 1
			$results = $this->hooks->trigger('search:results', $search_type, $options);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $results is correct as $this->hooks->trigger('s...$search_type, $options) targeting Elgg\PluginHooksService::trigger() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
107 1
			if (isset($results)) {
108
				// allow hooks to conditionally replace the result set
109 1
				return $results;
110
			}
111
		}
112
113 191
		return elgg_get_entities($options);
114
	}
115
116
	/**
117
	 * Normalize options
118
	 *
119
	 * @param array $options Options
120
	 *
121
	 * @return array
122
	 */
123 213
	public function normalizeOptions(array $options = []) {
124 213
		$search_type = elgg_extract('search_type', $options, 'entities', false);
125 213
		$options['search_type'] = $search_type;
126
127 213
		$options = $this->hooks->trigger('search:params', $search_type, $options, $options);
128
129 213
		$options = $this->normalizeQuery($options);
130 213
		$options = $this->normalizeSearchFields($options);
131
132 213
		return $options;
133
	}
134
135
	/**
136
	 * Prepare ege* options
137
	 *
138
	 * @param array $options Entity search params
139
	 *
140
	 * @return array
141
	 */
142 200
	public function prepareSearchOptions(array $options = []) {
143 200
		$options = $this->normalizeOptions($options);
144
145 200
		$fields = elgg_extract('fields', $options);
146 200
		$query_parts = elgg_extract('query_parts', $options);
147 200
		$partial = elgg_extract('partial_match', $options, true);
148
149 191
		$options['wheres']['search'] = function (QueryBuilder $qb, $alias) use ($fields, $query_parts, $partial) {
150 191
			return $this->buildSearchWhereQuery($qb, $alias, $fields, $query_parts, $partial);
151
		};
152
153 200
		$options = $this->prepareSortOptions($options);
154
155 200
		return $options;
156
	}
157
158
	/**
159
	 * Normalize query parts
160
	 *
161
	 * @param array $options Options
162
	 *
163
	 * @return array
164
	 */
165 213
	public function normalizeQuery(array $options = []) {
166
167 213
		$query = elgg_extract('query', $options);
168 213
		$query = filter_var($query, FILTER_SANITIZE_STRING);
169 213
		$query = trim($query);
170
171 213
		$words = preg_split('/\s+/', $query);
172 213
		$words = array_map(function ($e) {
173 213
			return trim($e);
174 213
		}, $words);
0 ignored issues
show
Bug introduced by
It seems like $words can also be of type false; however, parameter $arr1 of array_map() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

174
		}, /** @scrutinizer ignore-type */ $words);
Loading history...
175
176 213
		$query = implode(' ', $words);
177
178 213
		$options['query'] = $query;
179
180 213
		$tokenize = elgg_extract('tokenize', $options, true);
181 213
		if ($tokenize) {
182 118
			$parts = $words;
183
		} else {
184 95
			$parts = [$query];
185
		}
186
187 213
		$options['query_parts'] = array_unique(array_filter($parts));
188
189 213
		return $options;
190
	}
191
192
	/**
193
	 * Normalizes an array of search fields
194
	 *
195
	 * @param array $options Search parameters
196
	 *
197
	 * @return array
198
	 */
199 213
	public function normalizeSearchFields(array $options = []) {
200
201
		$fields = [
202 213
			'attributes' => [],
203
			'metadata' => [],
204
			'annotations' => [],
205
			'private_settings' => [],
206
		];
207
208 213
		$property_types = array_keys($fields);
209
210 213
		$entity_type = elgg_extract('type', $options);
211 213
		$entity_subtype = elgg_extract('subtype', $options);
212 213
		$search_type = elgg_extract('search_type', $options, 'entities');
213
214 213
		if ($entity_type) {
215 21
			$fields = $this->hooks->trigger('search:fields', $entity_type, $options, $fields);
216
		}
217
218 213
		if ($entity_subtype) {
219 16
			$fields = $this->hooks->trigger('search:fields', "$entity_type:$entity_subtype", $options, $fields);
220
		}
221
222 213
		if ($search_type) {
223 213
			$fields = $this->hooks->trigger('search:fields', $search_type, $options, $fields);
224
		}
225
226 213
		foreach ($property_types as $property_type) {
227 213
			if (empty($fields[$property_type])) {
228 213
				$fields[$property_type] = [];
229
			}
230
		}
231
232 213
		if (empty($options['fields'])) {
233 21
			$options['fields'] = $fields;
234
		} else {
235
			// only allow known fields
236 192
			foreach ($fields as $property_type => $property_type_fields) {
237 192
				if (empty($options['fields'][$property_type])) {
238 191
					$options['fields'][$property_type] = [];
239 191
					continue;
240
				}
241
242 192
				$allowed = array_intersect($property_type_fields, (array) $options['fields'][$property_type]);
243 192
				$options['fields'][$property_type] = array_values(array_unique($allowed));
244
			}
245
		}
246
247 213
		return $options;
248
	}
249
250
	/**
251
	 * Normalizes sort options
252
	 *
253
	 * @param array $options Search parameters
254
	 *
255
	 * @return array
256
	 */
257 200
	public function prepareSortOptions(array $options = []) {
258
259 200
		$sort = elgg_extract('sort', $options);
260 200
		if (is_string($sort)) {
261
			$sort = [
262 2
				'property' => $sort,
263 2
				'direction' => elgg_extract('order', $options)
264
			];
265
		}
266
267 200
		if (!isset($sort['property'])) {
268
			$sort = [
269 197
				'property' => 'time_created',
270
				'property_type' => 'attribute',
271
				'direction' => 'desc',
272
			];
273
		}
274
275 200
		$clause = new Database\Clauses\EntitySortByClause();
276 200
		$clause->property = elgg_extract('property', $sort);
277 200
		$clause->property_type = elgg_extract('property_type', $sort);
278 200
		$clause->direction = elgg_extract('direction', $sort, 'asc');
279 200
		$clause->signed = elgg_extract('signed', $sort, false);
280
281 200
		$options['order_by'] = [$clause];
282
283 200
		return $options;
284
	}
285
286
	/**
287
	 * Builds search clause
288
	 *
289
	 * @param QueryBuilder $qb            Query builder
290
	 * @param string       $alias         Entity table alias
291
	 * @param array        $fields        Fields to match against
292
	 * @param array        $query_parts   Search query
293
	 * @param bool         $partial_match Allow partial matches
294
	 *
295
	 * @return CompositeExpression
296
	 * @throws InvalidParameterException
297
	 */
298 191
	public function buildSearchWhereQuery(QueryBuilder $qb, $alias, $fields, $query_parts, $partial_match = true) {
299
300 191
		$attributes = elgg_extract('attributes', $fields, [], false);
301 191
		$metadata = elgg_extract('metadata', $fields, [], false);
302 191
		$annotations = elgg_extract('annotations', $fields, [], false);
303 191
		$private_settings = elgg_extract('private_settings', $fields, [], false);
304
305 191
		$ors = [];
306
307 191
		$populate_where = function ($where, $part) use ($partial_match) {
308 191
			$where->values = $partial_match ? "%{$part}%" : $part;
309 191
			$where->comparison = 'LIKE';
310 191
			$where->value_type = ELGG_VALUE_STRING;
311 191
			$where->case_sensitive = false;
312 191
		};
313
314
		if (!empty($attributes)) {
315
			foreach ($attributes as $attribute) {
316
				$attribute_ands = [];
317
				foreach ($query_parts as $part) {
318
					$where = new AttributeWhereClause();
319
					$where->names = $attribute;
320
					$populate_where($where, $part);
321
					$attribute_ands[] = $where->prepare($qb, $alias);
322
				}
323
				$ors[] = $qb->merge($attribute_ands, 'AND');
324
			}
325
		}
326
327
		if (!empty($metadata)) {
328
			$metadata_ands = [];
329
			$md_alias = $qb->joinMetadataTable($alias, 'guid', $metadata, 'left');
0 ignored issues
show
Bug introduced by
It seems like $metadata can also be of type array; however, parameter $name of Elgg\Database\QueryBuilder::joinMetadataTable() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

329
			$md_alias = $qb->joinMetadataTable($alias, 'guid', /** @scrutinizer ignore-type */ $metadata, 'left');
Loading history...
330
			foreach ($query_parts as $part) {
331
				$where = new MetadataWhereClause();
332
				$populate_where($where, $part);
333
				$metadata_ands[] = $where->prepare($qb, $md_alias);
334
			}
335
			$ors[] = $qb->merge($metadata_ands, 'AND');
336
		}
337
338
		if (!empty($annotations)) {
339
			$annotations_ands = [];
340
			$an_alias = $qb->joinAnnotationTable($alias, 'guid', $annotations, 'left');
0 ignored issues
show
Bug introduced by
It seems like $annotations can also be of type array; however, parameter $name of Elgg\Database\QueryBuilder::joinAnnotationTable() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

340
			$an_alias = $qb->joinAnnotationTable($alias, 'guid', /** @scrutinizer ignore-type */ $annotations, 'left');
Loading history...
341
			foreach ($query_parts as $part) {
342
				$where = new AnnotationWhereClause();
343
				$populate_where($where, $part);
344
				$annotations_ands[] = $where->prepare($qb, $an_alias);
345
			}
346
			$ors[] = $qb->merge($annotations_ands, 'AND');
347
		}
348
349
		if (!empty($private_settings)) {
350
			$private_settings_ands = [];
351
			$ps_alias = $qb->joinPrivateSettingsTable($alias, 'guid', $private_settings, 'left');
0 ignored issues
show
Bug introduced by
It seems like $private_settings can also be of type array; however, parameter $name of Elgg\Database\QueryBuild...nPrivateSettingsTable() does only seem to accept null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

351
			$ps_alias = $qb->joinPrivateSettingsTable($alias, 'guid', /** @scrutinizer ignore-type */ $private_settings, 'left');
Loading history...
352
			foreach ($query_parts as $part) {
353
				$where = new PrivateSettingWhereClause();
354
				$populate_where($where, $part);
355
				$private_settings_ands[] = $where->prepare($qb, $ps_alias);
356
			}
357
			$ors[] = $qb->merge($private_settings_ands, 'AND');
358
		}
359
360
		return $qb->merge($ors, 'OR');
361
	}
362
363
}
364