Passed
Push — master ( 22417d...b806b4 )
by Pauli
18:16
created

BaseMapper   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 416
Duplicated Lines 0 %

Importance

Changes 5
Bugs 0 Features 2
Metric Value
wmc 56
eloc 156
c 5
b 0
f 2
dl 0
loc 416
rs 5.5199

28 Methods

Rating   Name   Duplication   Size   Complexity  
A findById() 0 12 2
A exists() 0 4 1
A findAllStarred() 0 5 1
A createEntity() 0 2 1
A findAllIds() 0 5 1
A __construct() 0 3 1
A findAll() 0 7 1
A findAllByName() 0 25 4
A deleteByCond() 0 3 1
A findAllUsers() 0 5 1
A insert() 0 16 4
A count() 0 5 1
A deleteById() 0 6 2
A deleteAll() 0 3 1
A find() 0 3 1
A update() 0 4 1
A latestUpdateTime() 0 6 2
A prepareFuzzySearchString() 0 14 3
A selectEntities() 0 2 1
A selectUserEntities() 0 8 2
A formatSortingClause() 0 7 3
A questionMarks() 0 6 2
A formatTimestampConditions() 0 25 5
A entityNeedsUpdate() 0 8 3
A insertOrUpdate() 0 11 3
A latestInsertTime() 0 6 2
A updateOrInsert() 0 17 4
A setStarredDate() 0 10 2

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 - 2021
11
 */
12
13
namespace OCA\Music\Db;
14
15
use \OCP\AppFramework\Db\DoesNotExistException;
16
use \OCP\AppFramework\Db\Entity;
17
use \OCP\AppFramework\Db\Mapper;
18
use \OCP\AppFramework\Db\MultipleObjectsReturnedException;
19
use \OCP\IDBConnection;
20
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
 */
