Passed
Push — master ( 51b60d...a4260e )
by Pauli
04:35 queued 15s
created

BaseMapper   F

Complexity

Total Complexity 131

Size/Duplication

Total Lines 756
Duplicated Lines 0 %

Importance

Changes 13
Bugs 0 Features 0
Metric Value
eloc 311
c 13
b 0
f 0
dl 0
loc 756
rs 2
wmc 131

43 Methods

Rating   Name   Duplication   Size   Complexity  
A createEntity() 0 2 1
A __construct() 0 7 1
A findById() 0 12 2
A findAllStarred() 0 8 2
A findAll() 0 7 1
A findAllByName() 0 18 2
A unprefixedTableName() 0 2 1
A find() 0 3 1
A latestUpdateTime() 0 6 2
A exists() 0 4 1
A prepareSubstringSearchPattern() 0 14 3
B advFormatSqlCondition() 0 16 11
A findAllStarredIds() 0 8 2
A selectEntities() 0 2 1
A registerRegexpFuncForSqlite() 0 20 6
A findAllIds() 0 12 2
A selectUserEntities() 0 8 2
A sanitizeNumericOp() 0 5 2
B formatSortingClause() 0 17 8
A formatTimestampConditions() 0 25 5
A questionMarks() 0 6 2
A formatExcludeChildlessCondition() 0 2 1
D advFormatSqlOperator() 0 26 23
A formatNameConditions() 0 18 4
A findAllIdsByParentIds() 0 20 4
A insertOrUpdate() 0 8 2
A deleteByCond() 0 3 1
A latestInsertTime() 0 6 2
A findAllUsers() 0 5 1
A sqlConcat() 0 5 2
A funcExistsInSqlite() 0 10 2
A findAllAdvanced() 0 18 3
A insert() 0 16 4
B findAllIdsAndNames() 0 44 8
A count() 0 5 1
A findAllRated() 0 8 2
A sqlGroupConcat() 0 5 2
A deleteById() 0 6 2
A sqlCoalesce() 0 5 2
A deleteAll() 0 3 1
A updateOrInsert() 0 13 3
A setStarredDate() 0 10 2
A update() 0 4 1

How to fix   Complexity   

Complex Class

Complex classes like BaseMapper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BaseMapper, and based on these observations, apply Extract Interface, too.

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