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

BaseMapper::findAllIdsAndNames()   B

Complexity

Conditions 8
Paths 65

Size

Total Lines 44
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 8
eloc 27
c 2
b 0
f 0
nc 65
nop 10
dl 0
loc 44
rs 8.4444

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\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