27
abstract class BaseMapper extends Mapper {
28
	const SQL_DATE_FORMAT = 'Y-m-d H:i:s.v';
29
30
	protected $nameColumn;
31
32
	public function __construct(IDBConnection $db, string $tableName, string $entityClass, string $nameColumn) {
33
		parent::__construct($db, $tableName, $entityClass);
34
		$this->nameColumn = $nameColumn;
35
	}
36
37
	/**
38
	 * Create an empty object of the entity class bound to this mapper
39
	 */
40
	public function createEntity() : Entity {
41
		return new $this->entityClass();
42
	}
43
44
	/**
45
	 * Find a single entity by id and user_id
46
	 * @throws DoesNotExistException if the entity does not exist
47
	 * @throws MultipleObjectsReturnedException if more than one entity exists
48
	 */
49
	public function find(int $id, string $userId) : Entity {
50
		$sql = $this->selectUserEntities("`{$this->getTableName()}`.`id` = ?");
51
		return $this->findEntity($sql, [$userId, $id]);
52
	}
53
54
	/**
55
	 * Find all entities matching the given IDs. Specifying the owning user is optional.
56
	 * @param integer[] $ids  IDs of the entities to be found
57
	 * @param string|null $userId
58
	 * @return Entity[]
59
	 */
60
	public function findById(array $ids, string $userId=null) : array {
61
		$count = \count($ids);
62
		$condition = "`{$this->getTableName()}`.`id` IN ". $this->questionMarks($count);
63
64
		if (empty($userId)) {
65
			$sql = $this->selectEntities($condition);
66
		} else {
67
			$sql = $this->selectUserEntities($condition);
68
			$ids = \array_merge([$userId], $ids);
69
		}
70
71
		return $this->findEntities($sql, $ids);
72
	}
73
74
	/**
75
	 * Find all user's entities
76
	 * @param string|null $createdMin Optional minimum `created` timestamp.
77
	 * @param string|null $createdMax Optional maximum `created` timestamp.
78
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
79
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
80
	 * @return Entity[]
81
	 */
82
	public function findAll(string $userId, int $sortBy=SortBy::None, int $limit=null, int $offset=null,
83
							?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
84
		$sorting = $this->formatSortingClause($sortBy);
85
		[$condition, $params] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
86
		$sql = $this->selectUserEntities($condition, $sorting);
87
		\array_unshift($params, $userId);
88
		return $this->findEntities($sql, $params, $limit, $offset);
89
	}
90
91
	/**
92
	 * Find all user's entities matching the given name
93
	 * @param string|null $createdMin Optional minimum `created` timestamp.
94
	 * @param string|null $createdMax Optional maximum `created` timestamp.
95
	 * @param string|null $updatedMin Optional minimum `updated` timestamp.
96
	 * @param string|null $updatedMax Optional maximum `updated` timestamp.
97
	 * @return Entity[]
98
	 */
99
	public function findAllByName(
100
		?string $name, string $userId, bool $fuzzy=false, int $limit=null, int $offset=null,
101
		?string $createdMin=null, ?string $createdMax=null, ?string $updatedMin=null, ?string $updatedMax=null) : array {
102
103
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
104
		if ($name === null) {
105
			$condition = "$nameCol IS NULL";
106
			$params = [$userId];
107
		} elseif ($fuzzy) {
108
			$condition = "LOWER($nameCol) LIKE LOWER(?)";
109
			$params = [$userId, self::prepareFuzzySearchString($name)];
110
		} else {
111
			$condition = "LOWER($nameCol) = LOWER(?)";
112
			$params = [$userId, $name];
113
		}
114
115
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
116
		if (!empty($timestampConds)) {
117
			$condition .= ' AND ' . $timestampConds;
118
			$params = \array_merge($params, $timestampParams);
119
		}
120
121
		$sql = $this->selectUserEntities($condition, "ORDER BY LOWER($nameCol)");
122
123
		return $this->findEntities($sql, $params, $limit, $offset);
124
	}
125
126
	/**
127
	 * Find all user's starred entities
128
	 * @return Entity[]
129
	 */
130
	public function findAllStarred(string $userId, int $limit=null, int $offset=null) : array {
131
		$sql = $this->selectUserEntities(
132
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
133
				"ORDER BY LOWER(`{$this->getTableName()}`.`{$this->nameColumn}`)");
134
		return $this->findEntities($sql, [$userId], $limit, $offset);
135
	}
136
137
	/**
138
	 * Find IDs of all user's entities of this kind
139
	 * @return int[]
140
	 */
141
	public function findAllIds(string $userId) : array {
142
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
143
		$result = $this->execute($sql, [$userId]);
144
145
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
146
	}
147
148
	/**
149
	 * Find IDs of all users owning any entities of this mapper
150
	 * @return string[]
151
	 */
152
	public function findAllUsers() : array {
153
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
154
		$result = $this->execute($sql);
155
156
		return $result->fetchAll(\PDO::FETCH_COLUMN);
157
	}
158
159
	/**
160
	 * Delete all entities with given IDs without specifying the user
161
	 * @param integer[] $ids  IDs of the entities to be deleted
162
	 */
163
	public function deleteById(array $ids) : void {
164
		$count = \count($ids);
165
		if ($count === 0) {
166
			return;
167
		}
168
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
169
	}
170
171
	/**
172
	 * Delete all entities matching the given SQL condition
173
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
174
	 * @param array $params SQL parameters for the condition
175
	 */
176
	protected function deleteByCond(string $condition, array $params) : void {
177
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
178
		$this->execute($sql, $params);
179
	}
180
181
	/**
182
	 * Delete all entities of the given user
183
	 */
184
	public function deleteAll(string $userId) : void {
185
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
186
		$this->execute($sql, [$userId]);
187
	}
188
189
	/**
190
	 * Tests if entity with given ID and user ID exists in the database
191
	 */
192
	public function exists(int $id, string $userId) : bool {
193
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
194
		$result = $this->execute($sql, [$id, $userId]);
195
		return $result->rowCount() > 0;
196
	}
197
198
	/**
199
	 * Count all entities of a user
200
	 */
201
	public function count(string $userId) : int {
202
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
203
		$result = $this->execute($sql, [$userId]);
204
		$row = $result->fetch();
205
		return \intval($row['count']);
206
	}
207
208
	/**
209
	 * {@inheritDoc}
210
	 * @see \OCP\AppFramework\Db\Mapper::insert()
211
	 */
212
	public function insert(Entity $entity) : Entity {
213
		$now = new \DateTime();
214
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
215
		$entity->setCreated($nowStr);
216
		$entity->setUpdated($nowStr);
217
218
		try {
219
			return parent::insert($entity);
220
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
221
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
222
		} 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...
223
			// Nextcloud 21+
224
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
225
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
226
			} else {
227
				throw $e;
228
			}
229
		}
230
	}
231
232
	/**
233
	 * {@inheritDoc}
234
	 * @see \OCP\AppFramework\Db\Mapper::update()
235
	 */
236
	public function update(Entity $entity) : Entity {
237
		$now = new \DateTime();
238
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
239
		return parent::update($entity);
240
	}
241
242
	/**
243
	 * Insert an entity, or if an entity with the same identity already exists,
244
	 * update the existing entity.
245
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
246
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
247
	 * @return Entity The inserted or updated entity, containing also the id field
248
	 */
249
	public function insertOrUpdate(Entity $entity) : Entity {
250
		try {
251
			return $this->insert($entity);
252
		} catch (UniqueConstraintViolationException $ex) {
253
			$existingEntity = $this->findUniqueEntity($entity);
254
			if (self::entityNeedsUpdate($existingEntity, $entity)) {
255
				$entity->setId($existingEntity->getId());
256
				$entity->setCreated($existingEntity->getCreated());
257
				return $this->update($entity);
258
			} else {
259
				return $existingEntity;
260
			}
261
		}
262
	}
263
264
	/**
265
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
266
	 * a new entity is inserted.
267
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
268
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
269
	 * @return Entity The inserted or updated entity, containing also the id field
270
	 */
