Passed
Push — master ( f2aded...94d37f )
by Pauli
03:52
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 - 2025
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\Mapper;
21
use OCA\Music\AppFramework\Db\UniqueConstraintViolationException;
22
use OCA\Music\Utility\StringUtil;
23
24
/**
25
 * Common base class for data access classes of the Music app
26
 * 
27
 * @method Entity findEntity(string $sql, array $params)
28
 * @method Entity[] findEntities(string $sql, array $params, ?int $limit=null, ?int $offset=null)
29
 * @method Entity delete(Entity $entity)
30
 * 
31
 * @phpstan-template EntityType of Entity
32
 * @phpstan-extends Mapper<EntityType>
33
 * @phpstan-method EntityType findEntity(string $sql, array $params)
34
 * @phpstan-method EntityType[] findEntities(string $sql, array $params, ?int $limit=null, ?int $offset=null)
35
 * @phpstan-method EntityType delete(EntityType $entity)
36
 */
37
abstract class BaseMapper extends Mapper {
38
	const SQL_DATE_FORMAT = 'Y-m-d H:i:s.v';
39
40
	protected string $nameColumn;
41
	protected ?array $uniqueColumns;
42
	protected ?string $parentIdColumn;
43
	protected string $dbType; // database type 'mysql', 'pgsql', or 'sqlite3'
44
45
	/**
46
	 * @param ?string[] $uniqueColumns List of column names composing the unique constraint of the table. Null if there's no unique index.
47
	 * @phpstan-param class-string<EntityType> $entityClass
48
	 */
49
	public function __construct(
50
			IDBConnection $db, IConfig $config, string $tableName, string $entityClass,
51
			string $nameColumn, ?array $uniqueColumns=null, ?string $parentIdColumn=null) {
52
		parent::__construct($db, $tableName, $entityClass);
53
		$this->nameColumn = $nameColumn;
54
		$this->uniqueColumns = $uniqueColumns;
55
		$this->parentIdColumn = $parentIdColumn;
56
		$this->dbType = $config->getSystemValue('dbtype');
57
	}
58
59
	/**
60
	 * Create an empty object of the entity class bound to this mapper
61
	 * @phpstan-return EntityType
62
	 */
63
	public function createEntity() : Entity {
64
		return new $this->entityClass();
65
	}
66
67
	public function unprefixedTableName() : string {
68
		return \str_replace('*PREFIX*', '', $this->getTableName());
69
	}
70
71
	/**
72
	 * Find a single entity by id and user_id
73
	 * @throws DoesNotExistException if the entity does not exist
74
	 * @throws MultipleObjectsReturnedException if more than one entity exists
75
	 * @phpstan-return EntityType
76
	 */
77
	public function find(int $id, string $userId) : Entity {
78
		$sql = $this->selectUserEntities("`{$this->getTableName()}`.`id` = ?");
79
		return $this->findEntity($sql, [$userId, $id]);
80
	}
81
82
	/**
83
	 * Find all entities matching the given IDs. Specifying the owning user is optional.
84
	 * @param integer[] $ids  IDs of the entities to be found
85
	 * @param string|null $userId
86
	 * @return Entity[]
87
	 * @phpstan-return EntityType[]
88
	 */
89
	public function findById(array $ids, ?string $userId=null) : array {
90
		$count = \count($ids);
91
		$condition = "`{$this->getTableName()}`.`id` IN ". $this->questionMarks($count);
92
93
		if (empty($userId)) {
94
			$sql = $this->selectEntities($condition);
95
		} else {
96
			$sql = $this->selectUserEntities($condition);
97
			$ids = \array_merge([$userId], $ids);
98
		}
99
100
		return $this->findEntities($sql, $ids);
101
	}
102
103
	/**
104
	 * Find all user's entities
105
	 * @param string|null $createdMin Optional minimum `created` timestamp.
106
	 * @param string|null $createdMax Optional maximum `created` timestamp.
107
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
108
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
109
	 * @return Entity[]
110
	 * @phpstan-return EntityType[]
111
	 */
112
	public function findAll(string $userId, int $sortBy=SortBy::Name, ?int $limit=null, ?int $offset=null,
113
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
114
		$sorting = $this->formatSortingClause($sortBy);
115
		[$condition, $params] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
116
		$sql = $this->selectUserEntities($condition, $sorting);
117
		\array_unshift($params, $userId);
118
		return $this->findEntities($sql, $params, $limit, $offset);
119
	}
120
121
	/**
122
	 * Find all user's entities matching the given name
123
	 * @param string|null $createdMin Optional minimum `created` timestamp.
124
	 * @param string|null $createdMax Optional maximum `created` timestamp.
125
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
126
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
127
	 * @return Entity[]
128
	 * @phpstan-return EntityType[]
129
	 */
130
	public function findAllByName(
131
		?string $name, string $userId, int $matchMode=MatchMode::Exact, ?int $limit=null, ?int $offset=null,
132
		?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
133
134
		$params = [$userId];
135
136
		[$condition, $nameParams] = $this->formatNameConditions($name, $matchMode);
137
		$params = \array_merge($params, $nameParams);
138
139
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
140
		if (!empty($timestampConds)) {
141
			$condition .= ' AND ' . $timestampConds;
142
			$params = \array_merge($params, $timestampParams);
143
		}
144
145
		$sql = $this->selectUserEntities($condition, $this->formatSortingClause(SortBy::Name));
146
147
		return $this->findEntities($sql, $params, $limit, $offset);
148
	}
149
150
	/**
151
	 * Find all user's starred entities. It is safe to call this also on entity types
152
	 * not supporting starring in which case an empty array will be returned.
153
	 * @return Entity[]
154
	 * @phpstan-return EntityType[]
155
	 */
156
	public function findAllStarred(string $userId, ?int $limit=null, ?int $offset=null) : array {
157
		if (\property_exists($this->entityClass, 'starred')) {
158
			$sql = $this->selectUserEntities(
159
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
160
				$this->formatSortingClause(SortBy::Name));
161
			return $this->findEntities($sql, [$userId], $limit, $offset);
162
		} else {
163
			return [];
164
		}
165
	}
166
167
	/**
168
	 * Find IDSs of all user's starred entities. It is safe to call this also on entity types
169
	 * not supporting starring in which case an empty array will be returned.
170
	 * @return int[]
171
	 */
172
	public function findAllStarredIds(string $userId) : array {
173
		if (\property_exists($this->entityClass, 'starred')) {
174
			$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `starred` IS NOT NULL AND `user_id` = ?";
175
			$result = $this->execute($sql, [$userId]);
176
	
177
			return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
178
		} else {
179
			return [];
180
		}
181
	}
182
183
	/**
184
	 * Find all entities with user-given rating 1-5
185
	 * @return Entity[]
186
	 * @phpstan-return EntityType[]
187
	 */
188
	public function findAllRated(string $userId, ?int $limit=null, ?int $offset=null) : array {
189
		if (\property_exists($this->entityClass, 'rating')) {
190
			$sql = $this->selectUserEntities(
191
				"`{$this->getTableName()}`.`rating` > 0",
192
				$this->formatSortingClause(SortBy::Rating));
193
			return $this->findEntities($sql, [$userId], $limit, $offset);
194
		} else {
195
			return [];
196
		}
197
	}
198
199
	/**
200
	 * Find all entities matching multiple criteria, as needed for the Ampache API method `advanced_search`
201
	 * @param string $conjunction Operator to use between the rules, either 'and' or 'or'
202
	 * @param array $rules Array of arrays: [['rule' => string, 'operator' => string, 'input' => string], ...]
203
	 * 				Here, 'rule' has dozens of possible values depending on the business layer in question
204
	 * 				(see https://ampache.org/api/api-advanced-search#available-search-rules, alias names not supported here),
205
	 * 				'operator' is one of 
206
	 * 				['contain', 'notcontain', 'start', 'end', 'is', 'isnot', 'sounds', 'notsounds', 'regexp', 'notregexp',
207
	 * 				 '>=', '<=', '=', '!=', '>', '<', 'before', 'after', 'true', 'false', 'equal', 'ne', 'limit'],
208
	 * 				'input' is the right side value of the 'operator' (disregarded for the operators 'true' and 'false')
209
	 * @return Entity[]
210
	 * @phpstan-return EntityType[]
211
	 */
212
	public function findAllAdvanced(string $conjunction, array $rules, string $userId, int $sortBy=SortBy::Name, ?int $limit=null, ?int $offset=null) : array {
213
		$sqlConditions = [];
214
		$sqlParams = [$userId];
215
216
		foreach ($rules as $rule) {
217
			list('op' => $sqlOp, 'conv' => $sqlConv, 'param' => $param) = $this->advFormatSqlOperator($rule['operator'], (string)$rule['input'], $userId);
218
			$cond = $this->advFormatSqlCondition($rule['rule'], $sqlOp, $sqlConv);
219
			$sqlConditions[] = $cond;
220
			// On some conditions, the parameter may need to be repeated several times
221
			$paramCount = \substr_count($cond, '?');
222
			for ($i = 0; $i < $paramCount; ++$i) {
223
				$sqlParams[] = $param;
224
			}
225
		}
226
		$sqlConditions = \implode(" $conjunction ", $sqlConditions);
227
228
		$sql = $this->selectUserEntities($sqlConditions, $this->formatSortingClause($sortBy));
229
		return $this->findEntities($sql, $sqlParams, $limit, $offset);
230
	}
231
232
	/**
233
	 * Optionally, limit to given IDs which may be used to check the validity of those IDs.
234
	 * @return int[]
235
	 */
236
	public function findAllIds(string $userId, ?array $ids = null) : array {
237
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
238
		$params = [$userId];
239
240
		if ($ids !== null) {
241
			$sql .= ' AND `id` IN ' . $this->questionMarks(\count($ids));
242
			$params = \array_merge($params, $ids);
243
		}
244
245
		$result = $this->execute($sql, $params);
246
247
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
248
	}
249
250
	/**
251
	 * Find all entity IDs grouped by the given parent entity IDs. Not applicable on all entity types.
252
	 * @param int[] $parentIds
253
	 * @return array<int, int[]> like [parentId => childIds[]]; some parents may have an empty array of children
254
	 * @throws \DomainException if the entity type handled by this mapper doesn't have a parent relation
255
	 */
256
	public function findAllIdsByParentIds(string $userId, array $parentIds) : ?array {
257
		if ($this->parentIdColumn === null) {
258
			throw new \DomainException("Finding by parent is not applicable for the table {$this->getTableName()}");
259
		}
260
261
		$result = [];
262
		if (\count($parentIds) > 0) {
263
			$sql = "SELECT `id`, `{$this->parentIdColumn}` AS `parent_id` FROM `{$this->getTableName()}`
264
					WHERE `user_id` = ? AND `{$this->parentIdColumn}` IN " . $this->questionMarks(\count($parentIds));
265
			$params = \array_merge([$userId], $parentIds);
266
			$rows = $this->execute($sql, $params)->fetchAll();
267
268
			// ensure that the result contains also "parents" with no children and has the same order as $parentIds
269
			$result = \array_fill_keys($parentIds, []);
270
			foreach ($rows as $row) {
271
				$result[(int)$row['parent_id']][] = (int)$row['id'];
272
			}
273
		}	
274
275
		return $result;
276
	}
277
278
	/**
279
	 * Find all IDs and names of user's entities of this kind.
280
	 * Optionally, limit results based on a parent entity (not applicable for all entity types) or update/insert times or name
281
	 * @param bool $excludeChildless Exclude entities having no child-entities if applicable for this business layer (eg. artists without albums)
282
	 * @return array of arrays like ['id' => string, 'name' => ?string]
283
	 */
284
	public function findAllIdsAndNames(string $userId, ?int $parentId, ?int $limit=null, ?int $offset=null,
285
			?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null,
286
			bool $excludeChildless=false, ?string $name=null) : array {
287
		$sql = "SELECT `id`, `{$this->nameColumn}` AS `name` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
288
		$params = [$userId];
289
		if ($parentId !== null) {
290
			if ($this->parentIdColumn === null) {
291
				throw new \DomainException("The parentId filtering is not applicable for the table {$this->getTableName()}");
292
			} else {
293
				$sql .= " AND {$this->parentIdColumn} = ?";
294
				$params[] = $parentId;
295
			}
296
		}
297
298
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
299
		if (!empty($timestampConds)) {
300
			$sql .= " AND $timestampConds";
301
			$params = \array_merge($params, $timestampParams);
302
		}
303
304
		if ($excludeChildless) {
305
			$sql .= ' AND ' . $this->formatExcludeChildlessCondition();
306
		}
307
308
		if (!empty($name)) {
309
			[$nameCond, $nameParams] = $this->formatNameConditions($name, MatchMode::Substring);
310
			$sql .= " AND $nameCond";
311
			$params = \array_merge($params, $nameParams);
312
		}
313
314
		$sql .= ' ' . $this->formatSortingClause(SortBy::Name);
315
316
		if ($limit !== null) {
317
			$sql .= ' LIMIT ?';
318
			$params[] = $limit;
319
		}
320
		if ($offset !== null) {
321
			$sql .= ' OFFSET ?';
322
			$params[] = $offset;
323
		}
324
325
		$result = $this->execute($sql, $params);
326
327
		return $result->fetchAll();
328
	}
329
330
	/**
331
	 * Find IDs of all users owning any entities of this mapper
332
	 * @return string[]
333
	 */
334
	public function findAllUsers() : array {
335
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
336
		$result = $this->execute($sql);
337
338
		return $result->fetchAll(\PDO::FETCH_COLUMN);
339
	}
340
341
	/**
342
	 * Delete all entities with given IDs without specifying the user
343
	 * @param integer[] $ids  IDs of the entities to be deleted
344
	 */
345
	public function deleteById(array $ids) : void {
346
		$count = \count($ids);
347
		if ($count === 0) {
348
			return;
349
		}
350
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
351
	}
352
353
	/**
354
	 * Delete all entities matching the given SQL condition
355
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
356
	 * @param array $params SQL parameters for the condition
357
	 */
358
	protected function deleteByCond(string $condition, array $params) : void {
359
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
360
		$this->execute($sql, $params);
361
	}
362
363
	/**
364
	 * Delete all entities of the given user
365
	 */
366
	public function deleteAll(string $userId) : void {
367
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
368
		$this->execute($sql, [$userId]);
369
	}
370
371
	/**
372
	 * Tests if entity with given ID and user ID exists in the database
373
	 */
374
	public function exists(int $id, string $userId) : bool {
375
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
376
		$result = $this->execute($sql, [$id, $userId]);
377
		$row = $result->fetch();
378
		return (bool)$row;
379
	}
380
381
	/**
382
	 * Count all entities of a user
383
	 */
384
	public function count(string $userId) : int {
385
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
386
		$result = $this->execute($sql, [$userId]);
387
		$row = $result->fetch();
388
		return \intval($row['count']);
389
	}
390
391
	/**
392
	 * Get the largest entity ID of the user
393
	 */
394
	public function maxId(string $userId) : ?int {
395
		$sql = "SELECT MAX(`id`) AS max_id FROM `{$this->getTableName()}` WHERE `user_id` = ?";
396
		$result = $this->execute($sql, [$userId]);
397
		$row = $result->fetch();
398
		$max = $row['max_id'];
399
		return $max === null ? null : (int)$max;
400
	}
401
402
	/**
403
	 * {@inheritDoc}
404
	 * @see Mapper::insert()
405
	 * @phpstan-param EntityType $entity
406
	 * @phpstan-return EntityType
407
	 */
408
	public function insert(\OCP\AppFramework\Db\Entity $entity) : Entity {
409
		if (!($entity instanceof Entity)) {
410
			// Because of Liskov Substitution Principle, this class must technically accept any platform Entity.
411
			// However, the function only works correctly for our own Entity type. The return type can be narrowed
412
			// from the parent, thanks to the covariance rules of PHP 7.4 and later.
413
			throw new \BadMethodCallException('$entity must be of type ' . Entity::class);
414
		}
415
416
		$now = new \DateTime();
417
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
418
		$entity->setCreated($nowStr);
419
		$entity->setUpdated($nowStr);
420
421
		try {
422
			return parent::insert($entity);
423
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
424
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
425
		} catch (\OCP\DB\Exception $e) {
426
			// Nextcloud 21+
427
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
428
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
429
			} else {
430
				throw $e;
431
			}
432
		}
433
	}
