Passed
Push — master ( 4b5769...35b363 )
by Pauli
12:54
created

BaseMapper::prepareFuzzySearchString()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 14
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 14
rs 10
cc 3
nc 2
nop 1
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 - 2021
11
 */
12
13
namespace OCA\Music\Db;
14
15
use OCP\AppFramework\Db\DoesNotExistException;
16
use OCP\AppFramework\Db\Mapper;
17
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
18
use OCP\IDBConnection;
19
20
use OCA\Music\AppFramework\Db\UniqueConstraintViolationException;
21
use OCA\Music\Utility\Util;
22
23
/**
24
 * Common base class for data access classes of the Music app
25
 * @phpstan-template EntityType of Entity
26
 * @phpstan-method EntityType findEntity(string $sql, array $params)
27
 * @phpstan-method EntityType[] findEntities(string $sql, array $params, ?int $limit=null, ?int $offset=null)
28
 */
29
abstract class BaseMapper extends Mapper {
30
	const SQL_DATE_FORMAT = 'Y-m-d H:i:s.v';
31
32
	protected $nameColumn;
33
	/** @phpstan-var class-string<EntityType> $entityClass */
34
	protected $entityClass;
35
36
	/**
37
	 * @phpstan-param class-string<EntityType> $entityClass
38
	 */
39
	public function __construct(IDBConnection $db, string $tableName, string $entityClass, string $nameColumn) {
40
		parent::__construct($db, $tableName, $entityClass);
41
		$this->nameColumn = $nameColumn;
42
		// eclipse the base class property to help phpstan
43
		$this->entityClass = $entityClass;
44
	}
45
46
	/**
47
	 * Create an empty object of the entity class bound to this mapper
48
	 * @phpstan-return EntityType
49
	 */
50
	public function createEntity() : Entity {
51
		return new $this->entityClass();
52
	}
53
54
	/**
55
	 * Find a single entity by id and user_id
56
	 * @throws DoesNotExistException if the entity does not exist
57
	 * @throws MultipleObjectsReturnedException if more than one entity exists
58
	 * @phpstan-return EntityType
59
	 */
60
	public function find(int $id, string $userId) : Entity {
61
		$sql = $this->selectUserEntities("`{$this->getTableName()}`.`id` = ?");
62
		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...
63
	}
64
65
	/**
66
	 * Find all entities matching the given IDs. Specifying the owning user is optional.
67
	 * @param integer[] $ids  IDs of the entities to be found
68
	 * @param string|null $userId
69
	 * @return Entity[]
70
	 * @phpstan-return EntityType[]
71
	 */
72
	public function findById(array $ids, string $userId=null) : array {
73
		$count = \count($ids);
74
		$condition = "`{$this->getTableName()}`.`id` IN ". $this->questionMarks($count);
75
76
		if (empty($userId)) {
77
			$sql = $this->selectEntities($condition);
78
		} else {
79
			$sql = $this->selectUserEntities($condition);
80
			$ids = \array_merge([$userId], $ids);
81
		}
82
83
		return $this->findEntities($sql, $ids);
84
	}
85
86
	/**
87
	 * Find all user's entities
88
	 * @param string|null $createdMin Optional minimum `created` timestamp.
89
	 * @param string|null $createdMax Optional maximum `created` timestamp.
90
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
91
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
92
	 * @return Entity[]
93
	 * @phpstan-return EntityType[]
94
	 */
95
	public function findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null,
96
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
97
		$sorting = $this->formatSortingClause($sortBy);
98
		[$condition, $params] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
99
		$sql = $this->selectUserEntities($condition, $sorting);
100
		\array_unshift($params, $userId);
101
		return $this->findEntities($sql, $params, $limit, $offset);
102
	}
103
104
	/**
105
	 * Find all user's entities matching the given name
106
	 * @param string|null $createdMin Optional minimum `created` timestamp.
107
	 * @param string|null $createdMax Optional maximum `created` timestamp.
108
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
109
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
110
	 * @return Entity[]
111
	 * @phpstan-return EntityType[]
112
	 */
113
	public function findAllByName(
114
		?string $name, string $userId, int $matchMode=MatchMode::Exact, int $limit=null, int $offset=null,
115
		?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
116
117
		$params = [$userId];
118
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
119
		if ($name === null) {
120
			$condition = "$nameCol IS NULL";
121
		} else {
122
			if ($matchMode === MatchMode::Exact) {
123
				$condition = "LOWER($nameCol) = LOWER(?)";
124
			} else {
125
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
126
			}
127
			if ($matchMode === MatchMode::Substring) {
128
				$params[] = self::prepareSubstringSearchPattern($name);
129
			} else {
130
				$params[] = $name;
131
			}
132
		}
133
134
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
135
		if (!empty($timestampConds)) {
136
			$condition .= ' AND ' . $timestampConds;
137
			$params = \array_merge($params, $timestampParams);
138
		}
139
140
		$sql = $this->selectUserEntities($condition, "ORDER BY LOWER($nameCol)");
141
142
		return $this->findEntities($sql, $params, $limit, $offset);
143
	}
