Passed
Push — feature/909_Ampache_API_improv... ( 8b1a0c...1efcaa )
by Pauli
05:44
created

BaseMapper::advFormatSqlOperator()   D

Complexity

Conditions 18
Paths 18

Size

Total Lines 19
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 18
nc 18
nop 3
dl 0
loc 19
rs 4.8666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2016 - 2023
11
 */
12
13
namespace OCA\Music\Db;
14
15
use OCP\AppFramework\Db\DoesNotExistException;
16
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
17
use OCP\IConfig;
18
use OCP\IDBConnection;
19
20
use OCA\Music\AppFramework\Db\CompatibleMapper;
21
use OCA\Music\AppFramework\Db\UniqueConstraintViolationException;
22
use OCA\Music\Utility\Util;
23
24
/**
25
 * Common base class for data access classes of the Music app
26
 * @phpstan-template EntityType of Entity
27
 * @phpstan-method EntityType findEntity(string $sql, array $params)
28
 * @phpstan-method EntityType[] findEntities(string $sql, array $params, ?int $limit=null, ?int $offset=null)
29
 */
30
abstract class BaseMapper extends CompatibleMapper {
31
	const SQL_DATE_FORMAT = 'Y-m-d H:i:s.v';
32
33
	protected $nameColumn;
34
	protected $parentIdColumn;
35
	/** @phpstan-var class-string<EntityType> $entityClass */
36
	protected $entityClass;
37
	protected $dbIsPgsql; // the database type is PostgreSQL
38
39
	/**
40
	 * @phpstan-param class-string<EntityType> $entityClass
41
	 */
42
	public function __construct(IDBConnection $db, IConfig $config, string $tableName, string $entityClass, string $nameColumn, ?string $parentIdColumn=null) {
43
		parent::__construct($db, $tableName, $entityClass);
44
		$this->nameColumn = $nameColumn;
45
		$this->parentIdColumn = $parentIdColumn;
46
		// eclipse the base class property to help phpstan
47
		$this->entityClass = $entityClass;
48
		$this->dbIsPgsql = ($config->getSystemValue('dbtype') == 'pgsql');
49
	}
50
51
	/**
52
	 * Create an empty object of the entity class bound to this mapper
53
	 * @phpstan-return EntityType
54
	 */
55
	public function createEntity() : Entity {
56
		return new $this->entityClass();
57
	}
58
59
	/**
60
	 * Find a single entity by id and user_id
61
	 * @throws DoesNotExistException if the entity does not exist
62
	 * @throws MultipleObjectsReturnedException if more than one entity exists
63
	 * @phpstan-return EntityType
64
	 */
65
	public function find(int $id, string $userId) : Entity {
66
		$sql = $this->selectUserEntities("`{$this->getTableName()}`.`id` = ?");
67
		return $this->findEntity($sql, [$userId, $id]);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->findEntity...l, array($userId, $id)) returns the type OCP\AppFramework\Db\Entity which includes types incompatible with the type-hinted return OCA\Music\Db\Entity.
Loading history...
68
	}
69
70
	/**
71
	 * Find all entities matching the given IDs. Specifying the owning user is optional.
72
	 * @param integer[] $ids  IDs of the entities to be found
73
	 * @param string|null $userId
74
	 * @return Entity[]
75
	 * @phpstan-return EntityType[]
76
	 */
77
	public function findById(array $ids, string $userId=null) : array {
78
		$count = \count($ids);
79
		$condition = "`{$this->getTableName()}`.`id` IN ". $this->questionMarks($count);
80
81
		if (empty($userId)) {
82
			$sql = $this->selectEntities($condition);
83
		} else {
84
			$sql = $this->selectUserEntities($condition);
85
			$ids = \array_merge([$userId], $ids);
86
		}
87
88
		return $this->findEntities($sql, $ids);
89
	}
90
91
	/**
92
	 * Find all user's entities
93
	 * @param string|null $createdMin Optional minimum `created` timestamp.
94
	 * @param string|null $createdMax Optional maximum `created` timestamp.
95
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
96
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
97
	 * @return Entity[]
98
	 * @phpstan-return EntityType[]
99
	 */
100
	public function findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null,
