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

BaseMapper::exists()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 5
rs 10
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