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

BaseMapper::findAllByName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 9
dl 0
loc 18
rs 9.9666

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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