101
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
102
		$sorting = $this->formatSortingClause($sortBy);
103
		[$condition, $params] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
104
		$sql = $this->selectUserEntities($condition, $sorting);
105
		\array_unshift($params, $userId);
106
		return $this->findEntities($sql, $params, $limit, $offset);
107
	}
108
109
	/**
110
	 * Find all user's entities matching the given name
111
	 * @param string|null $createdMin Optional minimum `created` timestamp.
112
	 * @param string|null $createdMax Optional maximum `created` timestamp.
113
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
114
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
115
	 * @return Entity[]
116
	 * @phpstan-return EntityType[]
117
	 */
118
	public function findAllByName(
119
		?string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null,
120
		?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
121
122
		$params = [$userId];
123
124
		[$condition, $nameParams] = $this->formatNameConditions($name, $matchMode);
125
		$params = \array_merge($params, $nameParams);
126
127
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
128
		if (!empty($timestampConds)) {
129
			$condition .= ' AND ' . $timestampConds;
130
			$params = \array_merge($params, $timestampParams);
131
		}
132
133
		$sql = $this->selectUserEntities($condition, $this->formatSortingClause(SortBy::Name));
134
135
		return $this->findEntities($sql, $params, $limit, $offset);
136
	}
137
138
	/**
139
	 * Find all user's starred entities. It is safe to call this also on entity types
140
	 * not supporting starring in which case an empty array will be returned.
141
	 * @return Entity[]
142
	 * @phpstan-return EntityType[]
143
	 */
144
	public function findAllStarred(string $userId, int $limit=null, int $offset=null) : array {
145
		if (\property_exists($this->entityClass, 'starred')) {
146
			$sql = $this->selectUserEntities(
147
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
148
				$this->formatSortingClause(SortBy::Name));
149
			return $this->findEntities($sql, [$userId], $limit, $offset);
150
		} else {
151
			return [];
152
		}
153
	}
154
155
	/**
156
	 * Find all entities with user-given rating 1-5
157
	 * @return Entity[]
158
	 * @phpstan-return EntityType[]
159
	 */
160
	public function findAllRated(string $userId, int $limit=null, int $offset=null) : array {
161
		if (\property_exists($this->entityClass, 'rating')) {
162
			$sql = $this->selectUserEntities(
163
				"`{$this->getTableName()}`.`rating` > 0",
164
				$this->formatSortingClause(SortBy::Rating));
165
			return $this->findEntities($sql, [$userId], $limit, $offset);
166
		} else {
167
			return [];
168
		}
169
	}
170
171
	/**
172
	 * Find all entities matching multiple criteria, as needed for the Ampache API method `advanced_search`
173
	 * @param string $conjunction Operator to use between the rules, either 'and' or 'or'
174
	 * @param array $rules Array of arrays: [['rule' => string, 'operator' => string, 'input' => string], ...]
175
	 * 				Here, 'rule' has dozens of possible values depending on the business layer in question
176
	 * 				(see https://ampache.org/api/api-advanced-search#available-search-rules, alias names not supported here),
177
	 * 				'operator' is one of 
178
	 * 				['contain', 'notcontain', 'start', 'end', 'is', 'isnot', '>=', '<=', '=', '!=', '>', '<', 'true', 'false', 'equal', 'ne', 'limit'],
179
	 * 				'input' is the right side value of the 'operator' (disregarded for the operators 'true' and 'false')
180
	 * @return Entity[]
181
	 * @phpstan-return EntityType[]
182
	 */
183
	public function findAllAdvanced(string $conjunction, array $rules, string $userId, ?int $limit=null, ?int $offset=null) : array {
184
		$sqlConditions = [];
185
		$sqlParams = [$userId];
186
187
		foreach ($rules as $rule) {
188
			list('op' => $sqlOp, 'param' => $param) = $this->advFormatSqlOperator($rule['operator'], $rule['input'], $userId);
189
			$cond = $this->advFormatSqlCondition($rule['rule'], $sqlOp);
190
			$sqlConditions[] = $cond;
191
			// On some conditions, the parameter may need to be repeated several times
192
			$paramCount = \substr_count($cond, '?');
193
			for ($i = 0; $i < $paramCount; ++$i) {
194
				$sqlParams[] = $param;
195
			}
196
		}
197
		$sqlConditions = \implode(" $conjunction ", $sqlConditions);
198
199
		$sql = $this->selectUserEntities($sqlConditions, $this->formatSortingClause(SortBy::Name));
200
		return $this->findEntities($sql, $sqlParams, $limit, $offset);
201
	}
