Passed
Push — master ( 91b14a...fc89b7 )
by Pauli
02:49
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 - 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::Name, 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::Name, ?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 entity IDs grouped by the given parent entity IDs. Not applicable on all entity types.
228
	 * @param int[] $parentIds
229
	 * @return array like [parentId => childIds[]]; some parents may have an empty array of children
230
	 * @throws \DomainException if the entity type handled by this mapper doesn't have a parent relation
231
	 */
232
	public function findAllIdsByParentIds(string $userId, array $parentIds) : ?array {
233
		if ($this->parentIdColumn === null) {
234
			throw new \DomainException("Finding by parent is not applicable for the table {$this->getTableName()}");
235
		}
236
237
		$result = [];
238
		if (\count($parentIds) > 0) {
239
			$sql = "SELECT `id`, `{$this->parentIdColumn}` AS `parent_id` FROM `{$this->getTableName()}`
240
					WHERE `user_id` = ? AND `{$this->parentIdColumn}` IN " . $this->questionMarks(\count($parentIds));
241
			$params = \array_merge([$userId], $parentIds);
242
			$rows = $this->execute($sql, $params)->fetchAll();
243
244
			// ensure that the result contains also "parents" with no children and has the same order as $parentIds
245
			$result = \array_fill_keys($parentIds, []);
246
			foreach ($rows as $row) {
247
				$result[(int)$row['parent_id']][] = (int)$row['id'];
248
			}
249
		}	
250
251
		return $result;
252
	}
253
254
	/**
255
	 * Find all IDs and names of user's entities of this kind.
256
	 * Optionally, limit results based on a parent entity (not applicable for all entity types) or update/insert times or name
257
	 * @param bool $excludeChildless Exclude entities having no child-entities if applicable for this business layer (eg. artists without albums)
258
	 * @return array of arrays like ['id' => string, 'name' => ?string]
259
	 */
260
	public function findAllIdsAndNames(string $userId, ?int $parentId, ?int $limit=null, ?int $offset=null,
261
			?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null,
262
			bool $excludeChildless=false, ?string $name=null) : array {
263
		$sql = "SELECT `id`, `{$this->nameColumn}` AS `name` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
264
		$params = [$userId];
265
		if ($parentId !== null) {
266
			if ($this->parentIdColumn === null) {
267
				throw new \DomainException("The parentId filtering is not applicable for the table {$this->getTableName()}");
268
			} else {
269
				$sql .= " AND {$this->parentIdColumn} = ?";
270
				$params[] = $parentId;
271
			}
272
		}
273
274
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
275
		if (!empty($timestampConds)) {
276
			$sql .= " AND $timestampConds";
277
			$params = \array_merge($params, $timestampParams);
278
		}
279
280
		if ($excludeChildless) {
281
			$sql .= ' AND ' . $this->formatExcludeChildlessCondition();
282
		}
283
284
		if (!empty($name)) {
285
			[$nameCond, $nameParams] = $this->formatNameConditions($name, MatchMode::Substring);
286
			$sql .= " AND $nameCond";
287
			$params = \array_merge($params, $nameParams);
288
		}
289
290
		$sql .= ' ' . $this->formatSortingClause(SortBy::Name);
291
292
		if ($limit !== null) {
293
			$sql .= ' LIMIT ?';
294
			$params[] = $limit;
295
		}
296
		if ($offset !== null) {
297
			$sql .= ' OFFSET ?';
298
			$params[] = $offset;
299
		}
300
301
		$result = $this->execute($sql, $params);
302
303
		return $result->fetchAll();
304
	}
305
306
	/**
307
	 * Find IDs of all users owning any entities of this mapper
308
	 * @return string[]
309
	 */
310
	public function findAllUsers() : array {
311
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
312
		$result = $this->execute($sql);
313
314
		return $result->fetchAll(\PDO::FETCH_COLUMN);
315
	}
316
317
	/**
318
	 * Delete all entities with given IDs without specifying the user
319
	 * @param integer[] $ids  IDs of the entities to be deleted
320
	 */
321
	public function deleteById(array $ids) : void {
322
		$count = \count($ids);
323
		if ($count === 0) {
324
			return;
325
		}
326
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
327
	}
