Issues (40)

lib/Db/BaseMapper.php (2 issues)

Severity
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
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
The condition $created === false is always false.
Loading history...
857
			throw new DoesNotExistException('ID not found');
858
		}
859
		return $created;
860
	}
861
}
862