202
203
	/**
204
	 * Optionally, limit to given IDs which may be used to check the validity of those IDs.
205
	 * @return int[]
206
	 */
207
	public function findAllIds(string $userId, ?array $ids = null) : array {
208
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
209
		$params = [$userId];
210
211
		if ($ids !== null) {
212
			$sql .= ' AND `id` IN ' . $this->questionMarks(\count($ids));
213
			$params = \array_merge($params, $ids);
214
		}
215
216
		$result = $this->execute($sql, $params);
217
218
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
219
	}
220
221
	/**
222
	 * Find all IDs and names of user's entities of this kind.
223
	 * Optionally, limit results based on a parent entity (not applicable for all entity types) or update/insert times or name
224
	 * @param bool $excludeChildless Exclude entities having no child-entities if applicable for this business layer (eg. artists without albums)
225
	 * @return array of arrays like ['id' => string, 'name' => ?string]
226
	 */
227
	public function findAllIdsAndNames(string $userId, ?int $parentId, ?int $limit=null, ?int $offset=null,
228
			?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null,
229
			bool $excludeChidless=false, ?string $name) : array {
230
		$sql = "SELECT `id`, `{$this->nameColumn}` AS `name` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
231
		$params = [$userId];
232
		if ($parentId !== null) {
233
			if ($this->parentIdColumn === null) {
234
				throw new \DomainException("The parentId filtering is not applicable for the table {$this->getTableName()}");
235
			} else {
236
				$sql .= " AND {$this->parentIdColumn} = ?";
237
				$params[] = $parentId;
238
			}
239
		}
240
241
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
242
		if (!empty($timestampConds)) {
243
			$sql .= " AND $timestampConds";
244
			$params = \array_merge($params, $timestampParams);
245
		}
246
247
		if ($excludeChidless) {
248
			$sql .= ' AND ' . $this->formatExcludeChildlessCondition();
249
		}
250
251
		if (!empty($name)) {
252
			[$nameCond, $nameParams] = $this->formatNameConditions($name, MatchMode::Substring);
253
			$sql .= " AND $nameCond";
254
			$params = \array_merge($params, $nameParams);
255
		}
256
257
		$sql .= ' ' . $this->formatSortingClause(SortBy::Name);
258
259
		if ($limit !== null) {
260
			$sql .= ' LIMIT ?';
261
			$params[] = $limit;
262
		}
263
		if ($offset !== null) {
264
			$sql .= ' OFFSET ?';
265
			$params[] = $offset;
266
		}
267
268
		$result = $this->execute($sql, $params);
269
270
		return $result->fetchAll();
271
	}
272
273
	/**
274
	 * Find IDs of all users owning any entities of this mapper
275
	 * @return string[]
276
	 */
277
	public function findAllUsers() : array {
278
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
279
		$result = $this->execute($sql);
280
281
		return $result->fetchAll(\PDO::FETCH_COLUMN);
282
	}
283
284
	/**
285
	 * Delete all entities with given IDs without specifying the user
286
	 * @param integer[] $ids  IDs of the entities to be deleted
287
	 */
288
	public function deleteById(array $ids) : void {
289
		$count = \count($ids);
290
		if ($count === 0) {
291
			return;
292
		}
293
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
294
	}
295
296
	/**
297
	 * Delete all entities matching the given SQL condition
298
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
299
	 * @param array $params SQL parameters for the condition
300
	 */
301
	protected function deleteByCond(string $condition, array $params) : void {
302
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
303
		$this->execute($sql, $params);
304
	}
305
306
	/**
307
	 * Delete all entities of the given user
308
	 */
309
	public function deleteAll(string $userId) : void {
310
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
311
		$this->execute($sql, [$userId]);
312
	}
313
314
	/**
315
	 * Tests if entity with given ID and user ID exists in the database
316
	 */
