BaseMapper::findAllIdsByParentIds()   A
last analyzed

Complexity

Conditions 4
Paths 3

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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