144
145
	/**
146
	 * Find all user's starred entities
147
	 * @return Entity[]
148
	 * @phpstan-return EntityType[]
149
	 */
150
	public function findAllStarred(string $userId, int $limit=null, int $offset=null) : array {
151
		$sql = $this->selectUserEntities(
152
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
153
				"ORDER BY LOWER(`{$this->getTableName()}`.`{$this->nameColumn}`)");
154
		return $this->findEntities($sql, [$userId], $limit, $offset);
155
	}
156
157
	/**
158
	 * Find IDs of all user's entities of this kind
159
	 * @return int[]
160
	 */
161
	public function findAllIds(string $userId) : array {
162
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
163
		$result = $this->execute($sql, [$userId]);
164
165
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
166
	}
167
168
	/**
169
	 * Find IDs of all users owning any entities of this mapper
170
	 * @return string[]
171
	 */
172
	public function findAllUsers() : array {
173
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
174
		$result = $this->execute($sql);
175
176
		return $result->fetchAll(\PDO::FETCH_COLUMN);
177
	}
178
179
	/**
180
	 * Delete all entities with given IDs without specifying the user
181
	 * @param integer[] $ids  IDs of the entities to be deleted
182
	 */
183
	public function deleteById(array $ids) : void {
184
		$count = \count($ids);
185
		if ($count === 0) {
186
			return;
187
		}
188
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
189
	}
190
191
	/**
192
	 * Delete all entities matching the given SQL condition
193
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
194
	 * @param array $params SQL parameters for the condition
195
	 */
196
	protected function deleteByCond(string $condition, array $params) : void {
197
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
198
		$this->execute($sql, $params);
199
	}
200
201
	/**
202
	 * Delete all entities of the given user
203
	 */
204
	public function deleteAll(string $userId) : void {
205
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
206
		$this->execute($sql, [$userId]);
207
	}
208
209
	/**
210
	 * Tests if entity with given ID and user ID exists in the database
211
	 */
212
	public function exists(int $id, string $userId) : bool {
213
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
214
		$result = $this->execute($sql, [$id, $userId]);
215
		return $result->rowCount() > 0;
216
	}
217
218
	/**
219
	 * Count all entities of a user
220
	 */
221
	public function count(string $userId) : int {
222
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
223
		$result = $this->execute($sql, [$userId]);
224
		$row = $result->fetch();
225
		return \intval($row['count']);
226
	}
227
228
	/**
229
	 * {@inheritDoc}
230
	 * @see \OCP\AppFramework\Db\Mapper::insert()
231
	 * @phpstan-param EntityType $entity
232
	 * @phpstan-return EntityType
233
	 */
234
	public function insert(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
235
		$now = new \DateTime();
236
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
237
		$entity->setCreated($nowStr);
238
		$entity->setUpdated($nowStr);
239
240
		try {
241
			return parent::insert($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
242
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
243
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
244
		} catch (\OCP\DB\Exception $e) {
0 ignored issues
show
Bug introduced by
The type OCP\DB\Exception was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
245
			// Nextcloud 21+
246
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
247
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
248
			} else {
249
				throw $e;
250
			}
251
		}
252
	}
253
254
	/**
255
	 * {@inheritDoc}
256
	 * @see \OCP\AppFramework\Db\Mapper::update()
257
	 * @phpstan-param EntityType $entity
258
	 * @phpstan-return EntityType
259
	 */
260
	public function update(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
261
		$now = new \DateTime();
262
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
263
		return parent::update($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
264
	}
265
266
	/**
267
	 * Insert an entity, or if an entity with the same identity already exists,
268
	 * update the existing entity.
269
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
270
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
271
	 * @return Entity The inserted or updated entity, containing also the id field
272
	 * @phpstan-param EntityType $entity
273
	 * @phpstan-return EntityType
274
	 */
275
	public function insertOrUpdate(Entity $entity) : Entity {
276
		try {
277
			return $this->insert($entity);
278
		} catch (UniqueConstraintViolationException $ex) {
279
			$existingEntity = $this->findUniqueEntity($entity);
280
			$entity->setId($existingEntity->getId());
281
			$entity->setCreated($existingEntity->getCreated());
282
			return $this->update($entity);
283
		}
284
	}
285
286
	/**
287
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
288
	 * a new entity is inserted.
289
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
290
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
291
	 * @return Entity The inserted or updated entity, containing also the id field
292
	 * @phpstan-param EntityType $entity
293
	 * @phpstan-return EntityType
294
	 */
295
	public function updateOrInsert(Entity $entity) : Entity {
296
		try {
297
			$existingEntity = $this->findUniqueEntity($entity);
298
			$entity->setId($existingEntity->getId());
299
			return $this->update($entity);
300
		} catch (DoesNotExistException $ex) {
301
			try {
302
				return $this->insert($entity);
303
			} catch (UniqueConstraintViolationException $ex) {
304
				// the conflicting entry didn't exist an eyeblink ago but now it does
305
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
306
				//    update happens last; cancel this update
307
				return $this->findUniqueEntity($entity);
308
			}
309
		}
310
	}
311
312
	/**
313
	 * Set the "starred" column of the given entities
314
	 * @param \DateTime|null $date
315
	 * @param integer[] $ids
316
	 * @param string $userId
317
	 * @return int number of modified entities
318
	 */
319
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
320
		$count = \count($ids);
321
		if (!empty($date)) {
322
			$date = $date->format(self::SQL_DATE_FORMAT);
323
		}
324
325
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
326
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
327
		$params = \array_merge([$date], $ids, [$userId]);
328
		return $this->execute($sql, $params)->rowCount();
329
	}
