Passed
Push — master ( c2d8e3...289151 )
by Jeroen
06:06
created

MetadataTable   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 422
Duplicated Lines 0 %

Test Coverage

Coverage 84.38%

Importance

Changes 0
Metric Value
dl 0
loc 422
ccs 108
cts 128
cp 0.8438
rs 8.439
c 0
b 0
f 0
wmc 47

12 Methods

Rating   Name   Duplication   Size   Complexity  
A registerTagName() 0 6 2
A unregisterTagName() 0 9 2
A __construct() 0 8 1
A getTagNames() 0 2 1
A getAll() 0 6 1
B getIdsByName() 0 25 5
B update() 0 31 6
B getTags() 0 35 2
B delete() 0 19 5
C create() 0 74 15
A get() 0 14 2
B deleteAll() 0 28 5

How to fix   Complexity   

Complex Class

Complex classes like MetadataTable often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MetadataTable, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Elgg\Database;
4
5
use Elgg\Cache\MetadataCache;
6
use Elgg\Database;
7
use Elgg\Database\Clauses\MetadataWhereClause;
8
use Elgg\EventsService as Events;
9
use Elgg\TimeUsing;
10
use ElggMetadata;
11
use ElggEntity;
12
13
/**
14
 * This class interfaces with the database to perform CRUD operations on metadata
15
 *
16
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
17
 *
18
 * @access private
19
 */
20
class MetadataTable {
21
22
	use TimeUsing;
23
24
	/**
25
	 * @var MetadataCache
26
	 */
27
	protected $metadata_cache;
28
29
	/**
30
	 * @var Database
31
	 */
32
	protected $db;
33
34
	/**
35
	 * @var Events
36
	 */
37
	protected $events;
38
39
	/**
40
	 * @var string[]
41
	 */
42
	protected $tag_names = [];
43
44
	const MYSQL_TEXT_BYTE_LIMIT = 65535;
45
46
	/**
47
	 * Constructor
48
	 *
49
	 * @param MetadataCache $metadata_cache A cache for this table
50
	 * @param Database      $db             The Elgg database
51
	 * @param Events        $events         The events registry
52
	 */
53 4417
	public function __construct(
54
		MetadataCache $metadata_cache,
55
		Database $db,
56
		Events $events
57
	) {
58 4417
		$this->metadata_cache = $metadata_cache;
59 4417
		$this->db = $db;
60 4417
		$this->events = $events;
61 4417
	}
62
63
	/**
64
	 * Registers a metadata name as containing tags for an entity.
65
	 *
66
	 * @param string $name Tag name
67
	 *
68
	 * @return bool
69
	 */
70 32
	public function registerTagName($name) {
71 32
		if (!in_array($name, $this->tag_names)) {
72 19
			$this->tag_names[] = $name;
73
		}
74
75 32
		return true;
76
	}
77
78
	/**
79
	 * Unregisters a metadata tag name
80
	 *
81
	 * @param string $name Tag name
82
	 *
83
	 * @return bool
84
	 */
85 1
	public function unregisterTagName($name) {
86 1
		$index = array_search($name, $this->tag_names);
87 1
		if ($index >= 0) {
88 1
			unset($this->tag_names[$index]);
89
90 1
			return true;
91
		}
92
93
		return false;
94
	}
95
96
	/**
97
	 * Returns an array of valid metadata names for tags.
98
	 *
99
	 * @return string[]
100
	 */
101 3
	public function getTagNames() {
102 3
		return $this->tag_names;
103
	}
104
105
	/**
106
	 * Get popular tags and their frequencies
107
	 *
108
	 * Accepts all options supported by {@link elgg_get_entities()}
109
	 *
110
	 * Returns an array of objects that include "tag" and "total" properties
111
	 *
112
	 * @todo   When updating this function for 3.0, I have noticed that docs explicitly mention
113
	 *       that tags must be registered, but it was not really checked anywhere in code
114 9
	 *       So, either update the docs or decide what the behavior should be
115 9
	 *
116 9
	 * @param array $options Options
117
	 *
118 9
	 * @return    object[]|false
119 9
	 * @throws \DatabaseException
120 9
	 * @option int      $threshold Minimum number of tag occurrences
121
	 * @option string[] $tag_names Names of registered tag names to include in search
122 9
	 *
123 9
	 */
124 9
	public function getTags(array $options = []) {
125
		$defaults = [
126
			'threshold' => 1,
127
			'tag_names' => [],
128
		];
129
130
		$options = array_merge($defaults, $options);
131
132
		$singulars = ['tag_name'];
133
		$options = LegacyQueryOptionsAdapter::normalizePluralOptions($options, $singulars);
134
135
		$tag_names = elgg_extract('tag_names', $options);
136
		if (empty($tag_names)) {
137 199
			$tag_names = elgg_get_registered_tag_metadata_names();
138 199
		}
139
140
		$threshold = elgg_extract('threshold', $options, 1, false);
141
142 199
		unset($options['tag_names']);
143
		unset($options['threshold']);
144
145
		$qb = \Elgg\Database\Select::fromTable('metadata', 'md');
146 199
		$qb->select('md.value AS tag')
147 199
			->addSelect('COUNT(md.id) AS total')
148
			->where($qb->compare('md.name', 'IN', $tag_names, ELGG_VALUE_STRING))
149 199
			->andWhere($qb->compare('md.value', '!=', '', ELGG_VALUE_STRING))
150
			->groupBy('md.value')
151 199
			->having($qb->compare('total', '>=', $threshold, ELGG_VALUE_INTEGER))
152 199
			->orderBy('total', 'desc');
153
154
		$options = new \Elgg\Database\QueryOptions($options);
155 199
		$alias = $qb->joinEntitiesTable('md', 'entity_guid', 'inner', 'e');
156
		$qb->addClause(\Elgg\Database\Clauses\EntityWhereClause::factory($options), $alias);
157
158
		return _elgg_services()->db->getData($qb);
159
	}
160
161
	/**
162
	 * Get a specific metadata object by its id
163
	 *
164
	 * @see MetadataTable::getAll()
165
	 *
166
	 * @param int $id The id of the metadata object being retrieved.
167
	 *
168
	 * @return ElggMetadata|false  false if not found
169 1028
	 * @throws \DatabaseException
170 1028
	 */
171
	public function get($id) {
172
		$qb = Select::fromTable('metadata');
173
		$qb->select('*');
174
175 1028
		$where = new MetadataWhereClause();
176
		$where->ids = $id;
1 ignored issue
show
Documentation Bug introduced by
It seems like $id of type integer is incompatible with the declared type integer[] of property $ids.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
177
		$qb->addClause($where);
178
179
		$row = $this->db->getDataRow($qb);
180 1028
		if ($row) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $row of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
181
			return new ElggMetadata($row);
182
		}
183
184
		return false;
185
	}
