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