328
329
	/**
330
	 * Delete all entities matching the given SQL condition
331
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
332
	 * @param array $params SQL parameters for the condition
333
	 */
334
	protected function deleteByCond(string $condition, array $params) : void {
335
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
336
		$this->execute($sql, $params);
337
	}
338
339
	/**
340
	 * Delete all entities of the given user
341
	 */
342
	public function deleteAll(string $userId) : void {
343
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
344
		$this->execute($sql, [$userId]);
345
	}
346
347
	/**
348
	 * Tests if entity with given ID and user ID exists in the database
349
	 */
350
	public function exists(int $id, string $userId) : bool {
351
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
352
		$result = $this->execute($sql, [$id, $userId]);
353
		return $result->rowCount() > 0;
354
	}
355
356
	/**
357
	 * Count all entities of a user
358
	 */
359
	public function count(string $userId) : int {
360
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
361
		$result = $this->execute($sql, [$userId]);
362
		$row = $result->fetch();
363
		return \intval($row['count']);
364
	}
365
366
	/**
367
	 * {@inheritDoc}
368
	 * @see CompatibleMapper::insert()
369
	 * @phpstan-param EntityType $entity
370
	 * @phpstan-return EntityType
371
	 */
372
	public function insert(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
373
		$now = new \DateTime();
374
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
375
		$entity->setCreated($nowStr);
376
		$entity->setUpdated($nowStr);
377
378
		try {
379
			return parent::insert($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
380
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
381
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
382
		} catch (\OCP\DB\Exception $e) {
383
			// Nextcloud 21+
384
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
385
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
386
			} else {
387
				throw $e;
388
			}
389
		}
390
	}
391
392
	/**
393
	 * {@inheritDoc}
394
	 * @see CompatibleMapper::update()
395
	 * @phpstan-param EntityType $entity
396
	 * @phpstan-return EntityType
397
	 */
398
	public function update(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
399
		$now = new \DateTime();
400
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
401
		return parent::update($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
402
	}
403
404
	/**
405
	 * Insert an entity, or if an entity with the same identity already exists,
406
	 * update the existing entity.
407
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
408
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
409
	 * @return Entity The inserted or updated entity, containing also the id field
410
	 * @phpstan-param EntityType $entity
411
	 * @phpstan-return EntityType
412
	 */
413
	public function insertOrUpdate(Entity $entity) : Entity {
414
		try {
415
			return $this->insert($entity);
416
		} catch (UniqueConstraintViolationException $ex) {
417
			$existingEntity = $this->findUniqueEntity($entity);
418
			$entity->setId($existingEntity->getId());
419
			$entity->setCreated($existingEntity->getCreated());
420
			return $this->update($entity);
421
		}
422
	}
423
424
	/**
425
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
426
	 * a new entity is inserted.
427
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
428
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
429
	 * @return Entity The inserted or updated entity, containing also the id field
430
	 * @phpstan-param EntityType $entity
431
	 * @phpstan-return EntityType
432
	 */
433
	public function updateOrInsert(Entity $entity) : Entity {
434
		try {
435
			$existingEntity = $this->findUniqueEntity($entity);
436
			$entity->setId($existingEntity->getId());
437
			return $this->update($entity);
438
		} catch (DoesNotExistException $ex) {
439
			try {
440
				return $this->insert($entity);
441
			} catch (UniqueConstraintViolationException $ex) {
442
				// the conflicting entry didn't exist an eyeblink ago but now it does
443
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
444
				//    update happens last; cancel this update
445
				return $this->findUniqueEntity($entity);
446
			}
447
		}
448
	}
449
450
	/**
451
	 * Set the "starred" column of the given entities
452
	 * @param \DateTime|null $date
453
	 * @param integer[] $ids
454
	 * @param string $userId
455
	 * @return int number of modified entities
456
	 */
457
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
458
		$count = \count($ids);
459
		if (!empty($date)) {
460
			$date = $date->format(self::SQL_DATE_FORMAT);
461
		}
462
463
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
464
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
465
		$params = \array_merge([$date], $ids, [$userId]);
466
		return $this->execute($sql, $params)->rowCount();
467
	}
