Passed
Pull Request — master (#1141)
by Pauli
04:01
created

BaseMapper::findAll()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
c 0
b 0
f 0
nc 1
nop 8
dl 0
loc 7
rs 10

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 - 2024
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 $dbType; // database type 'mysql', 'pgsql', or 'sqlite3'
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->dbType = $config->getSystemValue('dbtype');
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
	public function unprefixedTableName() : string {
60
		return \str_replace('*PREFIX*', '', $this->getTableName());
61
	}
62
63
	/**
64
	 * Find a single entity by id and user_id
65
	 * @throws DoesNotExistException if the entity does not exist
66
	 * @throws MultipleObjectsReturnedException if more than one entity exists
67
	 * @phpstan-return EntityType
68
	 */
69
	public function find(int $id, string $userId) : Entity {
70
		$sql = $this->selectUserEntities("`{$this->getTableName()}`.`id` = ?");
71
		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...
72
	}
73
74
	/**
75
	 * Find all entities matching the given IDs. Specifying the owning user is optional.
76
	 * @param integer[] $ids  IDs of the entities to be found
77
	 * @param string|null $userId
78
	 * @return Entity[]
79
	 * @phpstan-return EntityType[]
80
	 */
81
	public function findById(array $ids, string $userId=null) : array {
82
		$count = \count($ids);
83
		$condition = "`{$this->getTableName()}`.`id` IN ". $this->questionMarks($count);
84
85
		if (empty($userId)) {
86
			$sql = $this->selectEntities($condition);
87
		} else {
88
			$sql = $this->selectUserEntities($condition);
89
			$ids = \array_merge([$userId], $ids);
90
		}
91
92
		return $this->findEntities($sql, $ids);
93
	}
94
95
	/**
96
	 * Find all user's entities
97
	 * @param string|null $createdMin Optional minimum `created` timestamp.
98
	 * @param string|null $createdMax Optional maximum `created` timestamp.
99
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
100
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
101
	 * @return Entity[]
102
	 * @phpstan-return EntityType[]
103
	 */
104
	public function findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null,
105
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
106
		$sorting = $this->formatSortingClause($sortBy);
107
		[$condition, $params] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
108
		$sql = $this->selectUserEntities($condition, $sorting);
109
		\array_unshift($params, $userId);
110
		return $this->findEntities($sql, $params, $limit, $offset);
111
	}
112
113
	/**
114
	 * Find all user's entities matching the given name
115
	 * @param string|null $createdMin Optional minimum `created` timestamp.
116
	 * @param string|null $createdMax Optional maximum `created` timestamp.
117
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
118
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
119
	 * @return Entity[]
120
	 * @phpstan-return EntityType[]
121
	 */
122
	public function findAllByName(
123
		?string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null,
124
		?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
125
126
		$params = [$userId];
127
128
		[$condition, $nameParams] = $this->formatNameConditions($name, $matchMode);
129
		$params = \array_merge($params, $nameParams);
130
131
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
132
		if (!empty($timestampConds)) {
133
			$condition .= ' AND ' . $timestampConds;
134
			$params = \array_merge($params, $timestampParams);
135
		}
136
137
		$sql = $this->selectUserEntities($condition, $this->formatSortingClause(SortBy::Name));
138
139
		return $this->findEntities($sql, $params, $limit, $offset);
140
	}
141
142
	/**
143
	 * Find all user's starred entities. It is safe to call this also on entity types
144
	 * not supporting starring in which case an empty array will be returned.
145
	 * @return Entity[]
146
	 * @phpstan-return EntityType[]
147
	 */
148
	public function findAllStarred(string $userId, int $limit=null, int $offset=null) : array {
149
		if (\property_exists($this->entityClass, 'starred')) {
150
			$sql = $this->selectUserEntities(
151
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
152
				$this->formatSortingClause(SortBy::Name));
153
			return $this->findEntities($sql, [$userId], $limit, $offset);
154
		} else {
155
			return [];
156
		}
157
	}
158
159
	/**
160
	 * Find all entities with user-given rating 1-5
161
	 * @return Entity[]
162
	 * @phpstan-return EntityType[]
163
	 */
164
	public function findAllRated(string $userId, int $limit=null, int $offset=null) : array {
165
		if (\property_exists($this->entityClass, 'rating')) {
166
			$sql = $this->selectUserEntities(
167
				"`{$this->getTableName()}`.`rating` > 0",
168
				$this->formatSortingClause(SortBy::Rating));
169
			return $this->findEntities($sql, [$userId], $limit, $offset);
170
		} else {
171
			return [];
172
		}
173
	}