317
	public function exists(int $id, string $userId) : bool {
318
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
319
		$result = $this->execute($sql, [$id, $userId]);
320
		return $result->rowCount() > 0;
321
	}
322
323
	/**
324
	 * Count all entities of a user
325
	 */
326
	public function count(string $userId) : int {
327
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
328
		$result = $this->execute($sql, [$userId]);
329
		$row = $result->fetch();
330
		return \intval($row['count']);
331
	}
332
333
	/**
334
	 * {@inheritDoc}
335
	 * @see CompatibleMapper::insert()
336
	 * @phpstan-param EntityType $entity
337
	 * @phpstan-return EntityType
338
	 */
339
	public function insert(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
340
		$now = new \DateTime();
341
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
342
		$entity->setCreated($nowStr);
343
		$entity->setUpdated($nowStr);
344
345
		try {
346
			return parent::insert($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
347
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
348
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
349
		} catch (\OCP\DB\Exception $e) {
350
			// Nextcloud 21+
351
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
352
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
353
			} else {
354
				throw $e;
355
			}
356
		}
357
	}
358
359
	/**
360
	 * {@inheritDoc}
361
	 * @see CompatibleMapper::update()
362
	 * @phpstan-param EntityType $entity
363
	 * @phpstan-return EntityType
364
	 */
365
	public function update(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
366
		$now = new \DateTime();
367
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
368
		return parent::update($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
369
	}
370
371
	/**
372
	 * Insert an entity, or if an entity with the same identity already exists,
373
	 * update the existing entity.
374
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
375
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
376
	 * @return Entity The inserted or updated entity, containing also the id field
377
	 * @phpstan-param EntityType $entity
378
	 * @phpstan-return EntityType
379
	 */
380
	public function insertOrUpdate(Entity $entity) : Entity {
381
		try {
382
			return $this->insert($entity);
383
		} catch (UniqueConstraintViolationException $ex) {
384
			$existingEntity = $this->findUniqueEntity($entity);
385
			$entity->setId($existingEntity->getId());
386
			$entity->setCreated($existingEntity->getCreated());
387
			return $this->update($entity);
388
		}
389
	}
390
391
	/**
392
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
393
	 * a new entity is inserted.
394
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
395
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
396
	 * @return Entity The inserted or updated entity, containing also the id field
397
	 * @phpstan-param EntityType $entity
398
	 * @phpstan-return EntityType
399
	 */
400
	public function updateOrInsert(Entity $entity) : Entity {
401
		try {
402
			$existingEntity = $this->findUniqueEntity($entity);
403
			$entity->setId($existingEntity->getId());
404
			return $this->update($entity);
405
		} catch (DoesNotExistException $ex) {
406
			try {
407
				return $this->insert($entity);
408
			} catch (UniqueConstraintViolationException $ex) {
409
				// the conflicting entry didn't exist an eyeblink ago but now it does
410
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
411
				//    update happens last; cancel this update
412
				return $this->findUniqueEntity($entity);
413
			}
414
		}
415
	}
416
417
	/**
418
	 * Set the "starred" column of the given entities
419
	 * @param \DateTime|null $date
420
	 * @param integer[] $ids
421
	 * @param string $userId
422
	 * @return int number of modified entities
423
	 */
424
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
425
		$count = \count($ids);
426
		if (!empty($date)) {
427
			$date = $date->format(self::SQL_DATE_FORMAT);
428
		}
429
430
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
431
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
432
		$params = \array_merge([$date], $ids, [$userId]);
433
		return $this->execute($sql, $params)->rowCount();
434
	}
435
436
	public function latestInsertTime(string $userId) : ?\DateTime {
437
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
438
		$result = $this->execute($sql, [$userId]);
439
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
440
441
		return ($createdTime === null) ? null : new \DateTime($createdTime);
442
	}
443
444
	public function latestUpdateTime(string $userId) : ?\DateTime {
445
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
446
		$result = $this->execute($sql, [$userId]);
447
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
448
449
		return ($createdTime === null) ? null : new \DateTime($createdTime);
450
	}
451
452
	/**
453
	 * helper creating a string like '(?,?,?)' with the specified number of elements
454
	 */