468
469
	public function latestInsertTime(string $userId) : ?\DateTime {
470
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
471
		$result = $this->execute($sql, [$userId]);
472
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
473
474
		return ($createdTime === null) ? null : new \DateTime($createdTime);
475
	}
476
477
	public function latestUpdateTime(string $userId) : ?\DateTime {
478
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
479
		$result = $this->execute($sql, [$userId]);
480
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
481
482
		return ($createdTime === null) ? null : new \DateTime($createdTime);
483
	}
484
485
	/**
486
	 * helper creating a string like '(?,?,?)' with the specified number of elements
487
	 */
488
	protected function questionMarks(int $count) : string {
489
		$questionMarks = [];
490
		for ($i = 0; $i < $count; $i++) {
491
			$questionMarks[] = '?';
492
		}
493
		return '(' . \implode(',', $questionMarks) . ')';
494
	}
495
496
	/**
497
	 * Build a SQL SELECT statement which selects all entities of the given user,
498
	 * and optionally applies other conditions, too.
499
	 * This is built upon `selectEntities` which may be overridden by the derived class.
500
	 * @param string|null $condition Optional extra condition. This will get automatically
501
	 *                               prefixed with ' AND ', so don't include that.
502
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
503
	 *                               the conditions in the SQL statement
504
	 */
505
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
506
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
507
508
		if (!empty($condition)) {
509
			$allConditions .= " AND ($condition)";
510
		}
511
512
		return $this->selectEntities($allConditions, $extension);
513
	}
514
515
	/**
516
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
517
	 * The derived class may override this if necessary.
518
	 * @param string $condition This will get automatically prefixed with ' WHERE '
519
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
520
	 *                               the conditions in the SQL statement
521
	 */
522
	protected function selectEntities(string $condition, string $extension=null) : string {
523
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
524
	}
525
526
	/**
527
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
528
	 */
529
	protected function formatNameConditions(?string $name, int $matchMode) : array {
530
		$params = [];
531
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
532
		if ($name === null) {
533
			$condition = "$nameCol IS NULL";
534
		} else {
535
			if ($matchMode === MatchMode::Exact) {
536
				$condition = "LOWER($nameCol) = LOWER(?)";
537
			} else {
538
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
539
			}
540
			if ($matchMode === MatchMode::Substring) {
541
				$params[] = self::prepareSubstringSearchPattern($name);
542
			} else {
543
				$params[] = $name;
544
			}
545
		}
546
		return [$condition, $params];
547
	}
548
549
	/**
550
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
551
	 */
552
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
553
		$conditions = [];
554
		$params = [];
555
556
		if (!empty($createdMin)) {
557
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
558
			$params[] = $createdMin;
559
		}
560
561
		if (!empty($createdMax)) {
562
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
563
			$params[] = $createdMax;
564
		}
565
566
		if (!empty($updatedMin)) {
567
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
568
			$params[] = $updatedMin;
569
		}
570
571
		if (!empty($updatedMax)) {
572
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
573
			$params[] = $updatedMax;
574
		}
575
576
		return [\implode(' AND ', $conditions), $params];
577
	}
578
579
	/**
580
	 * Convert given sorting condition to an SQL clause. Derived class may override this if necessary.
581
	 * @param int $sortBy One of the constants defined in the class SortBy
582
	 */
583
	protected function formatSortingClause(int $sortBy, bool $invertSort = false) : ?string {
584
		$table = $this->getTableName();
585
		if ($sortBy == SortBy::Name) {
586
			$dir = $invertSort ? 'DESC' : 'ASC';
587
			return "ORDER BY LOWER(`$table`.`{$this->nameColumn}`) $dir";
588
		} elseif ($sortBy == SortBy::Newest) {
589
			$dir = $invertSort ? 'ASC' : 'DESC';
590
			return "ORDER BY `$table`.`id` $dir"; // abuse the fact that IDs are ever-incrementing values
591
		} elseif ($sortBy == SortBy::Rating) {
592
			if (\property_exists($this->entityClass, 'rating')) {
593
				$dir = $invertSort ? 'ASC' : 'DESC';
594
				return "ORDER BY `$table`.`rating` $dir";
595
			} else {
596
				return null;
597
			}
598
		} else {
599
			return null;
600
		}
601
	}