271
	public function updateOrInsert(Entity $entity) : Entity {
272
		try {
273
			$existingEntity = $this->findUniqueEntity($entity);
274
			if (self::entityNeedsUpdate($existingEntity, $entity)) {
275
				$entity->setId($existingEntity->getId());
276
				return $this->update($entity);
277
			} else {
278
				return $existingEntity;
279
			}
280
		} catch (DoesNotExistException $ex) {
281
			try {
282
				return $this->insert($entity);
283
			} catch (UniqueConstraintViolationException $ex) {
284
				// the conflicting entry didn't exist an eyeblink ago but now it does
285
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
286
				//    update happens last; cancel this update
287
				return $this->findUniqueEntity($entity);
288
			}
289
		}
290
	}
291
292
	protected static function entityNeedsUpdate(Entity $oldData, Entity $newData) {
293
		$fields = $newData->getUpdatedFields();
294
		foreach ($fields as $field => $updated) {
295
			if ($oldData->$field != $newData->$field) {
296
				return true;
297
			}
298
		}
299
		return false;
300
	}
301
302
	/**
303
	 * Set the "starred" column of the given entities
304
	 * @param \DateTime|null $date
305
	 * @param integer[] $ids
306
	 * @param string $userId
307
	 * @return int number of modified entities
308
	 */
309
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
310
		$count = \count($ids);
311
		if (!empty($date)) {
312
			$date = $date->format(self::SQL_DATE_FORMAT);
313
		}
314
315
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
316
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
317
		$params = \array_merge([$date], $ids, [$userId]);
318
		return $this->execute($sql, $params)->rowCount();
319
	}
320
321
	public function latestInsertTime(string $userId) : ?\DateTime {
322
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
323
		$result = $this->execute($sql, [$userId]);
324
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
325
326
		return ($createdTime === null) ? null : new \DateTime($createdTime);
327
	}
328
329
	public function latestUpdateTime(string $userId) : ?\DateTime {
330
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
331
		$result = $this->execute($sql, [$userId]);
332
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
333
334
		return ($createdTime === null) ? null : new \DateTime($createdTime);
335
	}
336
337
	/**
338
	 * helper creating a string like '(?,?,?)' with the specified number of elements
339
	 */
340
	protected function questionMarks(int $count) : string {
341
		$questionMarks = [];
342
		for ($i = 0; $i < $count; $i++) {
343
			$questionMarks[] = '?';
344
		}
345
		return '(' . \implode(',', $questionMarks) . ')';
346
	}
347
348
	/**
349
	 * Build a SQL SELECT statement which selects all entities of the given user,
350
	 * and optionally applies other conditions, too.
351
	 * This is built upon `selectEntities` which may be overridden by the derived class.
352
	 * @param string|null $condition Optional extra condition. This will get automatically
353
	 *                               prefixed with ' AND ', so don't include that.
354
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
355
	 *                               the conditions in the SQL statement
356
	 */
357
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
358
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
359
360
		if (!empty($condition)) {
361
			$allConditions .= " AND $condition";
362
		}
363
364
		return $this->selectEntities($allConditions, $extension);
365
	}
366
367
	/**
368
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
369
	 * The derived class may override this if necessary.
370
	 * @param string $condition This will get automatically prefixed with ' WHERE '
371
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
372
	 *                               the conditions in the SQL statement
373
	 */
374
	protected function selectEntities(string $condition, string $extension=null) : string {
375
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
376
	}
377
378
	/**
379
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
380
	 */
381
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
382
		$conditions = [];
383
		$params = [];
384
385
		if (!empty($createdMin)) {
386
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
387
			$params[] = $createdMin;
388
		}
389
390
		if (!empty($createdMax)) {
391
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
392
			$params[] = $createdMax;
393
		}
394
395
		if (!empty($updatedMin)) {
396
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
397
			$params[] = $updatedMin;
398
		}
399
400
		if (!empty($updatedMax)) {
401
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
402
			$params[] = $updatedMax;
403
		}
404
405
		return [\implode(' AND ', $conditions), $params];
406
	}
407
408
	/**
409
	 * Convert given sorting condition to an SQL clause. Derived class may overide this if necessary.
410
	 * @param int $sortBy One of the constants defined in the class SortBy
411
	 */
412
	protected function formatSortingClause(int $sortBy) : ?string {
413
		if ($sortBy == SortBy::Name) {
414
			return "ORDER BY LOWER(`{$this->getTableName()}`.`{$this->nameColumn}`)";
415
		} elseif ($sortBy == SortBy::Newest) {
416
			return "ORDER BY `{$this->getTableName()}`.`id` DESC"; // abuse the fact that IDs are ever-incrementing values
417
		} else {
418
			return null;
419
		}
420
	}
421
422
	protected static function prepareFuzzySearchString(string $input) : string {
423
		// possibly multiparted query enclosed in quotation marks is handled as a single substing,
424
		// while the default interpretation of multipart string is that each of the parts can be found
425
		// separately as substing in the given order
426
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
427
			// remove the quotation
428
			$pattern = \substr($input, 1, -1);
429
		} else {
430
			// split to parts by whitespace
431
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
432
			// glue the parts back together with a wildcard charater
433
			$pattern = \implode('%', $parts);
434
		}
435
		return "%$pattern%";
436
	}
437
438
	/**
439
	 * Find an entity which has the same identity as the supplied entity.
440
	 * How the identity of the entity is defined, depends on the derived concrete class.
441
	 */
442
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
443
}
444