434
435
	/**
436
	 * {@inheritDoc}
437
	 * @see Mapper::update()
438
	 * @phpstan-param EntityType $entity
439
	 * @phpstan-return EntityType
440
	 */
441
	public function update(\OCP\AppFramework\Db\Entity $entity) : Entity {
442
		if (!($entity instanceof Entity)) {
443
			// Because of Liskov Substitution Principle, this class must technically accept any platform Entity.
444
			// However, the function only works correctly for our own Entity type. The return type can be narrowed
445
			// from the parent, thanks to the covariance rules of PHP 7.4 and later.
446
			throw new \BadMethodCallException('$entity must be of type ' . Entity::class);
447
		}
448
449
		$now = new \DateTime();
450
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
451
		return parent::update($entity);
452
	}
453
454
	/**
455
	 * Insert an entity, or if an entity with the same identity already exists,
456
	 * update the existing entity.
457
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
458
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
459
	 * @return Entity The inserted or updated entity, containing also the id field
460
	 * @phpstan-param EntityType $entity
461
	 * @phpstan-return EntityType
462
	 */
463
	public function insertOrUpdate(Entity $entity) : Entity {
464
		try {
465
			return $this->insert($entity);
466
		} catch (UniqueConstraintViolationException $ex) {
467
			$existingId = $this->findIdOfConflict($entity);
468
			$entity->setId($existingId);
469
			// The previous call to $this->insert has set the `created` property of $entity.
470
			// Set it again using the data from the existing entry.
471
			$entity->setCreated($this->getCreated($existingId));
472
			return $this->update($entity);
473
		}
474
	}