186 1028
187
	/**
188
	 * Deletes metadata using its ID
189
	 *
190 1028
	 * @param ElggMetadata $metadata Metadata
191 1027
	 *
192
	 * @return bool
193 1027
	 * @throws \DatabaseException
194
	 */
195
	public function delete(ElggMetadata $metadata) {
196
		if (!$metadata->id || !$metadata->canEdit()) {
197 1027
			return false;
198 198
		}
199
200 198
		if (!elgg_trigger_event('delete', 'metadata', $metadata)) {
201 198
			return false;
202
		}
203
204
		$qb = Delete::fromTable('metadata');
205
		$qb->where($qb->compare('id', '=', $metadata->id, ELGG_VALUE_INTEGER));
206 1028
207
		$deleted = $this->db->deleteData($qb);
208
209
		if ($deleted) {
210 1028
			$this->metadata_cache->clear($metadata->entity_guid);
211
		}
212 1028
213 1028
		return $deleted !== false;
214 1028
	}
215 1028
216 1028
	/**
217 1028
	 * Create a new metadata object, or update an existing one (if multiple is allowed)
218 1028
	 *
219
	 * Metadata can be an array by setting allow_multiple to true, but it is an
220
	 * indexed array with no control over the indexing
221 1028
	 *
222
	 * @param ElggMetadata $metadata       Metadata
223 1028
	 * @param bool         $allow_multiple Allow multiple values for one key. Default is false
224
	 *
225
	 * @return int|false id of metadata or false if failure
226
	 * @throws \DatabaseException
227 1028
	 */