174
175
	/**
176
	 * Find all entities matching multiple criteria, as needed for the Ampache API method `advanced_search`
177
	 * @param string $conjunction Operator to use between the rules, either 'and' or 'or'
178
	 * @param array $rules Array of arrays: [['rule' => string, 'operator' => string, 'input' => string], ...]
179
	 * 				Here, 'rule' has dozens of possible values depending on the business layer in question
180
	 * 				(see https://ampache.org/api/api-advanced-search#available-search-rules, alias names not supported here),
181
	 * 				'operator' is one of 
182
	 * 				['contain', 'notcontain', 'start', 'end', 'is', 'isnot', 'sounds', 'notsounds', 'regexp', 'notregexp',
183
	 * 				 '>=', '<=', '=', '!=', '>', '<', 'before', 'after', 'true', 'false', 'equal', 'ne', 'limit'],
184
	 * 				'input' is the right side value of the 'operator' (disregarded for the operators 'true' and 'false')
185
	 * @return Entity[]
186
	 * @phpstan-return EntityType[]
187
	 */
188
	public function findAllAdvanced(string $conjunction, array $rules, string $userId, int $sortBy=SortBy::None, ?int $limit=null, ?int $offset=null) : array {
189
		$sqlConditions = [];
190
		$sqlParams = [$userId];
191
192
		foreach ($rules as $rule) {
193
			list('op' => $sqlOp, 'conv' => $sqlConv, 'param' => $param) = $this->advFormatSqlOperator($rule['operator'], (string)$rule['input'], $userId);
194
			$cond = $this->advFormatSqlCondition($rule['rule'], $sqlOp, $sqlConv);
195
			$sqlConditions[] = $cond;
196
			// On some conditions, the parameter may need to be repeated several times
197
			$paramCount = \substr_count($cond, '?');
198
			for ($i = 0; $i < $paramCount; ++$i) {
199
				$sqlParams[] = $param;
200
			}
201
		}
202
		$sqlConditions = \implode(" $conjunction ", $sqlConditions);
203
204
		$sql = $this->selectUserEntities($sqlConditions, $this->formatSortingClause($sortBy));
205
		return $this->findEntities($sql, $sqlParams, $limit, $offset);
206
	}
207
208
	/**
209
	 * Optionally, limit to given IDs which may be used to check the validity of those IDs.
210
	 * @return int[]
211
	 */
212
	public function findAllIds(string $userId, ?array $ids = null) : array {
213
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
214
		$params = [$userId];
215
216
		if ($ids !== null) {
217
			$sql .= ' AND `id` IN ' . $this->questionMarks(\count($ids));
218
			$params = \array_merge($params, $ids);
219
		}
220
221
		$result = $this->execute($sql, $params);
222
223
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
224
	}
225
226
	/**
227
	 * Find all IDs and names of user's entities of this kind.
228
	 * Optionally, limit results based on a parent entity (not applicable for all entity types) or update/insert times or name
229
	 * @param bool $excludeChildless Exclude entities having no child-entities if applicable for this business layer (eg. artists without albums)
230
	 * @return array of arrays like ['id' => string, 'name' => ?string]
231
	 */
232
	public function findAllIdsAndNames(string $userId, ?int $parentId, ?int $limit=null, ?int $offset=null,
233
			?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null,
234
			bool $excludeChildless=false, ?string $name=null) : array {
235
		$sql = "SELECT `id`, `{$this->nameColumn}` AS `name` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
236
		$params = [$userId];
237
		if ($parentId !== null) {
238
			if ($this->parentIdColumn === null) {
239
				throw new \DomainException("The parentId filtering is not applicable for the table {$this->getTableName()}");
240
			} else {
241
				$sql .= " AND {$this->parentIdColumn} = ?";
242
				$params[] = $parentId;
243
			}
244
		}
245
246
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
247
		if (!empty($timestampConds)) {
248
			$sql .= " AND $timestampConds";
249
			$params = \array_merge($params, $timestampParams);
250
		}
251
252
		if ($excludeChildless) {
253
			$sql .= ' AND ' . $this->formatExcludeChildlessCondition();
254
		}
255
256
		if (!empty($name)) {
257
			[$nameCond, $nameParams] = $this->formatNameConditions($name, MatchMode::Substring);
258
			$sql .= " AND $nameCond";
259
			$params = \array_merge($params, $nameParams);
260
		}
261
262
		$sql .= ' ' . $this->formatSortingClause(SortBy::Name);
263
264
		if ($limit !== null) {
265
			$sql .= ' LIMIT ?';
266
			$params[] = $limit;
267
		}
268
		if ($offset !== null) {
269
			$sql .= ' OFFSET ?';
270
			$params[] = $offset;
271
		}
272
273
		$result = $this->execute($sql, $params);
274
275
		return $result->fetchAll();
276
	}