475
476
	/**
477
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
478
	 * a new entity is inserted.
479
	 * Note: The functions insertOrUpdate and updateOrInsert get the exactly same thing done. The only difference is
480
	 * that the former is optimized for cases where the entity doesn't exist and the latter for cases where it does exist.
481
	 * @param Entity $entity
482
	 * @return Entity The inserted or updated entity, containing also the id field
483
	 * @phpstan-param EntityType $entity
484
	 * @phpstan-return EntityType
485
	 */
486
	public function updateOrInsert(Entity $entity) : Entity {
487
		try {
488
			$existingId = $this->findIdOfConflict($entity);
489
			$entity->setId($existingId);
490
			return $this->update($entity);
491
		} catch (DoesNotExistException $ex) {
492
			return $this->insertOrUpdate($entity);
493
		}
494
	}
495
496
	/**
497
	 * Set the "starred" column of the given entities
498
	 * @param \DateTime|null $date
499
	 * @param integer[] $ids
500
	 * @param string $userId
501
	 * @return int number of modified entities
502
	 */
503
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
504
		$count = \count($ids);
505
		if (!empty($date)) {
506
			$date = $date->format(self::SQL_DATE_FORMAT);
507
		}
508
509
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
510
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
511
		$params = \array_merge([$date], $ids, [$userId]);