228 1028
	public function create(ElggMetadata $metadata, $allow_multiple = false) {
229
		if (!isset($metadata->value) || !isset($metadata->entity_guid)) {
230 1028
			elgg_log("Metadata must have a value and entity guid", 'ERROR');
231 1028
			return false;
232
		}
233 1028
234
		if (!is_scalar($metadata->value)) {
235 1028
			elgg_log("To set multiple metadata values use ElggEntity::setMetadata", 'ERROR');
236
			return false;
237
		}
238
239
		if ($metadata->id) {
240
			if ($this->update($metadata)) {
241
				return $metadata->id;
242
			}
243
		}
244
245
		if (strlen($metadata->value) > self::MYSQL_TEXT_BYTE_LIMIT) {
246
			elgg_log("Metadata '$metadata->name' is above the MySQL TEXT size limit and may be truncated.", 'WARNING');
247
		}
248
249
		if (!$allow_multiple) {
250 198
			$id = $this->getIdsByName($metadata->entity_guid, $metadata->name);
251 198
252 1
			if (is_array($id)) {
253
				throw new \LogicException("
254
					Multiple '{$metadata->name}' metadata values exist for entity [guid: {$metadata->entity_guid}]. 
255 198
					Use ElggEntity::setMetadata()
256
				");
257
			}
258
259 198
			if ($id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $id of type null|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
260
				$metadata->id = $id;
261
262
				if ($this->update($metadata)) {
263 198
					return $metadata->id;
264 198
				}
265 198
			}
266 198
		}
267 198
268
		if (!$this->events->triggerBefore('create', 'metadata', $metadata)) {
0 ignored issues
show
Bug introduced by
$metadata of type ElggMetadata is incompatible with the type string expected by parameter $object of Elgg\EventsService::triggerBefore(). ( Ignorable by Annotation )

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

268
		if (!$this->events->triggerBefore('create', 'metadata', /** @scrutinizer ignore-type */ $metadata)) {
Loading history...
269 198
			return false;
270
		}
271 198
272
		$time_created = $this->getCurrentTime()->getTimestamp();
273
274
		$qb = Insert::intoTable('metadata');
275 198
		$qb->values([
276
			'name' => $qb->param($metadata->name, ELGG_VALUE_STRING),
277 198
			'entity_guid' => $qb->param($metadata->entity_guid, ELGG_VALUE_INTEGER),
278 198
			'value' => $qb->param($metadata->value, $metadata->value_type === 'integer' ? ELGG_VALUE_INTEGER : ELGG_VALUE_STRING),
279
			'value_type' => $qb->param($metadata->value_type, ELGG_VALUE_STRING),
280 198
			'time_created' => $qb->param($time_created, ELGG_VALUE_INTEGER),
281
		]);
282
283
		$id = $this->db->insertData($qb);
284
285
		if ($id === false) {
286
			return false;
287
		}
288
289
		$metadata->id = (int) $id;
290
		$metadata->time_created = $time_created;
291
292
		if ($this->events->trigger('create', 'metadata', $metadata)) {
293
			$this->metadata_cache->clear($metadata->entity_guid);
294 590
295
			$this->events->triggerAfter('create', 'metadata', $metadata);
0 ignored issues
show
Bug introduced by
$metadata of type ElggMetadata is incompatible with the type string expected by parameter $object of Elgg\EventsService::triggerAfter(). ( Ignorable by Annotation )

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

295
			$this->events->triggerAfter('create', 'metadata', /** @scrutinizer ignore-type */ $metadata);
Loading history...
296 590
297 590
			return $id;
298
		} else {
299 590
			$this->delete($metadata);
300
301
			return false;
302
		}
303
	}
304
305
	/**
306
	 * Update a specific piece of metadata
307
	 *
308
	 * @param ElggMetadata $metadata Updated metadata
309
	 *
310
	 * @return bool
311
	 * @throws \DatabaseException
312
	 */
313
	public function update(ElggMetadata $metadata) {
314
		if (!$metadata->canEdit()) {
315
			return false;
316 271
		}
317 271
318
		if (!$this->events->triggerBefore('update', 'metadata', $metadata)) {
0 ignored issues
show
Bug introduced by
$metadata of type ElggMetadata is incompatible with the type string expected by parameter $object of Elgg\EventsService::triggerBefore(). ( Ignorable by Annotation )

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

318
		if (!$this->events->triggerBefore('update', 'metadata', /** @scrutinizer ignore-type */ $metadata)) {
Loading history...
319
			return false;
320
		}
321
322
		if (strlen($metadata->value) > self::MYSQL_TEXT_BYTE_LIMIT) {
323 271
			elgg_log("Metadata '$metadata->name' is above the MySQL TEXT size limit and may be truncated.", 'WARNING');
324
		}
325 271
326 271
		$qb = Update::table('metadata');
327 271
		$qb->set('name', $qb->param($metadata->name, ELGG_VALUE_STRING))
328
			->set('value', $qb->param($metadata->value, $metadata->value_type === 'integer' ? ELGG_VALUE_INTEGER : ELGG_VALUE_STRING))
329 271
			->set('value_type', $qb->param($metadata->value_type, ELGG_VALUE_STRING))
330 271
			->where($qb->compare('id', '=', $metadata->id, ELGG_VALUE_INTEGER));
331
332 271
		$result = $this->db->updateData($qb);
333 91
334
		if ($result === false) {
335
			return false;
336 199
		}
337 199
338 199
		$this->metadata_cache->clear($metadata->entity_guid);
339 199
340
		$this->events->trigger('update', 'metadata', $metadata);
341
		$this->events->triggerAfter('update', 'metadata', $metadata);
0 ignored issues
show
Bug introduced by
$metadata of type ElggMetadata is incompatible with the type string expected by parameter $object of Elgg\EventsService::triggerAfter(). ( Ignorable by Annotation )

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

341
		$this->events->triggerAfter('update', 'metadata', /** @scrutinizer ignore-type */ $metadata);
Loading history...
342
343 199
		return true;
344
	}
345
346
	/**
347
	 * Returns metadata
348
	 *
349
	 * Accepts all {@link elgg_get_entities()} options for entity restraints.
350
	 *
351
	 * @see     elgg_get_entities()
352
	 *
353
	 * @param array $options Options
354 1027
	 *
355 1027
	 * @return ElggMetadata[]|mixed
356 666
	 */
357
	public function getAll(array $options = []) {
358 1027
359 1027
		$options['metastring_type'] = 'metadata';
360 1027
		$options = LegacyQueryOptionsAdapter::normalizeMetastringOptions($options);
361 1027
362
		return Metadata::find($options);
363 1027
	}
364 1
365 1027
	/**
366
	 * Deletes metadata based on $options.
367
	 *
368
	 * @warning Unlike elgg_get_metadata() this will not accept an empty options array!
369
	 *          This requires at least one constraint:
370
	 *          metadata_name(s), metadata_value(s), or guid(s) must be set.
371
	 *
372
	 * @see     elgg_get_metadata()
373
	 * @see     elgg_get_entities()
374
	 *
375
	 * @param array $options Options
376
	 *
377
	 * @return bool|null true on success, false on failure, null if no metadata to delete.
378
	 */
379
	public function deleteAll(array $options) {
380
		if (!_elgg_is_valid_options_for_batch_operation($options, 'metadata')) {
381
			return false;
382
		}
383
384
		// This moved last in case an object's constructor sets metadata. Currently the batch
385
		// delete process has to create the entity to delete its metadata. See #5214
386
		$this->metadata_cache->invalidateByOptions($options);
387
388
		$options['batch'] = true;
389
		$options['batch_size'] = 50;
390
		$options['batch_inc_offset'] = false;
391
392
		$metadata = Metadata::find($options);
393
		$count = $metadata->count();
394
395
		if (!$count) {
396
			return;
397
		}
398
399
		$success = 0;
400
		foreach ($metadata as $md) {
401
			if ($md->delete()) {
402
				$success++;
403
			}
404
		}
405
406
		return $success == $count;
407
	}
408
409
	/**
410
	 * Returns ID(s) of metadata with a particular name attached to an entity
411
	 *
412
	 * @param int    $entity_guid Entity guid
413
	 * @param string $name        Metadata name
414
	 *
415
	 * @return int[]|int|null
416
	 */
417
	public function getIdsByName($entity_guid, $name) {
418
		if ($this->metadata_cache->isLoaded($entity_guid)) {
419
			$ids = $this->metadata_cache->getSingleId($entity_guid, $name);
420
		} else {
421
			$qb = Select::fromTable('metadata');
422
			$qb->select('id')
423
				->where($qb->compare('entity_guid', '=', $entity_guid, ELGG_VALUE_INTEGER))
424
				->andWhere($qb->compare('name', '=', $name, ELGG_VALUE_STRING));
425
426
			$callback = function (\stdClass $row) {
427
				return (int) $row->id;
428
			};
429
430
			$ids = $this->db->getData($qb, $callback);
431
		}
432
433
		if (empty($ids)) {
434
			return null;
435
		}
436
437
		if (is_array($ids) && count($ids) === 1) {
438
			return array_shift($ids);
439
		}
440
441
		return $ids;
442
	}
443
}
444