455
	protected function questionMarks(int $count) : string {
456
		$questionMarks = [];
457
		for ($i = 0; $i < $count; $i++) {
458
			$questionMarks[] = '?';
459
		}
460
		return '(' . \implode(',', $questionMarks) . ')';
461
	}
462
463
	/**
464
	 * Build a SQL SELECT statement which selects all entities of the given user,
465
	 * and optionally applies other conditions, too.
466
	 * This is built upon `selectEntities` which may be overridden by the derived class.
467
	 * @param string|null $condition Optional extra condition. This will get automatically
468
	 *                               prefixed with ' AND ', so don't include that.
469
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
470
	 *                               the conditions in the SQL statement
471
	 */
472
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
473
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
474
475
		if (!empty($condition)) {
476
			$allConditions .= " AND ($condition)";
477
		}
478
479
		return $this->selectEntities($allConditions, $extension);
480
	}
481
482
	/**
483
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
484
	 * The derived class may override this if necessary.
485
	 * @param string $condition This will get automatically prefixed with ' WHERE '
486
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
487
	 *                               the conditions in the SQL statement
488
	 */
489
	protected function selectEntities(string $condition, string $extension=null) : string {
490
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
491
	}
492
493
	/**
494
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
495
	 */
496
	protected function formatNameConditions(?string $name, int $matchMode) : array {
497
		$params = [];
498
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
499
		if ($name === null) {
500
			$condition = "$nameCol IS NULL";
501
		} else {
502
			if ($matchMode === MatchMode::Exact) {
503
				$condition = "LOWER($nameCol) = LOWER(?)";
504
			} else {
505
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
506
			}
507
			if ($matchMode === MatchMode::Substring) {
508
				$params[] = self::prepareSubstringSearchPattern($name);
509
			} else {
510
				$params[] = $name;
511
			}
512
		}
513
		return [$condition, $params];
514
	}
515
516
	/**
517
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
518
	 */
519
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
520
		$conditions = [];
521
		$params = [];
522
523
		if (!empty($createdMin)) {
524
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
525
			$params[] = $createdMin;
526
		}
527
528
		if (!empty($createdMax)) {
529
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
530
			$params[] = $createdMax;
531
		}
532
533
		if (!empty($updatedMin)) {
534
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
535
			$params[] = $updatedMin;
536
		}
537
538
		if (!empty($updatedMax)) {
539
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
540
			$params[] = $updatedMax;
541
		}
542
543
		return [\implode(' AND ', $conditions), $params];
544
	}
545
546
	/**
547
	 * Convert given sorting condition to an SQL clause. Derived class may overide this if necessary.
548
	 * @param int $sortBy One of the constants defined in the class SortBy
549
	 */
550
	protected function formatSortingClause(int $sortBy, bool $invertSort = false) : ?string {
551
		$table = $this->getTableName();
552
		if ($sortBy == SortBy::Name) {
553
			$dir = $invertSort ? 'DESC' : 'ASC';
554
			return "ORDER BY LOWER(`$table`.`{$this->nameColumn}`) $dir";
555
		} elseif ($sortBy == SortBy::Newest) {
556
			$dir = $invertSort ? 'ASC' : 'DESC';
557
			return "ORDER BY `$table`.`id` $dir"; // abuse the fact that IDs are ever-incrementing values
558
		} elseif ($sortBy == SortBy::Rating) {
559
			if (\property_exists($this->entityClass, 'rating')) {
560
				$dir = $invertSort ? 'ASC' : 'DESC';
561
				return "ORDER BY `$table`.`rating` $dir";
562
			} else {
563
				return null;
564
			}
565
		} else {
566
			return null;
567
		}
568
	}
569
570
	/**
571
	 * Return an SQL condition to exclude entities having no children. The default implementation is empty
572
	 * and derived classes may override this if applicable.
573
	 */
574
	protected function formatExcludeChildlessCondition() : string {
575
		return 'true';
576
	}