512
		return $this->execute($sql, $params)->rowCount();
513
	}
514
515
	public function latestInsertTime(string $userId) : ?\DateTime {
516
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
517
		$result = $this->execute($sql, [$userId]);
518
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
519
520
		return ($createdTime === null) ? null : new \DateTime($createdTime);
521
	}
522
523
	public function latestUpdateTime(string $userId) : ?\DateTime {
524
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
525
		$result = $this->execute($sql, [$userId]);
526
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
527
528
		return ($createdTime === null) ? null : new \DateTime($createdTime);
529
	}
530
531
	/**
532
	 * helper creating a string like '(?,?,?)' with the specified number of elements
533
	 */
534
	protected function questionMarks(int $count) : string {
535
		$questionMarks = [];
536
		for ($i = 0; $i < $count; $i++) {
537
			$questionMarks[] = '?';
538
		}
539
		return '(' . \implode(',', $questionMarks) . ')';
540
	}
541
542
	/**
543
	 * Build a SQL SELECT statement which selects all entities of the given user,
544
	 * and optionally applies other conditions, too.
545
	 * This is built upon `selectEntities` which may be overridden by the derived class.
546
	 * @param string|null $condition Optional extra condition. This will get automatically
547
	 *                               prefixed with ' AND ', so don't include that.
548
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
549
	 *                               the conditions in the SQL statement
550
	 */