277
278
	/**
279
	 * Find IDs of all users owning any entities of this mapper
280
	 * @return string[]
281
	 */
282
	public function findAllUsers() : array {
283
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
284
		$result = $this->execute($sql);
285
286
		return $result->fetchAll(\PDO::FETCH_COLUMN);
287
	}
288
289
	/**
290
	 * Delete all entities with given IDs without specifying the user
291
	 * @param integer[] $ids  IDs of the entities to be deleted
292
	 */
293
	public function deleteById(array $ids) : void {
294
		$count = \count($ids);
295
		if ($count === 0) {
296
			return;
297
		}
298
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
299
	}
300
301
	/**
302
	 * Delete all entities matching the given SQL condition
303
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
304
	 * @param array $params SQL parameters for the condition
305
	 */
306
	protected function deleteByCond(string $condition, array $params) : void {
307
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
308
		$this->execute($sql, $params);
309
	}
310
311
	/**
312
	 * Delete all entities of the given user
313
	 */
314
	public function deleteAll(string $userId) : void {
315
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
316
		$this->execute($sql, [$userId]);
317
	}
318
319
	/**
320
	 * Tests if entity with given ID and user ID exists in the database
321
	 */
322
	public function exists(int $id, string $userId) : bool {
323
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
324
		$result = $this->execute($sql, [$id, $userId]);
325
		return $result->rowCount() > 0;
326
	}
327
328
	/**
329
	 * Count all entities of a user
330
	 */
331
	public function count(string $userId) : int {
332
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
333
		$result = $this->execute($sql, [$userId]);
334
		$row = $result->fetch();
335
		return \intval($row['count']);
336
	}
337
338
	/**
339
	 * {@inheritDoc}
340
	 * @see CompatibleMapper::insert()
341
	 * @phpstan-param EntityType $entity
342
	 * @phpstan-return EntityType
343
	 */
344
	public function insert(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
345
		$now = new \DateTime();
346
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
347
		$entity->setCreated($nowStr);
348
		$entity->setUpdated($nowStr);
349
350
		try {
351
			return parent::insert($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
352
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
353
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
354
		} catch (\OCP\DB\Exception $e) {
355
			// Nextcloud 21+
356
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
357
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
358
			} else {
359
				throw $e;
360
			}
361
		}
362
	}
363
364
	/**
365
	 * {@inheritDoc}
366
	 * @see CompatibleMapper::update()
367
	 * @phpstan-param EntityType $entity
368
	 * @phpstan-return EntityType
369
	 */
370
	public function update(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
371
		$now = new \DateTime();
372
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
373
		return parent::update($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
374
	}
375
376
	/**
377
	 * Insert an entity, or if an entity with the same identity already exists,
378
	 * update the existing entity.
379
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
380
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
381
	 * @return Entity The inserted or updated entity, containing also the id field
382
	 * @phpstan-param EntityType $entity
383
	 * @phpstan-return EntityType
384
	 */
385
	public function insertOrUpdate(Entity $entity) : Entity {
386
		try {
387
			return $this->insert($entity);
388
		} catch (UniqueConstraintViolationException $ex) {
389
			$existingEntity = $this->findUniqueEntity($entity);
390
			$entity->setId($existingEntity->getId());
391
			$entity->setCreated($existingEntity->getCreated());
392
			return $this->update($entity);
393
		}
394
	}
395
396
	/**
397
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
398
	 * a new entity is inserted.
399
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
400
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
401
	 * @return Entity The inserted or updated entity, containing also the id field
402
	 * @phpstan-param EntityType $entity
403
	 * @phpstan-return EntityType
404
	 */
405
	public function updateOrInsert(Entity $entity) : Entity {
406
		try {
407
			$existingEntity = $this->findUniqueEntity($entity);
408
			$entity->setId($existingEntity->getId());
409
			return $this->update($entity);
410
		} catch (DoesNotExistException $ex) {
411
			try {
412
				return $this->insert($entity);
413
			} catch (UniqueConstraintViolationException $ex) {
414
				// the conflicting entry didn't exist an eyeblink ago but now it does
415
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
416
				//    update happens last; cancel this update
417
				return $this->findUniqueEntity($entity);
418
			}
419
		}
420
	}
421
422
	/**
423
	 * Set the "starred" column of the given entities
424
	 * @param \DateTime|null $date
425
	 * @param integer[] $ids
426
	 * @param string $userId
427
	 * @return int number of modified entities
428
	 */
429
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
430
		$count = \count($ids);
431
		if (!empty($date)) {
432
			$date = $date->format(self::SQL_DATE_FORMAT);
433
		}
434
435
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
436
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
437
		$params = \array_merge([$date], $ids, [$userId]);
438
		return $this->execute($sql, $params)->rowCount();
439
	}