602
603
	/**
604
	 * Return an SQL condition to exclude entities having no children. The default implementation is empty
605
	 * and derived classes may override this if applicable.
606
	 */
607
	protected function formatExcludeChildlessCondition() : string {
608
		return '1=1';
609
	}
610
611
	protected static function prepareSubstringSearchPattern(string $input) : string {
612
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
613
		// while the default interpretation of multipart string is that each of the parts can be found
614
		// separately as substring in the given order
615
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
616
			// remove the quotation
617
			$pattern = \substr($input, 1, -1);
618
		} else {
619
			// split to parts by whitespace
620
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
621
			// glue the parts back together with a wildcard charater
622
			$pattern = \implode('%', $parts);
623
		}
624
		return "%$pattern%";
625
	}
626
627
	/**
628
	 * Format SQL operator, conversion, and parameter matching the given advanced search operator.
629
	 * @return array like ['op' => string, 'conv' => string, 'param' => string|int|null]
630
	 */
631
	protected function advFormatSqlOperator(string $ruleOperator, string $ruleInput, string $userId) {
632
		if ($this->dbType == 'sqlite3' && ($ruleOperator == 'regexp' || $ruleOperator == 'notregexp')) {
633
			$this->registerRegexpFuncForSqlite();
634
		}
635
636
		$pgsql = ($this->dbType == 'pgsql');
637
638
		switch ($ruleOperator) {
639
			case 'contain':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput%"];
640
			case 'notcontain':	return ['op' => 'NOT LIKE',								'conv' => 'LOWER',		'param' => "%$ruleInput%"];
641
			case 'start':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "$ruleInput%"];
642
			case 'end':			return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput"];
643
			case 'is':			return ['op' => '=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
644
			case 'isnot':		return ['op' => '!=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
645
			case 'sounds':		return ['op' => '=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
646
			case 'notsounds':	return ['op' => '!=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
647
			case 'regexp':		return ['op' => $pgsql ? '~' : 'REGEXP',				'conv' => 'LOWER',		'param' => $ruleInput];
648
			case 'notregexp':	return ['op' => $pgsql ? '!~' : 'NOT REGEXP',			'conv' => 'LOWER',		'param' => $ruleInput];
649
			case 'true':		return ['op' => 'IS NOT NULL',							'conv' => '',			'param' => null];
650
			case 'false':		return ['op' => 'IS NULL',								'conv' => '',			'param' => null];
651
			case 'equal':		return ['op' => '',										'conv' => '',			'param' => $ruleInput];
652
			case 'ne':			return ['op' => 'NOT',									'conv' => '',			'param' => $ruleInput];
653
			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
654
			case 'before':		return ['op' => '<',									'conv' => '',			'param' => $ruleInput];
655
			case 'after':		return ['op' => '>',									'conv' => '',			'param' => $ruleInput];
656
			default:			return ['op' => self::sanitizeNumericOp($ruleOperator),	'conv' => '',			'param' => (int)$ruleInput]; // all numerical operators fall here
657
		}
658
	}
659
660
	protected static function sanitizeNumericOp($comparisonOperator) {
661
		if (\in_array($comparisonOperator, ['>=', '<=', '=', '!=', '>', '<'])) {
662
			return $comparisonOperator;
663
		} else {
664
			throw new \DomainException("Bad advanced search operator: $comparisonOperator");
665
		}
666
	}
667
668
	/**
669
	 * Format SQL condition matching the given advanced search rule and SQL operator.
670
	 * Derived classes should override this to provide support for table-specific rules.
671
	 * @param string $rule	Identifier of the property which is the target of the SQL condition. The identifiers match the Ampache API specification.
672
	 * @param string $sqlOp	SQL (comparison) operator to be used
673
	 * @param string $conv	SQL conversion function to be applied on the target column and the parameter (e.g. "LOWER")
674
	 * @return string SQL condition statement to be used in the "WHERE" clause
675
	 */
676
	protected function advFormatSqlCondition(string $rule, string $sqlOp, string $conv) : string {
677
		$table = $this->getTableName();
678
		$nameCol = $this->nameColumn;
679
680
		switch ($rule) {
681
			case 'title':			return "$conv(`$table`.`$nameCol`) $sqlOp $conv(?)";
682
			case 'my_flagged':		return "`$table`.`starred` $sqlOp";
683
			case 'favorite':		return "($conv(`$table`.`$nameCol`) $sqlOp $conv(?) AND `$table`.`starred` IS NOT NULL)"; // title search among flagged
684
			case 'myrating':		// fall through, we provide no access to other people's data
685
			case 'rating':			return "`$table`.`rating` $sqlOp ?";
686
			case 'added':			return "`$table`.`created` $sqlOp ?";
687
			case 'updated':			return "`$table`.`updated` $sqlOp ?";
688
			case 'mbid':			return "`$table`.`mbid` $sqlOp ?";
689
			case 'recent_added':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `created` DESC LIMIT $sqlOp) mysqlhack)";
690
			case 'recent_updated':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `updated` DESC LIMIT $sqlOp) mysqlhack)";
691
			default:				throw new \DomainException("Rule '$rule' not supported on this entity type");
692
		}