551
	protected function selectUserEntities(?string $condition=null, ?string $extension=null) : string {
552
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
553
554
		if (!empty($condition)) {
555
			$allConditions .= " AND ($condition)";
556
		}
557
558
		return $this->selectEntities($allConditions, $extension);
559
	}
560
561
	/**
562
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
563
	 * The derived class may override this if necessary.
564
	 * @param string $condition This will get automatically prefixed with ' WHERE '
565
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
566
	 *                               the conditions in the SQL statement
567
	 */
568
	protected function selectEntities(string $condition, ?string $extension=null) : string {
569
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
570
	}
571
572
	/**
573
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
574
	 */
575
	protected function formatNameConditions(?string $name, int $matchMode) : array {
576
		$params = [];
577
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
578
		if ($name === null) {
579
			$condition = "$nameCol IS NULL";
580
		} else {
581
			if ($matchMode === MatchMode::Exact) {
582
				$condition = "LOWER($nameCol) = LOWER(?)";
583
			} else {
584
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
585
			}
586
			if ($matchMode === MatchMode::Substring) {
587
				$params[] = self::prepareSubstringSearchPattern($name);
588
			} else {
589
				$params[] = $name;
590
			}
591
		}
592
		return [$condition, $params];
593
	}
594
595
	/**
596
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
597
	 */