440
441
	public function latestInsertTime(string $userId) : ?\DateTime {
442
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) 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
	public function latestUpdateTime(string $userId) : ?\DateTime {
450
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
451
		$result = $this->execute($sql, [$userId]);
452
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
453
454
		return ($createdTime === null) ? null : new \DateTime($createdTime);
455
	}
456
457
	/**
458
	 * helper creating a string like '(?,?,?)' with the specified number of elements
459
	 */
460
	protected function questionMarks(int $count) : string {
461
		$questionMarks = [];
462
		for ($i = 0; $i < $count; $i++) {
463
			$questionMarks[] = '?';
464
		}
465
		return '(' . \implode(',', $questionMarks) . ')';
466
	}
467
468
	/**
469
	 * Build a SQL SELECT statement which selects all entities of the given user,
470
	 * and optionally applies other conditions, too.
471
	 * This is built upon `selectEntities` which may be overridden by the derived class.
472
	 * @param string|null $condition Optional extra condition. This will get automatically
473
	 *                               prefixed with ' AND ', so don't include that.
474
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
475
	 *                               the conditions in the SQL statement
476
	 */
477
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
478
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
479
480
		if (!empty($condition)) {
481
			$allConditions .= " AND ($condition)";
482
		}
483
484
		return $this->selectEntities($allConditions, $extension);
485
	}
486
487
	/**
488
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
489
	 * The derived class may override this if necessary.
490
	 * @param string $condition This will get automatically prefixed with ' WHERE '
491
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
492
	 *                               the conditions in the SQL statement
493
	 */
494
	protected function selectEntities(string $condition, string $extension=null) : string {
495
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
496
	}
497
498
	/**
499
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
500
	 */
501
	protected function formatNameConditions(?string $name, int $matchMode) : array {
502
		$params = [];
503
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
504
		if ($name === null) {
505
			$condition = "$nameCol IS NULL";
506
		} else {
507
			if ($matchMode === MatchMode::Exact) {
508
				$condition = "LOWER($nameCol) = LOWER(?)";
509
			} else {
510
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
511
			}
512
			if ($matchMode === MatchMode::Substring) {
513
				$params[] = self::prepareSubstringSearchPattern($name);
514
			} else {
515
				$params[] = $name;
516
			}
517
		}
518
		return [$condition, $params];
519
	}
520
521
	/**
522
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
523
	 */
524
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
525
		$conditions = [];
526
		$params = [];
527
528
		if (!empty($createdMin)) {
529
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
530
			$params[] = $createdMin;
531
		}
532
533
		if (!empty($createdMax)) {
534
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
535
			$params[] = $createdMax;
536
		}
537
538
		if (!empty($updatedMin)) {
539
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
540
			$params[] = $updatedMin;
541
		}
542
543
		if (!empty($updatedMax)) {
544
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
545
			$params[] = $updatedMax;
546
		}
547
548
		return [\implode(' AND ', $conditions), $params];
549
	}
550
551
	/**
552
	 * Convert given sorting condition to an SQL clause. Derived class may override this if necessary.
553
	 * @param int $sortBy One of the constants defined in the class SortBy
554
	 */
