Passed
Push — master ( d93244...31d6f2 )
by Pauli
03:37
created

BaseMapper::findAllByName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 9
dl 0
loc 18
rs 9.9666

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