598
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
599
		$conditions = [];
600
		$params = [];
601
602
		if (!empty($createdMin)) {
603
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
604
			$params[] = $createdMin;
605
		}
606
607
		if (!empty($createdMax)) {
608
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
609
			$params[] = $createdMax;
610
		}
611
612
		if (!empty($updatedMin)) {
613
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
614
			$params[] = $updatedMin;
615
		}
616
617
		if (!empty($updatedMax)) {
618
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
619
			$params[] = $updatedMax;
620
		}
621
622
		return [\implode(' AND ', $conditions), $params];
623
	}
624
625
	/**
626
	 * Convert given sorting condition to an SQL clause. Derived class may override this if necessary.
627
	 * @param int $sortBy One of the constants defined in the class SortBy
628
	 */
629
	protected function formatSortingClause(int $sortBy, bool $invertSort = false) : ?string {
630
		$table = $this->getTableName();
631
		if ($sortBy == SortBy::Name) {
632
			$dir = $invertSort ? 'DESC' : 'ASC';
633
			return "ORDER BY LOWER(`$table`.`{$this->nameColumn}`) $dir";
634
		} elseif ($sortBy == SortBy::Newest) {
635
			$dir = $invertSort ? 'ASC' : 'DESC';
636
			return "ORDER BY `$table`.`id` $dir"; // abuse the fact that IDs are ever-incrementing values
637
		} elseif ($sortBy == SortBy::Rating) {
638
			if (\property_exists($this->entityClass, 'rating')) {
639
				$dir = $invertSort ? 'ASC' : 'DESC';
640
				return "ORDER BY `$table`.`rating` $dir";
641
			} else {
642
				return null;
643
			}
644
		} else {
645
			return null;
646
		}
647
	}
648
649
	/**
650
	 * Return an SQL condition to exclude entities having no children. The default implementation is empty
651
	 * and derived classes may override this if applicable.
652
	 */
653
	protected function formatExcludeChildlessCondition() : string {
654
		return '1=1';
655
	}
656
657
	protected static function prepareSubstringSearchPattern(string $input) : string {
658
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
659
		// while the default interpretation of multipart string is that each of the parts can be found
660
		// separately as substring in the given order
661
		if (StringUtil::startsWith($input, '"') && StringUtil::endsWith($input, '"')) {
662
			// remove the quotation
663
			$pattern = \substr($input, 1, -1);
664
		} else {
665
			// split to parts by whitespace
666
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
667
			// glue the parts back together with a wildcard character
668
			$pattern = \implode('%', $parts);
669
		}
670
		return "%$pattern%";
671
	}
672
673
	/**
674
	 * Format SQL operator, conversion, and parameter matching the given advanced search operator.
675
	 * @return array{op: string, conv: string, param: string|int|null}
676
	 */