693
	}
694
695
	protected function sqlConcat(string ...$parts) : string {
696
		if ($this->dbType == 'sqlite3') {
697
			return '(' . \implode(' || ', $parts) . ')';
698
		} else {
699
			return 'CONCAT(' . \implode(', ', $parts) . ')';
700
		}
701
	}
702
703
	protected function sqlGroupConcat(string $column) : string {
704
		if ($this->dbType == 'pgsql') {
705
			return "string_agg($column, ',')";
706
		} else {
707
			return "GROUP_CONCAT($column)";
708
		}
709
	}
710
711
	protected function sqlCoalesce(string $value, string $replacement) : string {
712
		if ($this->dbType == 'pgsql') {
713
			return "COALESCE($value, $replacement)";
714
		} else {
715
			return "IFNULL($value, $replacement)";
716
		}
717
	}
718
719
	/**
720
	 * SQLite connects the operator REGEXP to the function of the same name but doesn't ship the function itself.
721
	 * Hence, we need to register it as a user-function. This happens by creating a suitable wrapper for the PHP
722
	 * native preg_match function. Based on https://stackoverflow.com/a/18484596.
723
	 */
724
	private function registerRegexpFuncForSqlite() {
725
		// skip if the function already exists
726
		if (!$this->funcExistsInSqlite('regexp')) {
727
			// We need to use a private interface here to drill down to the native DB connection. The interface is
728
			// slightly different on NC compared to OC.
729
			if (\method_exists($this->db, 'getInner')) {
730
				$connection = $this->db->/** @scrutinizer ignore-call */getInner()->getWrappedConnection();
731
				$pdo = $connection->getWrappedConnection();
732
			} else if (\method_exists($this->db, 'getWrappedConnection')) {
733
				$pdo = $this->db->/** @scrutinizer ignore-call */getWrappedConnection();
734
			}
735
736
			if (isset($pdo)) {
737
				$pdo->sqliteCreateFunction(
738
					'regexp',
739
					function ($pattern, $data, $delimiter = '~', $modifiers = 'isuS') {
740
						if (isset($pattern, $data) === true) {
741
							return (\preg_match(\sprintf('%1$s%2$s%1$s%3$s', $delimiter, $pattern, $modifiers), $data) > 0);
742
						}
743
						return null;
744
					}
745
				);
746
			}
747
		}
748
	}
749
750
	private function funcExistsInSqlite(string $funcName) : bool {
751
		// If the SQLite version is very old, it may not have the `pragma_function_list` table available. In such cases,
752
		// assume that the queried function doesn't exist. It doesn't really make any harm if that leads to registering
753
		// the same function again.
754
		try {
755
			$result = $this->execute('SELECT EXISTS(SELECT 1 FROM `pragma_function_list` WHERE `NAME` = ?)', [$funcName]);
756
			$row = $result->fetch();
757
			return (bool)\current($row);
758
		} catch (\Exception $e) {
759
			return false;
760
		}
761
	}
762
763
	/**
764
	 * Find an entity which has the same identity as the supplied entity.
765
	 * How the identity of the entity is defined, depends on the derived concrete class.
766
	 * @phpstan-param EntityType $entity
767
	 * @phpstan-return EntityType
768
	 */
769
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
770
}
771