330
331
	public function latestInsertTime(string $userId) : ?\DateTime {
332
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
333
		$result = $this->execute($sql, [$userId]);
334
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
335
336
		return ($createdTime === null) ? null : new \DateTime($createdTime);
337
	}
338
339
	public function latestUpdateTime(string $userId) : ?\DateTime {
340
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
341
		$result = $this->execute($sql, [$userId]);
342
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
343
344
		return ($createdTime === null) ? null : new \DateTime($createdTime);
345
	}
346
347
	/**
348
	 * helper creating a string like '(?,?,?)' with the specified number of elements
349
	 */
350
	protected function questionMarks(int $count) : string {
351
		$questionMarks = [];
352
		for ($i = 0; $i < $count; $i++) {
353
			$questionMarks[] = '?';
354
		}
355
		return '(' . \implode(',', $questionMarks) . ')';
356
	}
357
358
	/**
359
	 * Build a SQL SELECT statement which selects all entities of the given user,
360
	 * and optionally applies other conditions, too.
361
	 * This is built upon `selectEntities` which may be overridden by the derived class.
362
	 * @param string|null $condition Optional extra condition. This will get automatically
363
	 *                               prefixed with ' AND ', so don't include that.
364
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
365
	 *                               the conditions in the SQL statement
366
	 */
367
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
368
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
369
370
		if (!empty($condition)) {
371
			$allConditions .= " AND $condition";
372
		}
373
374
		return $this->selectEntities($allConditions, $extension);
375
	}
376
377
	/**
378
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
379
	 * The derived class may override this if necessary.
380
	 * @param string $condition This will get automatically prefixed with ' WHERE '
381
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
382
	 *                               the conditions in the SQL statement
383
	 */
384
	protected function selectEntities(string $condition, string $extension=null) : string {
385
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
386
	}
387
388
	/**
389
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
390
	 */
391
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
392
		$conditions = [];
393
		$params = [];
394
395
		if (!empty($createdMin)) {
396
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
397
			$params[] = $createdMin;
398
		}
399
400
		if (!empty($createdMax)) {
401
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
402
			$params[] = $createdMax;
403
		}
404
405
		if (!empty($updatedMin)) {
406
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
407
			$params[] = $updatedMin;
408
		}
409
410
		if (!empty($updatedMax)) {
411
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
412
			$params[] = $updatedMax;
413
		}
414
415
		return [\implode(' AND ', $conditions), $params];
416
	}
417
418
	/**
419
	 * Convert given sorting condition to an SQL clause. Derived class may overide this if necessary.
420
	 * @param int $sortBy One of the constants defined in the class SortBy
421
	 */
422
	protected function formatSortingClause(int $sortBy) : ?string {
423
		if ($sortBy == SortBy::Name) {
424
			return "ORDER BY LOWER(`{$this->getTableName()}`.`{$this->nameColumn}`)";
425
		} elseif ($sortBy == SortBy::Newest) {
426
			return "ORDER BY `{$this->getTableName()}`.`id` DESC"; // abuse the fact that IDs are ever-incrementing values
427
		} else {
428
			return null;
429
		}
430
	}
431
432
	protected static function prepareSubstringSearchPattern(string $input) : string {
433
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
434
		// while the default interpretation of multipart string is that each of the parts can be found
435
		// separately as substing in the given order
436
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
437
			// remove the quotation
438
			$pattern = \substr($input, 1, -1);
439
		} else {
440
			// split to parts by whitespace
441
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
442
			// glue the parts back together with a wildcard charater
443
			$pattern = \implode('%', $parts);
444
		}
445
		return "%$pattern%";
446
	}
447
448
	/**
449
	 * Find an entity which has the same identity as the supplied entity.
450
	 * How the identity of the entity is defined, depends on the derived concrete class.
451
	 * @phpstan-param EntityType $entity
452
	 * @phpstan-return EntityType
453
	 */
454
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
455
}
456