677
	protected function advFormatSqlOperator(string $ruleOperator, string $ruleInput, string $userId) {
678
		if ($this->dbType == 'sqlite3' && ($ruleOperator == 'regexp' || $ruleOperator == 'notregexp')) {
679
			$this->registerRegexpFuncForSqlite();
680
		}
681
682
		$pgsql = ($this->dbType == 'pgsql');
683
684
		switch ($ruleOperator) {
685
			case 'contain':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput%"];
686
			case 'notcontain':	return ['op' => 'NOT LIKE',								'conv' => 'LOWER',		'param' => "%$ruleInput%"];
687
			case 'start':		return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "$ruleInput%"];
688
			case 'end':			return ['op' => 'LIKE',									'conv' => 'LOWER',		'param' => "%$ruleInput"];
689
			case 'is':			return ['op' => '=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
690
			case 'isnot':		return ['op' => '!=',									'conv' => 'LOWER',		'param' => "$ruleInput"];
691
			case 'sounds':		return ['op' => '=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
692
			case 'notsounds':	return ['op' => '!=',									'conv' => 'SOUNDEX',	'param' => $ruleInput]; // requires extension `fuzzystrmatch` on PgSQL
693
			case 'regexp':		return ['op' => $pgsql ? '~' : 'REGEXP',				'conv' => 'LOWER',		'param' => $ruleInput];
694
			case 'notregexp':	return ['op' => $pgsql ? '!~' : 'NOT REGEXP',			'conv' => 'LOWER',		'param' => $ruleInput];
695
			case 'true':		return ['op' => 'IS NOT NULL',							'conv' => '',			'param' => null];
696
			case 'false':		return ['op' => 'IS NULL',								'conv' => '',			'param' => null];
697
			case 'equal':		return ['op' => '',										'conv' => '',			'param' => $ruleInput];
698
			case 'ne':			return ['op' => 'NOT',									'conv' => '',			'param' => $ruleInput];
699
			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
700
			case 'before':		return ['op' => '<',									'conv' => '',			'param' => $ruleInput];
701
			case 'after':		return ['op' => '>',									'conv' => '',			'param' => $ruleInput];
702
			default:			return ['op' => self::sanitizeNumericOp($ruleOperator),	'conv' => '',			'param' => (int)$ruleInput]; // all numerical operators fall here
703
		}
704
	}
705
706
	protected static function sanitizeNumericOp(string $comparisonOperator) : string {
707
		if (\in_array($comparisonOperator, ['>=', '<=', '=', '!=', '>', '<'])) {
708
			return $comparisonOperator;
709
		} else {
710
			throw new \DomainException("Bad advanced search operator: $comparisonOperator");
711
		}
712
	}
713
714
	/**
715
	 * Format SQL condition matching the given advanced search rule and SQL operator.
716
	 * Derived classes should override this to provide support for table-specific rules.
717
	 * @param string $rule	Identifier of the property which is the target of the SQL condition. The identifiers match the Ampache API specification.
718
	 * @param string $sqlOp	SQL (comparison) operator to be used
719
	 * @param string $conv	SQL conversion function to be applied on the target column and the parameter (e.g. "LOWER")
720
	 * @return string SQL condition statement to be used in the "WHERE" clause
721
	 */
722
	protected function advFormatSqlCondition(string $rule, string $sqlOp, string $conv) : string {
723
		$table = $this->getTableName();
724
		$nameCol = $this->nameColumn;
725
726
		switch ($rule) {
727
			case 'title':			return "$conv(`$table`.`$nameCol`) $sqlOp $conv(?)";
728
			case 'my_flagged':		return "`$table`.`starred` $sqlOp";
729
			case 'favorite':		return "($conv(`$table`.`$nameCol`) $sqlOp $conv(?) AND `$table`.`starred` IS NOT NULL)"; // title search among flagged
730
			case 'myrating':		// fall through, we provide no access to other people's data
731
			case 'rating':			return "`$table`.`rating` $sqlOp ?";
732
			case 'added':			return "`$table`.`created` $sqlOp ?";
733
			case 'updated':			return "`$table`.`updated` $sqlOp ?";
734
			case 'mbid':			return "`$table`.`mbid` $sqlOp ?";
735
			case 'recent_added':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `created` DESC LIMIT $sqlOp) mysqlhack)";
736
			case 'recent_updated':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `updated` DESC LIMIT $sqlOp) mysqlhack)";
737
			default:				throw new \DomainException("Rule '$rule' not supported on this entity type");
738
		}
739
	}
740
741
	protected function sqlConcat(string ...$parts) : string {
742
		if ($this->dbType == 'sqlite3') {
743
			return '(' . \implode(' || ', $parts) . ')';
744
		} else {
745
			return 'CONCAT(' . \implode(', ', $parts) . ')';
746
		}
747
	}
748
749
	protected function sqlGroupConcat(string $column) : string {
750
		if ($this->dbType == 'pgsql') {
751
			return "string_agg($column, ',')";
752
		} else {
753
			return "GROUP_CONCAT($column)";
754
		}
755
	}
756
757
	protected function sqlCoalesce(string $value, string $replacement) : string {
758
		if ($this->dbType == 'pgsql') {
759
			return "COALESCE($value, $replacement)";
760
		} else {
761
			return "IFNULL($value, $replacement)";
762
		}
763
	}
764
765
	/**
766
	 * @param string $datetime Date-and-time string in UTC
767
	 */