555
	protected function formatSortingClause(int $sortBy, bool $invertSort = false) : ?string {
556
		$table = $this->getTableName();
557
		if ($sortBy == SortBy::Name) {
558
			$dir = $invertSort ? 'DESC' : 'ASC';
559
			return "ORDER BY LOWER(`$table`.`{$this->nameColumn}`) $dir";
560
		} elseif ($sortBy == SortBy::Newest) {
561
			$dir = $invertSort ? 'ASC' : 'DESC';
562
			return "ORDER BY `$table`.`id` $dir"; // abuse the fact that IDs are ever-incrementing values
563
		} elseif ($sortBy == SortBy::Rating) {
564
			if (\property_exists($this->entityClass, 'rating')) {
565
				$dir = $invertSort ? 'ASC' : 'DESC';
566
				return "ORDER BY `$table`.`rating` $dir";
567
			} else {
568
				return null;
569
			}
570
		} else {
571
			return null;
572
		}
573
	}
574
575
	/**
576
	 * Return an SQL condition to exclude entities having no children. The default implementation is empty
577
	 * and derived classes may override this if applicable.
578
	 */
579
	protected function formatExcludeChildlessCondition() : string {
580
		return '1=1';
581
	}
582
583
	protected static function prepareSubstringSearchPattern(string $input) : string {
584
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
585
		// while the default interpretation of multipart string is that each of the parts can be found
586
		// separately as substring in the given order
587
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
588
			// remove the quotation
589
			$pattern = \substr($input, 1, -1);
590
		} else {
591
			// split to parts by whitespace
592
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
593
			// glue the parts back together with a wildcard charater
594
			$pattern = \implode('%', $parts);
595
		}
596
		return "%$pattern%";
597
	}
598
599
	/**
600
	 * Format SQL operator, conversion, and parameter matching the given advanced search operator.
601
	 * @return array like ['op' => string, 'conv' => string, 'param' => string|int|null]
602
	 */
603
	protected function advFormatSqlOperator(string $ruleOperator, string $ruleInput, string $userId) {
604
		if ($this->dbType == 'sqlite3' && ($ruleOperator == 'regexp' || $ruleOperator == 'notregexp')) {
605
			$this->registerRegexpFuncForSqlite();
606
		}
607
608
		$pgsql = ($this->dbType == 'pgsql');
609
610
		switch ($ruleOperator) {
611
			case 'contain':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput%"];
612
			case 'notcontain':	return ['op' => 'NOT LIKE',								'conv' => 'LOWER',		'param' => "%$ruleInput%"];
613
			case 'start':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "$ruleInput%"];
614
			case 'end':			return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput"];
615
			case 'is':			return ['op' => '=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
616
			case 'isnot':		return ['op' => '!=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
617
			case 'sounds':		return ['op' => '=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
618
			case 'notsounds':	return ['op' => '!=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
619
			case 'regexp':		return ['op' => $pgsql ? '~' : 'REGEXP',				'conv' => 'LOWER',		'param' => $ruleInput];
620
			case 'notregexp':	return ['op' => $pgsql ? '!~' : 'NOT REGEXP',			'conv' => 'LOWER',		'param' => $ruleInput];
621
			case 'true':		return ['op' => 'IS NOT NULL',							'conv' => '',			'param' => null];
622
			case 'false':		return ['op' => 'IS NULL',								'conv' => '',			'param' => null];
623
			case 'equal':		return ['op' => '',										'conv' => '',			'param' => $ruleInput];
624
			case 'ne':			return ['op' => 'NOT',									'conv' => '',			'param' => $ruleInput];
625
			case 'limit':		return ['op' => (string)(int)$ruleInput,				'conv' => '',			'param' => $userId]; // this is a bit hacky, userId needs to be passed as an SQL param while simple sanitation suffices for the limit
626
			case 'before':		return ['op' => '<',									'conv' => '',			'param' => $ruleInput];
627
			case 'after':		return ['op' => '>',									'conv' => '',			'param' => $ruleInput];
628
			default:			return ['op' => self::sanitizeNumericOp($ruleOperator),	'conv' => '',			'param' => (int)$ruleInput]; // all numerical operators fall here
629
		}
630
	}
631
632
	protected static function sanitizeNumericOp($comparisonOperator) {
633
		if (\in_array($comparisonOperator, ['>=', '<=', '=', '!=', '>', '<'])) {
634
			return $comparisonOperator;
635
		} else {
636
			throw new \DomainException("Bad advanced search operator: $comparisonOperator");
637
		}
638
	}
639
640
	/**
641
	 * Format SQL condition matching the given advanced search rule and SQL operator.
642
	 * Derived classes should override this to provide support for table-specific rules.
643
	 * @param string $rule	Identifier of the property which is the target of the SQL condition. The identifiers match the Ampache API specification.
644
	 * @param string $sqlOp	SQL (comparison) operator to be used
645
	 * @param string $conv	SQL conversion function to be applied on the target column and the parameter (e.g. "LOWER")
646
	 * @return string SQL condition statement to be used in the "WHERE" clause
647
	 */
648
	protected function advFormatSqlCondition(string $rule, string $sqlOp, string $conv) : string {
649
		$table = $this->getTableName();
650
		$nameCol = $this->nameColumn;
651
652
		switch ($rule) {
653
			case 'title':			return "$conv(`$table`.`$nameCol`) $sqlOp $conv(?)";
654
			case 'my_flagged':		return "`$table`.`starred` $sqlOp";
655
			case 'favorite':		return "($conv(`$table`.`$nameCol`) $sqlOp $conv(?) AND `$table`.`starred` IS NOT NULL)"; // title search among flagged
656
			case 'myrating':		// fall through, we provide no access to other people's data
657
			case 'rating':			return "`$table`.`rating` $sqlOp ?";
658
			case 'added':			return "`$table`.`created` $sqlOp ?";
659
			case 'updated':			return "`$table`.`updated` $sqlOp ?";
660
			case 'mbid':			return "`$table`.`mbid` $sqlOp ?";
661
			case 'recent_added':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `created` DESC LIMIT $sqlOp) mysqlhack)";
662
			case 'recent_updated':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `updated` DESC LIMIT $sqlOp) mysqlhack)";
663
			default:				throw new \DomainException("Rule '$rule' not supported on this entity type");
664
		}
