Completed
Push — master ( caf222...deba87 )
by Jeroen
72:32 queued 44:47
created

engine/classes/Elgg/Database/MetadataTable.php (1 issue)

1
<?php
2
namespace Elgg\Database;
3
4
5
use Elgg\Database;
6
use Elgg\Database\EntityTable;
7
use Elgg\EventsService as Events;
8
use ElggSession as Session;
9
use Elgg\Cache\MetadataCache as Cache;
10
11
/**
12
 * WARNING: API IN FLUX. DO NOT USE DIRECTLY.
13
 *
14
 * @access private
15
 *
16
 * @package    Elgg.Core
17
 * @subpackage Database
18
 * @since      1.10.0
19
 */
20
class MetadataTable {
21
22
	use \Elgg\TimeUsing;
23
24
	/** @var Cache */
25
	protected $cache;
26
27
	/** @var Database */
28
	protected $db;
29
30
	/** @var EntityTable */
31
	protected $entityTable;
32
33
	/** @var Events */
34
	protected $events;
35
36
	/** @var Session */
37
	protected $session;
38
39
	/** @var string */
40
	protected $table;
41
42
	/**
43
	 * @var string[]
44
	 */
45
	protected $tag_names = [];
46
47
	const MYSQL_TEXT_BYTE_LIMIT = 65535;
48
49
	/**
50
	 * Constructor
51
	 *
52
	 * @param Cache       $cache       A cache for this table
53
	 * @param Database    $db          The Elgg database
54
	 * @param EntityTable $entityTable The entities table
55
	 * @param Events      $events      The events registry
56
	 * @param Session     $session     The session
57
	 */
58 3711
	public function __construct(
59
			Cache $cache,
60
			Database $db,
61
			EntityTable $entityTable,
62
			Events $events,
63
			Session $session) {
64 3711
		$this->cache = $cache;
65 3711
		$this->db = $db;
66 3711
		$this->entityTable = $entityTable;
67 3711
		$this->events = $events;
68 3711
		$this->session = $session;
69 3711
		$this->table = $this->db->prefix . "metadata";
70 3711
	}
71
72
	/**
73
	 * Registers a metadata name as containing tags for an entity.
74
	 *
75
	 * @param string $name Tag name
76
	 *
77
	 * @return bool
78
	 */
79 293
	function registerTagName($name) {
80 293
		if (!in_array($name, $this->tag_names)) {
81 293
			$this->tag_names[] = $name;
82
		}
83
84 293
		return true;
85
	}
86
87
	/**
88
	 * Returns an array of valid metadata names for tags.
89
	 *
90
	 * @return string[]
91
	 */
92
	function getTagNames() {
93
		return $this->tag_names;
94
	}
95
96
	/**
97
	 * Get a specific metadata object by its id.
98
	 * If you want multiple metadata objects, use
99
	 * {@link elgg_get_metadata()}.
100
	 *
101
	 * @param int $id The id of the metadata object being retrieved.
102
	 *
103
	 * @return \ElggMetadata|false  false if not found
104
	 */
105 3582
	function get($id) {
106 3582
		return _elgg_get_metastring_based_object_from_id($id, 'metadata');
107
	}
108
109
	/**
110
	 * Deletes metadata using its ID.
111
	 *
112
	 * @param int $id The metadata ID to delete.
113
	 * @return bool
114
	 */
115
	function delete($id) {
116
		$metadata = $this->get($id);
117
118
		return $metadata ? $metadata->delete() : false;
119
	}
120
121
	/**
122
	 * Create a new metadata object, or update an existing one.
123
	 *
124
	 * Metadata can be an array by setting allow_multiple to true, but it is an
125
	 * indexed array with no control over the indexing.
126
	 *
127
	 * @param int    $entity_guid    The entity to attach the metadata to
128
	 * @param string $name           Name of the metadata
129
	 * @param string $value          Value of the metadata
130
	 * @param string $value_type     'text', 'integer', or '' for automatic detection
131
	 * @param bool   $allow_multiple Allow multiple values for one key. Default is false
132
	 *
133
	 * @return int|false id of metadata or false if failure
134
	 */
135 3582
	function create($entity_guid, $name, $value, $value_type = '', $allow_multiple = false) {
136
137 3582
		$entity_guid = (int) $entity_guid;
138 3582
		$value_type = \ElggExtender::detectValueType($value, trim($value_type));
139 3582
		$allow_multiple = (boolean) $allow_multiple;
140
141 3582
		if (!isset($value)) {
142
			return false;
143
		}
144
145 3582
		if (strlen($value) > self::MYSQL_TEXT_BYTE_LIMIT) {
146
			elgg_log("Metadata '$name' is above the MySQL TEXT size limit and may be truncated.", 'WARNING');
147
		}
148
149 3582
		$query = "SELECT * FROM {$this->table}
150
			WHERE entity_guid = :entity_guid and name = :name LIMIT 1";
151
152 3582
		$existing = $this->db->getDataRow($query, null, [
153 3582
			':entity_guid' => $entity_guid,
154 3582
			':name' => $name,
155
		]);
156 3582
		if ($existing && !$allow_multiple) {
157 1
			$id = (int) $existing->id;
158 1
			$result = $this->update($id, $name, $value, $value_type);
159
160 1
			if (!$result) {
161 1
				return false;
162
			}
163
		} else {
164
			// Support boolean types
165 3582
			if (is_bool($value)) {
166 123
				$value = (int) $value;
167
			}
168
169
			// If ok then add it
170 3582
			$query = "INSERT INTO {$this->table}
171
				(entity_guid, name, value, value_type, time_created)
172
				VALUES (:entity_guid, :name, :value, :value_type, :time_created)";
173
174 3582
			$id = $this->db->insertData($query, [
175 3582
				':entity_guid' => $entity_guid,
176 3582
				':name' => $name,
177 3582
				':value' => $value,
178 3582
				':value_type' => $value_type,
179 3582
				':time_created' => $this->getCurrentTime()->getTimestamp(),
180
			]);
181
182 3582
			if ($id !== false) {
183 3582
				$obj = $this->get($id);
184 3582
				if ($this->events->trigger('create', 'metadata', $obj)) {
185 3582
					$this->cache->clear($entity_guid);
186
187 3582
					return $id;
188
				} else {
189
					$this->delete($id);
190
				}
191
			}
192
		}
193
194
		return $id;
195
	}
196
197
	/**
198
	 * Update a specific piece of metadata.
199
	 *
200
	 * @param int    $id         ID of the metadata to update
201
	 * @param string $name       Metadata name
202
	 * @param string $value      Metadata value
203
	 * @param string $value_type Value type
204
	 *
205
	 * @return bool
206
	 */
207 1
	function update($id, $name, $value, $value_type) {
208 1
		$id = (int) $id;
209
210 1
		if (!$md = $this->get($id)) {
211 1
			return false;
212
		}
213
		if (!$md->canEdit()) {
214
			return false;
215
		}
216
217
		$value_type = \ElggExtender::detectValueType($value, trim($value_type));
218
219
		// Support boolean types (as integers)
220
		if (is_bool($value)) {
221
			$value = (int) $value;
222
		}
223
		if (strlen($value) > self::MYSQL_TEXT_BYTE_LIMIT) {
224
			elgg_log("Metadata '$name' is above the MySQL TEXT size limit and may be truncated.", 'WARNING');
225
		}
226
		// If ok then add it
227
		$query = "UPDATE {$this->table}
228
			SET name = :name,
229
			    value = :value,
230
				value_type = :value_type
231
			WHERE id = :id";
232
233
		$result = $this->db->updateData($query, false, [
234
			':name' => $name,
235
			':value' => $value,
236
			':value_type' => $value_type,
237
			':id' => $id,
238
		]);
239
240
		if ($result !== false) {
241
			$this->cache->clear($md->entity_guid);
242
243
			// @todo this event tells you the metadata has been updated, but does not
244
			// let you do anything about it. What is needed is a plugin hook before
245
			// the update that passes old and new values.
246
			$obj = $this->get($id);
247
			$this->events->trigger('update', 'metadata', $obj);
248
		}
249
250
		return $result;
251
	}
252
253
	/**
254
	 * Returns metadata.  Accepts all elgg_get_entities() options for entity
255
	 * restraints.
256
	 *
257
	 * @see elgg_get_entities
258
	 *
259
	 * @warning 1.7's find_metadata() didn't support limits and returned all metadata.
260
	 *          This function defaults to a limit of 25. There is probably not a reason
261
	 *          for you to return all metadata unless you're exporting an entity,
262
	 *          have other restraints in place, or are doing something horribly
263
	 *          wrong in your code.
264
	 *
265
	 * @param array $options Array in format:
266
	 *
267
	 * metadata_names               => null|ARR metadata names
268
	 * metadata_values              => null|ARR metadata values
269
	 * metadata_ids                 => null|ARR metadata ids
270
	 * metadata_case_sensitive      => BOOL Overall Case sensitive
271
	 * metadata_created_time_lower  => INT Lower limit for created time.
272
	 * metadata_created_time_upper  => INT Upper limit for created time.
273
	 * metadata_calculation         => STR Perform the MySQL function on the metadata values returned.
274
	 *                                   The "metadata_calculation" option causes this function to
275
	 *                                   return the result of performing a mathematical calculation on
276
	 *                                   all metadata that match the query instead of returning
277
	 *                                   \ElggMetadata objects.
278
	 *
279
	 * @return \ElggMetadata[]|mixed
280
	 */
281 181 View Code Duplication
	function getAll(array $options = []) {
0 ignored issues
show
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
282
283
		// @todo remove support for count shortcut - see #4393
284
		// support shortcut of 'count' => true for 'metadata_calculation' => 'count'
285 181
		if (isset($options['count']) && $options['count']) {
286 1
			$options['metadata_calculation'] = 'count';
287 1
			unset($options['count']);
288
		}
289
290 181
		$options['metastring_type'] = 'metadata';
291 181
		return _elgg_get_metastring_based_objects($options);
292
	}
293
294
	/**
295
	 * Deletes metadata based on $options.
296
	 *
297
	 * @warning Unlike elgg_get_metadata() this will not accept an empty options array!
298
	 *          This requires at least one constraint:
299
	 *          metadata_name(s), metadata_value(s), or guid(s) must be set.
300
	 *
301
	 * @param array $options An options array. {@link elgg_get_metadata()}
302
	 * @return bool|null true on success, false on failure, null if no metadata to delete.
303
	 */
304 3614
	function deleteAll(array $options) {
305 3614
		if (!_elgg_is_valid_options_for_batch_operation($options, 'metadata')) {
306
			return false;
307
		}
308 3614
		$options['metastring_type'] = 'metadata';
309 3614
		$result = _elgg_batch_metastring_based_objects($options, 'elgg_batch_delete_callback', false);
310
311
		// This moved last in case an object's constructor sets metadata. Currently the batch
312
		// delete process has to create the entity to delete its metadata. See #5214
313 3614
		$this->cache->invalidateByOptions($options);
314
315 3614
		return $result;
316
	}
317
318
	/**
319
	 * Returns entities based upon metadata.  Also accepts all
320
	 * options available to elgg_get_entities().  Supports
321
	 * the singular option shortcut.
322
	 *
323
	 * @note Using metadata_names and metadata_values results in a
324
	 * "names IN (...) AND values IN (...)" clause.  This is subtly
325
	 * differently than default multiple metadata_name_value_pairs, which use
326
	 * "(name = value) AND (name = value)" clauses.
327
	 *
328
	 * When in doubt, use name_value_pairs.
329
	 *
330
	 * To ask for entities that do not have a metadata value, use a custom
331
	 * where clause like this:
332
	 *
333
	 * 	$options['wheres'][] = "NOT EXISTS (
334
	 *			SELECT 1 FROM {$dbprefix}metadata md
335
	 *			WHERE md.entity_guid = e.guid
336
	 *				AND md.name = $name
337
	 *				AND md.value = $value)";
338
	 *
339
	 * Note the metadata name and value has been denormalized in the above example.
340
	 *
341
	 * @see elgg_get_entities
342
	 *
343
	 * @param array $options Array in format:
344
	 *
345
	 * 	metadata_names => null|ARR metadata names
346
	 *
347
	 * 	metadata_values => null|ARR metadata values
348
	 *
349
	 * 	metadata_name_value_pairs => null|ARR (
350
	 *                                         name => 'name',
351
	 *                                         value => 'value',
352
	 *                                         'operand' => '=',
353
	 *                                         'case_sensitive' => true
354
	 *                                        )
355
	 *                               Currently if multiple values are sent via
356
	 *                               an array (value => array('value1', 'value2')
357
	 *                               the pair's operand will be forced to "IN".
358
	 *                               If passing "IN" as the operand and a string as the value,
359
	 *                               the value must be a properly quoted and escaped string.
360
	 *
361
	 * 	metadata_name_value_pairs_operator => null|STR The operator to use for combining
362
	 *                                        (name = value) OPERATOR (name = value); default AND
363
	 *
364
	 * 	metadata_case_sensitive => BOOL Overall Case sensitive
365
	 *
366
	 *  order_by_metadata => null|ARR array(
367
	 *                                      'name' => 'metadata_text1',
368
	 *                                      'direction' => ASC|DESC,
369
	 *                                      'as' => text|integer
370
	 *                                     )
371
	 *                                Also supports array('name' => 'metadata_text1')
372
	 *
373
	 * @return \ElggEntity[]|mixed If count, int. If not count, array. false on errors.
374
	 */
375 504
	function getEntities(array $options = []) {
376
		$defaults = [
377 504
			'metadata_names'                     => ELGG_ENTITIES_ANY_VALUE,
378 504
			'metadata_values'                    => ELGG_ENTITIES_ANY_VALUE,
379 504
			'metadata_name_value_pairs'          => ELGG_ENTITIES_ANY_VALUE,
380
381 504
			'metadata_name_value_pairs_operator' => 'AND',
382
			'metadata_case_sensitive'            => true,
383
			'order_by_metadata'                  => [],
384
		];
385
386 504
		$options = array_merge($defaults, $options);
387
388 504
		$singulars = ['metadata_name', 'metadata_value', 'metadata_name_value_pair'];
389
390 504
		$options = _elgg_normalize_plural_options_array($options, $singulars);
391
392 504
		if (!$options = _elgg_entities_get_metastrings_options('metadata', $options)) {
393
			return false;
394
		}
395
396 504
		return $this->entityTable->getEntities($options);
397
	}
398
399
	/**
400
	 * Returns metadata name and value SQL where for entities.
401
	 * NB: $names and $values are not paired. Use $pairs for this.
402
	 * Pairs default to '=' operand.
403
	 *
404
	 * This function is reused for annotations because the tables are
405
	 * exactly the same.
406
	 *
407
	 * @param string     $e_table           Entities table name
408
	 * @param string     $n_table           Normalized metastrings table name (Where entities,
409
	 *                                    values, and names are joined. annotations / metadata)
410
	 * @param array|null $names             Array of names
411
	 * @param array|null $values            Array of values
412
	 * @param array|null $pairs             Array of names / values / operands
413
	 * @param string     $pair_operator     ("AND" or "OR") Operator to use to join the where clauses for pairs
414
	 * @param bool       $case_sensitive    Case sensitive metadata names?
415
	 * @param array|null $order_by_metadata Array of names / direction
416
	 *
417
	 * @return false|array False on fail, array('joins', 'wheres')
418
	 * @access private
419
	 */
420 504
	function getEntityMetadataWhereSql($e_table, $n_table, $names = null, $values = null,
421
			$pairs = null, $pair_operator = 'AND', $case_sensitive = true, $order_by_metadata = null) {
422
		// short circuit if nothing requested
423
		// 0 is a valid (if not ill-conceived) metadata name.
424
		// 0 is also a valid metadata value for false, null, or 0
425 504 View Code Duplication
		if ((!$names && $names !== 0)
426 504
			&& (!$values && $values !== 0)
427 504
			&& (!$pairs && $pairs !== 0)
428 504
			&& !$order_by_metadata) {
429 293
			return '';
430
		}
431
432
		// join counter for incremental joins.
433 504
		$i = 1;
434
435
		// binary forces byte-to-byte comparision of strings, making
436
		// it case- and diacritical-mark- sensitive.
437
		// only supported on values.
438 504
		$binary = ($case_sensitive) ? ' BINARY ' : '';
439
440
		$return =  [
441 504
			'joins' =>  [],
442
			'wheres' => [],
443
			'orders' => [],
444
		];
445
446 504
		$return['joins'][] = "JOIN {$this->db->prefix}{$n_table} n_table on
447 504
			{$e_table}.guid = n_table.entity_guid";
448
449 504
		$wheres = [];
450
451
		// get names wheres and joins
452 504
		$names_where = '';
453 504 View Code Duplication
		if ($names !== null) {
454 27
			if (!is_array($names)) {
455 1
				$names = [$names];
456
			}
457
458 27
			$sanitised_names = [];
459 27
			foreach ($names as $name) {
460
				// normalise to 0.
461 27
				if (!$name) {
462
					$name = '0';
463
				}
464 27
				$sanitised_names[] = '\'' . $this->db->sanitizeString($name) . '\'';
465
			}
466
467 27
			if ($names_str = implode(',', $sanitised_names)) {
468 27
				$names_where = "(n_table.name IN ($names_str))";
469
			}
470
		}
471
472
		// get values wheres and joins
473 504
		$values_where = '';
474 504 View Code Duplication
		if ($values !== null) {
475 13
			if (!is_array($values)) {
476 1
				$values = [$values];
477
			}
478
479 13
			$sanitised_values = [];
480 13
			foreach ($values as $value) {
481
				// normalize to 0
482 13
				if (!$value) {
483 2
					$value = 0;
484
				}
485 13
				$sanitised_values[] = '\'' . $this->db->sanitizeString($value) . '\'';
486
			}
487
488 13
			if ($values_str = implode(',', $sanitised_values)) {
489 13
				$values_where = "({$binary}n_table.value IN ($values_str))";
490
			}
491
		}
492
493 504 View Code Duplication
		if ($names_where && $values_where) {
494 7
			$wheres[] = "($names_where AND $values_where)";
495 504
		} elseif ($names_where) {
496 20
			$wheres[] = "($names_where)";
497 502
		} elseif ($values_where) {
498 6
			$wheres[] = "($values_where)";
499
		}
500
501
		// add pairs
502
		// pairs must be in arrays.
503 504
		if (is_array($pairs)) {
504
			// check if this is an array of pairs or just a single pair.
505 502
			if (isset($pairs['name']) || isset($pairs['value'])) {
506 7
				$pairs = [$pairs];
507
			}
508
509 502
			$pair_wheres = [];
510
511
			// @todo when the pairs are > 3 should probably split the query up to
512
			// denormalize the strings table.
513
514 502
			foreach ($pairs as $index => $pair) {
515
				// @todo move this elsewhere?
516
				// support shortcut 'n' => 'v' method.
517 502
				if (!is_array($pair)) {
518
					$pair = [
519
						'name' => $index,
520
						'value' => $pair
521
					];
522
				}
523
524
				// must have at least a name and value
525 502
				if (!isset($pair['name']) || !isset($pair['value'])) {
526
					// @todo should probably return false.
527 289
					continue;
528
				}
529
530
				// case sensitivity can be specified per pair.
531
				// default to higher level setting.
532 300
				if (isset($pair['case_sensitive'])) {
533
					$pair_binary = ($pair['case_sensitive']) ? ' BINARY ' : '';
534
				} else {
535 300
					$pair_binary = $binary;
536
				}
537
538 300 View Code Duplication
				if (isset($pair['operand'])) {
539 3
					$operand = $this->db->sanitizeString($pair['operand']);
540
				} else {
541 297
					$operand = ' = ';
542
				}
543
544
				// for comparing
545 300
				$trimmed_operand = trim(strtolower($operand));
546
547
				// certain operands can't work well with strings that can be interpreted as numbers
548
				// for direct comparisons like IN, =, != we treat them as strings
549
				// gt/lt comparisons need to stay unencapsulated because strings '5' > '15'
550
				// see https://github.com/Elgg/Elgg/issues/7009
551 300
				$num_safe_operands = ['>', '<', '>=', '<='];
552 300
				$num_test_operand = trim(strtoupper($operand));
553
554 300
				$value = '';
555 300
				if (is_numeric($pair['value']) && in_array($num_test_operand, $num_safe_operands)) {
556 1
					$value = $this->db->sanitizeString($pair['value']);
557 299
				} else if (is_bool($pair['value'])) {
558 1
					$value = (int) $pair['value'];
559 298 View Code Duplication
				} else if (is_array($pair['value'])) {
560 2
					$values_array = [];
561
562 2
					foreach ($pair['value'] as $pair_value) {
563 2
						if (is_numeric($pair_value) && !in_array($num_test_operand, $num_safe_operands)) {
564 1
							$values_array[] = $this->db->sanitizeString($pair_value);
565
						} else {
566 2
							$values_array[] = "'" . $this->db->sanitizeString($pair_value) . "'";
567
						}
568
					}
569
570 2
					if ($values_array) {
571 2
						$value = '(' . implode(', ', $values_array) . ')';
572
					}
573
574
					// @todo allow support for non IN operands with array of values.
575
					// will have to do more silly joins.
576 2
					$operand = 'IN';
577 296
				} else if ($trimmed_operand == 'in') {
578 1
					$value = "({$pair['value']})";
579
				} else {
580 295
					$value = "'" . $this->db->sanitizeString($pair['value']) . "'";
581
				}
582
583 300
				$name = $this->db->sanitizeString($pair['name']);
584
585 300
				$return['joins'][] = "JOIN {$this->db->prefix}{$n_table} n_table{$i}
586 300
					on {$e_table}.guid = n_table{$i}.entity_guid";
587
588 300
				$pair_wheres[] = "(n_table{$i}.name = '$name' AND {$pair_binary}n_table{$i}.value
589 300
					$operand $value)";
590
591 300
				$i++;
592
			}
593
594 502
			if ($where = implode(" $pair_operator ", $pair_wheres)) {
595 300
				$wheres[] = "($where)";
596
			}
597
		}
598
599 504
		if ($where = implode(' AND ', $wheres)) {
600 326
			$return['wheres'][] = "($where)";
601
		}
602
603 504
		if (is_array($order_by_metadata)) {
604 504
			if ((count($order_by_metadata) > 0) && !isset($order_by_metadata[0])) {
605
				// singleton, so fix
606 6
				$order_by_metadata = [$order_by_metadata];
607
			}
608 504
			foreach ($order_by_metadata as $order_by) {
609 6
				if (is_array($order_by) && isset($order_by['name'])) {
610 6
					$name = $this->db->sanitizeString($order_by['name']);
611 6
					if (isset($order_by['direction'])) {
612
						$direction = $this->db->sanitizeString($order_by['direction']);
613
					} else {
614 6
						$direction = 'ASC';
615
					}
616 6
					$return['joins'][] = "JOIN {$this->db->prefix}{$n_table} n_table{$i}
617 6
						on {$e_table}.guid = n_table{$i}.entity_guid";
618
619 6
					$return['wheres'][] = "(n_table{$i}.name = '$name')";
620
621 6
					if (isset($order_by['as']) && $order_by['as'] == 'integer') {
622 5
						$return['orders'][] = "CAST(n_table{$i}.value AS SIGNED) $direction";
623
					} else {
624 1
						$return['orders'][] = "n_table{$i}.value $direction";
625
					}
626 6
					$i++;
627
				}
628
			}
629
		}
630
631 504
		return $return;
632
	}
633
634
	/**
635
	 * Get the URL for this metadata
636
	 *
637
	 * By default this links to the export handler in the current view.
638
	 *
639
	 * @param int $id Metadata ID
640
	 *
641
	 * @return mixed
642
	 */
643
	function getUrl($id) {
644
		$extender = $this->get($id);
645
646
		return $extender ? $extender->getURL() : false;
647
	}
648
}
649