577
578
	protected static function prepareSubstringSearchPattern(string $input) : string {
579
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
580
		// while the default interpretation of multipart string is that each of the parts can be found
581
		// separately as substring in the given order
582
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
583
			// remove the quotation
584
			$pattern = \substr($input, 1, -1);
585
		} else {
586
			// split to parts by whitespace
587
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
588
			// glue the parts back together with a wildcard charater
589
			$pattern = \implode('%', $parts);
590
		}
591
		return "%$pattern%";
592
	}
593
594
	/**
595
	 * Format SQL operator and parameter matching the given advanced search operator.
596
	 * @return array like ['op' => string, 'param' => string]
597
	 */
598
	protected function advFormatSqlOperator(string $ruleOperator, string $ruleInput, string $userId) {
599
		$pqsql = $this->dbIsPgsql;
600
		switch ($ruleOperator) {
601
			case 'contain':		return ['op' => 'LIKE',							'param' => "%$ruleInput%"];
602
			case 'notcontain':	return ['op' => 'NOT LIKE',						'param' => "%$ruleInput%"];
603
			case 'start':		return ['op' => 'LIKE',							'param' => "$ruleInput%"];
604
			case 'end':			return ['op' => 'LIKE',							'param' => "%$ruleInput"];
605
			case 'is':			return ['op' => '=',							'param' => "$ruleInput"];
606
			case 'isnot':		return ['op' => '!=',							'param' => "$ruleInput"];
607
			case 'sounds':		return ['op' => 'SOUNDS LIKE',					'param' => $ruleInput]; // MySQL-specific syntax
608
			case 'notsounds':	return ['op' => 'NOT SOUNDS LIKE',				'param' => $ruleInput]; // MySQL-specific syntax
609
			case 'regexp':		return ['op' => $pqsql ? '~' : 'REGEXP',		'param' => $ruleInput];
610
			case 'notregexp':	return ['op' => $pqsql ? '!~' : 'NOT REGEXP',	'param' => $ruleInput];
611
			case 'true':		return ['op' => 'IS NOT NULL',					'param' => null];
612
			case 'false':		return ['op' => 'IS NULL',						'param' => null];
613
			case 'equal':		return ['op' => '',								'param' => $ruleInput];
614
			case 'ne':			return ['op' => 'NOT',							'param' => $ruleInput];
615
			case 'limit':		return ['op' => (string)(int)$ruleInput,		'param' => $userId];	// this is a bit hacky, userId needs to be passed as an SQL param while simple sanitation suffices for the limit
616
			default:			return ['op' => $ruleOperator,					'param' => $ruleInput]; // all numerical operators fall here
617
		}
618
	}
619
620
	/**
621
	 * Format SQL condition matching the given advanced search rule and SQL operator.
622
	 * Derived classes should override this to provide support for table-specific rules.
623
	 */
624
	protected function advFormatSqlCondition(string $rule, string $sqlOp) : string {
625
		$table = $this->getTableName();
626
		$nameCol = $this->nameColumn;
627
628
		switch ($rule) {
629
			case 'title':			return "LOWER(`$table`.`$nameCol`) $sqlOp LOWER(?)";
630
			case 'my_flagged':		return "`$table`.`starred` $sqlOp";
631
			case 'favorite':		return "(LOWER(`$table`.`$nameCol`) $sqlOp LOWER(?) AND `$table`.`starred` IS NOT NULL)"; // title search among flagged
632
			case 'myrating':		// fall throuhg, we provide no access to other people's data
633
			case 'rating':			return "`$table`.`rating` $sqlOp ?";
634
			case 'added':			return "`$table`.`created` $sqlOp ?";
635
			case 'updated':			return "`$table`.`updated` $sqlOp ?";
636
			case 'mbid':			return "`$table`.`mbid` $sqlOp ?";
637
			case 'recent_added':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `created` DESC LIMIT $sqlOp) mysqlhack)";
638
			case 'recent_updated':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `updated` DESC LIMIT $sqlOp) mysqlhack)";
639
			default:				throw new \DomainException("Rule '$rule' not supported on this entity type");
640
		}
641
	}
642
643
	/**
644
	 * Find an entity which has the same identity as the supplied entity.
645
	 * How the identity of the entity is defined, depends on the derived concrete class.
646
	 * @phpstan-param EntityType $entity
647
	 * @phpstan-return EntityType
648
	 */
649
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
650
}
651