Passed
Push — feature/909_Ampache_API_improv... ( 2004a5...b752cc )
by Pauli
12:33
created

BaseMapper::formatExcludeChildlessCondition()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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