665
	}
666
667
	protected function sqlConcat(string ...$parts) : string {
668
		if ($this->dbType == 'sqlite3') {
669
			return '(' . \implode(' || ', $parts) . ')';
670
		} else {
671
			return 'CONCAT(' . \implode(', ', $parts) . ')';
672
		}
673
	}
674
675
	protected function sqlGroupConcat(string $column) : string {
676
		if ($this->dbType == 'pgsql') {
677
			return "string_agg($column, ',')";
678
		} else {
679
			return "GROUP_CONCAT($column)";
680
		}
681
	}
682
683
	/**
684
	 * SQLite connects the operator REGEXP to the function of the same name but doesn't ship the function itself.
685
	 * Hence, we need to register it as a user-function. This happens by creating a suitable wrapper for the PHP
686
	 * native preg_match function. Based on https://stackoverflow.com/a/18484596.
687
	 */
688
	private function registerRegexpFuncForSqlite() {
689
		// skip if the function already exists
690
		if (!$this->funcExistsInSqlite('regexp')) {
691
			// We need to use a private interface here to drill down to the native DB connection. The interface is
692
			// slightly different on NC compared to OC.
693
			if (\method_exists($this->db, 'getInner')) {
694
				$connection = $this->db->/** @scrutinizer ignore-call */getInner()->getWrappedConnection();
695
				$pdo = $connection->getWrappedConnection();
696
			} else if (\method_exists($this->db, 'getWrappedConnection')) {
697
				$pdo = $this->db->/** @scrutinizer ignore-call */getWrappedConnection();
698
			}
699
700
			if (isset($pdo)) {
701
				$pdo->sqliteCreateFunction(
702
					'regexp',
703
					function ($pattern, $data, $delimiter = '~', $modifiers = 'isuS') {
704
						if (isset($pattern, $data) === true) {
705
							return (\preg_match(\sprintf('%1$s%2$s%1$s%3$s', $delimiter, $pattern, $modifiers), $data) > 0);
706
						}
707
						return null;
708
					}
709
				);
710
			}
711
		}
712
	}
713
714
	private function funcExistsInSqlite(string $funcName) : bool {
715
		// If the SQLite version is very old, it may not have the `pragma_function_list` table available. In such cases,
716
		// assume that the queried function doesn't exist. It doesn't really make any harm if that leads to registering
717
		// the same function again.
718
		try {
719
			$result = $this->execute('SELECT EXISTS(SELECT 1 FROM `pragma_function_list` WHERE `NAME` = ?)', [$funcName]);
720
			$row = $result->fetch();
721
			return (bool)\current($row);
722
		} catch (\Exception $e) {
723
			return false;
724
		}
725
	}
726
727
	/**
728
	 * Find an entity which has the same identity as the supplied entity.
729
	 * How the identity of the entity is defined, depends on the derived concrete class.
730
	 * @phpstan-param EntityType $entity
731
	 * @phpstan-return EntityType
732
	 */
733
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
734
}
735