768
	protected function sqlDateToEpoch(string $datetime) : string {
769
		if ($this->dbType == 'pgsql') {
770
			return "DATE_PART('EPOCH', $datetime)";
771
		} else if ($this->dbType == 'sqlite3') {
772
			return "CAST(strftime('%s', $datetime) AS INT)";
773
		} else { // 'mysql'
774
			// MySQL function UNIX_TIMESTAMP "helpfully" converts given datetime from session timezone to UTC
775
			// and there's no way to prevent this. Hence, we need to convert the UTC first to the session time zone.
776
			// In addition to being stupid, this is ambiguous for one hour per year when the DST ends. As result, this
777
			// function is slightly bugged and the result is off-by-one-hour during this DST shift hour.
778
			return "UNIX_TIMESTAMP(CONVERT_TZ($datetime, '+00:00', @@session.time_zone))";
779
		}
780
	}
781
782
	/**
783
	 * SQLite connects the operator REGEXP to the function of the same name but doesn't ship the function itself.
784
	 * Hence, we need to register it as a user-function. This happens by creating a suitable wrapper for the PHP
785
	 * native preg_match function. Based on https://stackoverflow.com/a/18484596.
786
	 */
787
	private function registerRegexpFuncForSqlite() : void {
788
		// skip if the function already exists
789
		if (!$this->funcExistsInSqlite('regexp')) {
790
			// We need to use a private interface here to drill down to the native DB connection. The interface is
791
			// slightly different on NC compared to OC.
792
			if (\method_exists($this->db, 'getInner')) {
793
				$connection = $this->db->/** @scrutinizer ignore-call */getInner()->getWrappedConnection();
794
				$pdo = $connection->getWrappedConnection();
795
			} else if (\method_exists($this->db, 'getWrappedConnection')) {
796
				$pdo = $this->db->/** @scrutinizer ignore-call */getWrappedConnection();
797
			}
798
799
			if (isset($pdo)) {
800
				$pdo->sqliteCreateFunction(
801
					'regexp',
802
					function ($pattern, $data, $delimiter = '~', $modifiers = 'isuS') {
803
						if (isset($pattern, $data) === true) {
804
							return (\preg_match(\sprintf('%1$s%2$s%1$s%3$s', $delimiter, $pattern, $modifiers), $data) > 0);
805
						}
806
						return null;
807
					}
808
				);
809
			}
810
		}
811
	}
812
813
	private function funcExistsInSqlite(string $funcName) : bool {
814
		// If the SQLite version is very old, it may not have the `pragma_function_list` table available. In such cases,
815
		// assume that the queried function doesn't exist. It doesn't really make any harm if that leads to registering
816
		// the same function again.
817
		try {
818
			$result = $this->execute('SELECT EXISTS(SELECT 1 FROM `pragma_function_list` WHERE `NAME` = ?)', [$funcName]);
819
			$row = $result->fetch();
820
			return (bool)\current($row);
821
		} catch (\Exception $e) {
822
			return false;
823
		}
824
	}
825
826
	/**
827
	 * Find ID of an existing entity which conflicts with the unique constraint of the given entity
828
	 */
829
	private function findIdOfConflict(Entity $entity) : int {
830
		if (empty($this->uniqueColumns)) {
831
			throw new \BadMethodCallException('not supported');
832
		}
833
834
		$properties = \array_map(fn($col) => $entity->columnToProperty($col), $this->uniqueColumns);
835
		$values = \array_map(fn($prop) => $entity->$prop, $properties);
836
837
		$conds = \array_map(fn($col) => "`$col` = ?", $this->uniqueColumns);
838
		$sql = "SELECT `id` FROM {$this->getTableName()} WHERE " . \implode(' AND ', $conds);
839
840
		$result = $this->execute($sql, $values);
841
		/** @var string|false $id */ // phpdoc for \Doctrine\DBAL\Driver\Statement::fetchColumn is erroneous and omits the `false`
842
		$id = $result->fetchColumn();
843
844
		if ($id === false) {
0 ignored issues
show
introduced by
The condition $id === false is always false.
Loading history...
845
			throw new DoesNotExistException('Conflicting entity not found');
846
		}
847
848
		return (int)$id;
849
	}
850
851
	private function getCreated(int $id) : string {
852
		$sql = "SELECT `created` FROM {$this->getTableName()} WHERE `id` = ?";
853
		$result = $this->execute($sql, [$id]);
854
		/** @var string|false $created */ // phpdoc for \Doctrine\DBAL\Driver\Statement::fetchColumn is erroneous and omits the `false`
855
		$created = $result->fetchColumn();
856
		if ($created === false) {
0 ignored issues
show
introduced by
The condition $created === false is always false.
Loading history...
857
			throw new DoesNotExistException('ID not found');
858
		}
859
		return $created;
860
	}
861
}
862