BaseMapper::findAllIdsAndNames()   B
last analyzed

Complexity

Conditions 8
Paths 65

Size

Total Lines 46
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 29
nc 65
nop 10
dl 0
loc 46
rs 8.2114
c 0
b 0
f 0

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