Passed
Pull Request — master (#1078)
by Pauli
05:23 queued 02:42
created

BaseMapper::findAllAdvanced()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 12
nc 3
nop 5
dl 0
loc 18
rs 9.8666
c 0
b 0
f 0
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 - 2023
11
 */
12
13
namespace OCA\Music\Db;
14
15
use OCP\AppFramework\Db\DoesNotExistException;
16
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
17
use OCP\IDBConnection;
18
19
use OCA\Music\AppFramework\Db\CompatibleMapper;
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 CompatibleMapper {
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
119
		[$condition, $nameParams] = $this->formatNameConditions($name, $matchMode);
120
		$params = \array_merge($params, $nameParams);
121
122
		[$timestampConds, $timestampParams] = $this->formatTimestampConditions($createdMin, $createdMax, $updatedMin, $updatedMax);
123
		if (!empty($timestampConds)) {
124
			$condition .= ' AND ' . $timestampConds;
125
			$params = \array_merge($params, $timestampParams);
126
		}
127
128
		$sql = $this->selectUserEntities($condition, $this->formatSortingClause(SortBy::Name));
129
130
		return $this->findEntities($sql, $params, $limit, $offset);
131
	}
132
133
	/**
134
	 * Find all user's starred entities. It is safe to call this also on entity types
135
	 * not supporting starring in which case an empty array will be returned.
136
	 * @return Entity[]
137
	 * @phpstan-return EntityType[]
138
	 */
139
	public function findAllStarred(string $userId, int $limit=null, int $offset=null) : array {
140
		if (\property_exists($this->entityClass, 'starred')) {
141
			$sql = $this->selectUserEntities(
142
				"`{$this->getTableName()}`.`starred` IS NOT NULL",
143
				$this->formatSortingClause(SortBy::Name));
144
			return $this->findEntities($sql, [$userId], $limit, $offset);
145
		} else {
146
			return [];
147
		}
148
	}
149
150
	/**
151
	 * Find all entities with user-given rating 1-5
152
	 * @return Entity[]
153
	 * @phpstan-return EntityType[]
154
	 */
155
	public function findAllRated(string $userId, int $limit=null, int $offset=null) : array {
156
		if (\property_exists($this->entityClass, 'rating')) {
157
			$sql = $this->selectUserEntities(
158
				"`{$this->getTableName()}`.`rating` > 0",
159
				$this->formatSortingClause(SortBy::Rating));
160
			return $this->findEntities($sql, [$userId], $limit, $offset);
161
		} else {
162
			return [];
163
		}
164
	}
165
166
	/**
167
	 * Find all entities matching multiple criteria, as needed for the Ampache API method `advanced_search`
168
	 * @param string $conjunction Operator to use between the rules, either 'and' or 'or'
169
	 * @param array $rules Array of arrays: [['rule' => string, 'operator' => string, 'input' => string], ...]
170
	 * 				Here, 'rule' has dozens of possible values depending on the business layer in question
171
	 * 				(see https://ampache.org/api/api-advanced-search#available-search-rules, alias names not supported here),
172
	 * 				'operator' is one of 
173
	 * 				['contain', 'notcontain', 'start', 'end', 'is', 'isnot', '>=', '<=', '=', '!=', '>', '<', 'true', 'false', 'equal', 'ne', 'limit'],
174
	 * 				'input' is the right side value of the 'operator' (disregarded for the operators 'true' and 'false')
175
	 * @return Entity[]
176
	 * @phpstan-return EntityType[]
177
	 */
178
	public function findAllAdvanced(string $conjunction, array $rules, string $userId, ?int $limit=null, ?int $offset=null) : array {
179
		$sqlConditions = [];
180
		$sqlParams = [$userId];
181
182
		foreach ($rules as $rule) {
183
			list('op' => $sqlOp, 'param' => $param) = $this->advFormatSqlOperator($rule['operator'], $rule['input'], $userId);
184
			$cond = $this->advFormatSqlCondition($rule['rule'], $sqlOp);
185
			$sqlConditions[] = $cond;
186
			// On some conditions, the parameter may need to be repeated several times
187
			$paramCount = \substr_count($cond, '?');
188
			for ($i = 0; $i < $paramCount; ++$i) {
189
				$sqlParams[] = $param;
190
			}
191
		}
192
		$sqlConditions = \implode(" $conjunction ", $sqlConditions);
193
194
		$sql = $this->selectUserEntities($sqlConditions, $this->formatSortingClause(SortBy::Name));
195
		return $this->findEntities($sql, $sqlParams, $limit, $offset);
196
	}
197
198
	/**
199
	 * Optionally, limit to given IDs which may be used to check the validity of those IDs.
200
	 * @return int[]
201
	 */
202
	public function findAllIds(string $userId, ?array $ids = null) : array {
203
		$sql = "SELECT `id` FROM `{$this->getTableName()}` WHERE `user_id` = ?";
204
		$params = [$userId];
205
206
		if ($ids !== null) {
207
			$sql .= ' AND `id` IN ' . $this->questionMarks(\count($ids));
208
			$params = \array_merge($params, $ids);
209
		}
210
211
		$result = $this->execute($sql, $params);
212
213
		return \array_map('intval', $result->fetchAll(\PDO::FETCH_COLUMN));
214
	}
215
216
	/**
217
	 * Find IDs of all users owning any entities of this mapper
218
	 * @return string[]
219
	 */
220
	public function findAllUsers() : array {
221
		$sql = "SELECT DISTINCT(`user_id`) FROM `{$this->getTableName()}`";
222
		$result = $this->execute($sql);
223
224
		return $result->fetchAll(\PDO::FETCH_COLUMN);
225
	}
226
227
	/**
228
	 * Delete all entities with given IDs without specifying the user
229
	 * @param integer[] $ids  IDs of the entities to be deleted
230
	 */
231
	public function deleteById(array $ids) : void {
232
		$count = \count($ids);
233
		if ($count === 0) {
234
			return;
235
		}
236
		$this->deleteByCond('`id` IN ' . $this->questionMarks($count), $ids);
237
	}
238
239
	/**
240
	 * Delete all entities matching the given SQL condition
241
	 * @param string $condition SQL 'WHERE' condition (without the keyword 'WHERE')
242
	 * @param array $params SQL parameters for the condition
243
	 */
244
	protected function deleteByCond(string $condition, array $params) : void {
245
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE ". $condition;
246
		$this->execute($sql, $params);
247
	}
248
249
	/**
250
	 * Delete all entities of the given user
251
	 */
252
	public function deleteAll(string $userId) : void {
253
		$sql = "DELETE FROM `{$this->getTableName()}` WHERE `user_id` = ?";
254
		$this->execute($sql, [$userId]);
255
	}
256
257
	/**
258
	 * Tests if entity with given ID and user ID exists in the database
259
	 */
260
	public function exists(int $id, string $userId) : bool {
261
		$sql = "SELECT 1 FROM `{$this->getTableName()}` WHERE `id` = ? AND `user_id` = ?";
262
		$result = $this->execute($sql, [$id, $userId]);
263
		return $result->rowCount() > 0;
264
	}
265
266
	/**
267
	 * Count all entities of a user
268
	 */
269
	public function count(string $userId) : int {
270
		$sql = "SELECT COUNT(*) AS count FROM `{$this->getTableName()}` WHERE `user_id` = ?";
271
		$result = $this->execute($sql, [$userId]);
272
		$row = $result->fetch();
273
		return \intval($row['count']);
274
	}
275
276
	/**
277
	 * {@inheritDoc}
278
	 * @see CompatibleMapper::insert()
279
	 * @phpstan-param EntityType $entity
280
	 * @phpstan-return EntityType
281
	 */
282
	public function insert(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
283
		$now = new \DateTime();
284
		$nowStr = $now->format(self::SQL_DATE_FORMAT);
285
		$entity->setCreated($nowStr);
286
		$entity->setUpdated($nowStr);
287
288
		try {
289
			return parent::insert($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
290
		} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
291
			throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
292
		} catch (\OCP\DB\Exception $e) {
293
			// Nextcloud 21+
294
			if ($e->getReason() == \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
295
				throw new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e);
296
			} else {
297
				throw $e;
298
			}
299
		}
300
	}
301
302
	/**
303
	 * {@inheritDoc}
304
	 * @see CompatibleMapper::update()
305
	 * @phpstan-param EntityType $entity
306
	 * @phpstan-return EntityType
307
	 */
308
	public function update(\OCP\AppFramework\Db\Entity $entity) : \OCP\AppFramework\Db\Entity {
309
		$now = new \DateTime();
310
		$entity->setUpdated($now->format(self::SQL_DATE_FORMAT));
311
		return parent::update($entity); // @phpstan-ignore-line: no way to tell phpstan that the parent uses the template type
312
	}
313
314
	/**
315
	 * Insert an entity, or if an entity with the same identity already exists,
316
	 * update the existing entity.
317
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
318
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
319
	 * @return Entity The inserted or updated entity, containing also the id field
320
	 * @phpstan-param EntityType $entity
321
	 * @phpstan-return EntityType
322
	 */
323
	public function insertOrUpdate(Entity $entity) : Entity {
324
		try {
325
			return $this->insert($entity);
326
		} catch (UniqueConstraintViolationException $ex) {
327
			$existingEntity = $this->findUniqueEntity($entity);
328
			$entity->setId($existingEntity->getId());
329
			$entity->setCreated($existingEntity->getCreated());
330
			return $this->update($entity);
331
		}
332
	}
333
334
	/**
335
	 * Update an entity whose unique constraint fields match the given entity. If such entity is not found,
336
	 * a new entity is inserted.
337
	 * Note: The functions insertOrUpate and updateOrInsert get the exactly same thing done. The only difference is
338
	 * that the former is optimized for cases where the entity doens't exist and the latter for cases where it does exist.
339
	 * @return Entity The inserted or updated entity, containing also the id field
340
	 * @phpstan-param EntityType $entity
341
	 * @phpstan-return EntityType
342
	 */
343
	public function updateOrInsert(Entity $entity) : Entity {
344
		try {
345
			$existingEntity = $this->findUniqueEntity($entity);
346
			$entity->setId($existingEntity->getId());
347
			return $this->update($entity);
348
		} catch (DoesNotExistException $ex) {
349
			try {
350
				return $this->insert($entity);
351
			} catch (UniqueConstraintViolationException $ex) {
352
				// the conflicting entry didn't exist an eyeblink ago but now it does
353
				// => this is essentially a concurrent update and it is anyway non-deterministic, which
354
				//    update happens last; cancel this update
355
				return $this->findUniqueEntity($entity);
356
			}
357
		}
358
	}
359
360
	/**
361
	 * Set the "starred" column of the given entities
362
	 * @param \DateTime|null $date
363
	 * @param integer[] $ids
364
	 * @param string $userId
365
	 * @return int number of modified entities
366
	 */
367
	public function setStarredDate(?\DateTime $date, array $ids, string $userId) : int {
368
		$count = \count($ids);
369
		if (!empty($date)) {
370
			$date = $date->format(self::SQL_DATE_FORMAT);
371
		}
372
373
		$sql = "UPDATE `{$this->getTableName()}` SET `starred` = ?
374
				WHERE `id` IN {$this->questionMarks($count)} AND `user_id` = ?";
375
		$params = \array_merge([$date], $ids, [$userId]);
376
		return $this->execute($sql, $params)->rowCount();
377
	}
378
379
	public function latestInsertTime(string $userId) : ?\DateTime {
380
		$sql = "SELECT MAX(`{$this->getTableName()}`.`created`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
381
		$result = $this->execute($sql, [$userId]);
382
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
383
384
		return ($createdTime === null) ? null : new \DateTime($createdTime);
385
	}
386
387
	public function latestUpdateTime(string $userId) : ?\DateTime {
388
		$sql = "SELECT MAX(`{$this->getTableName()}`.`updated`) FROM `{$this->getTableName()}` WHERE `user_id` = ?";
389
		$result = $this->execute($sql, [$userId]);
390
		$createdTime = $result->fetch(\PDO::FETCH_COLUMN);
391
392
		return ($createdTime === null) ? null : new \DateTime($createdTime);
393
	}
394
395
	/**
396
	 * helper creating a string like '(?,?,?)' with the specified number of elements
397
	 */
398
	protected function questionMarks(int $count) : string {
399
		$questionMarks = [];
400
		for ($i = 0; $i < $count; $i++) {
401
			$questionMarks[] = '?';
402
		}
403
		return '(' . \implode(',', $questionMarks) . ')';
404
	}
405
406
	/**
407
	 * Build a SQL SELECT statement which selects all entities of the given user,
408
	 * and optionally applies other conditions, too.
409
	 * This is built upon `selectEntities` which may be overridden by the derived class.
410
	 * @param string|null $condition Optional extra condition. This will get automatically
411
	 *                               prefixed with ' AND ', so don't include that.
412
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
413
	 *                               the conditions in the SQL statement
414
	 */
415
	protected function selectUserEntities(string $condition=null, string $extension=null) : string {
416
		$allConditions = "`{$this->getTableName()}`.`user_id` = ?";
417
418
		if (!empty($condition)) {
419
			$allConditions .= " AND ($condition)";
420
		}
421
422
		return $this->selectEntities($allConditions, $extension);
423
	}
424
425
	/**
426
	 * Build a SQL SELECT statement which selects all entities matching the given condition.
427
	 * The derived class may override this if necessary.
428
	 * @param string $condition This will get automatically prefixed with ' WHERE '
429
	 * @param string|null $extension Any extension (e.g. ORDER BY, LIMIT) to be added after
430
	 *                               the conditions in the SQL statement
431
	 */
432
	protected function selectEntities(string $condition, string $extension=null) : string {
433
		return "SELECT * FROM `{$this->getTableName()}` WHERE $condition $extension ";
434
	}
435
436
	/**
437
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
438
	 */
439
	protected function formatNameConditions(?string $name, int $matchMode) : array {
440
		$params = [];
441
		$nameCol = "`{$this->getTableName()}`.`{$this->nameColumn}`";
442
		if ($name === null) {
443
			$condition = "$nameCol IS NULL";
444
		} else {
445
			if ($matchMode === MatchMode::Exact) {
446
				$condition = "LOWER($nameCol) = LOWER(?)";
447
			} else {
448
				$condition = "LOWER($nameCol) LIKE LOWER(?)";
449
			}
450
			if ($matchMode === MatchMode::Substring) {
451
				$params[] = self::prepareSubstringSearchPattern($name);
452
			} else {
453
				$params[] = $name;
454
			}
455
		}
456
		return [$condition, $params];
457
	}
458
459
	/**
460
	 * @return array with two values: The SQL condition as string and the SQL parameters as string[]
461
	 */
462
	protected function formatTimestampConditions(?string $createdMin, ?string $createdMax, ?string $updatedMin, ?string $updatedMax) : array {
463
		$conditions = [];
464
		$params = [];
465
466
		if (!empty($createdMin)) {
467
			$conditions[] = "`{$this->getTableName()}`.`created` >= ?";
468
			$params[] = $createdMin;
469
		}
470
471
		if (!empty($createdMax)) {
472
			$conditions[] = "`{$this->getTableName()}`.`created` <= ?";
473
			$params[] = $createdMax;
474
		}
475
476
		if (!empty($updatedMin)) {
477
			$conditions[] = "`{$this->getTableName()}`.`updated` >= ?";
478
			$params[] = $updatedMin;
479
		}
480
481
		if (!empty($updatedMax)) {
482
			$conditions[] = "`{$this->getTableName()}`.`updated` <= ?";
483
			$params[] = $updatedMax;
484
		}
485
486
		return [\implode(' AND ', $conditions), $params];
487
	}
488
489
	/**
490
	 * Convert given sorting condition to an SQL clause. Derived class may overide this if necessary.
491
	 * @param int $sortBy One of the constants defined in the class SortBy
492
	 */
493
	protected function formatSortingClause(int $sortBy, bool $invertSort = false) : ?string {
494
		$table = $this->getTableName();
495
		if ($sortBy == SortBy::Name) {
496
			$dir = $invertSort ? 'DESC' : 'ASC';
497
			return "ORDER BY LOWER(`$table`.`{$this->nameColumn}`) $dir";
498
		} elseif ($sortBy == SortBy::Newest) {
499
			$dir = $invertSort ? 'ASC' : 'DESC';
500
			return "ORDER BY `$table`.`id` $dir"; // abuse the fact that IDs are ever-incrementing values
501
		} elseif ($sortBy == SortBy::Rating) {
502
			if (\property_exists($this->entityClass, 'rating')) {
503
				$dir = $invertSort ? 'ASC' : 'DESC';
504
				return "ORDER BY `$table`.`rating` $dir";
505
			} else {
506
				return null;
507
			}
508
		} else {
509
			return null;
510
		}
511
	}
512
513
	protected static function prepareSubstringSearchPattern(string $input) : string {
514
		// possibly multiparted query enclosed in quotation marks is handled as a single substring,
515
		// while the default interpretation of multipart string is that each of the parts can be found
516
		// separately as substring in the given order
517
		if (Util::startsWith($input, '"') && Util::endsWith($input, '"')) {
518
			// remove the quotation
519
			$pattern = \substr($input, 1, -1);
520
		} else {
521
			// split to parts by whitespace
522
			$parts = \preg_split('/\s+/', $input, -1, PREG_SPLIT_NO_EMPTY);
523
			// glue the parts back together with a wildcard charater
524
			$pattern = \implode('%', $parts);
525
		}
526
		return "%$pattern%";
527
	}
528
529
	/**
530
	 * Format SQL operator and parameter matching the given advanced search operator.
531
	 * @return array like ['op' => string, 'param' => string]
532
	 */
533
	protected function advFormatSqlOperator(string $ruleOperator, string $ruleInput, string $userId) {
534
		switch ($ruleOperator) {
535
			case 'contain':		return ['op' => 'LIKE',						'param' => "%$ruleInput%"];
536
			case 'notcontain':	return ['op' => 'NOT LIKE',					'param' => "%$ruleInput%"];
537
			case 'start':		return ['op' => 'LIKE',						'param' => "$ruleInput%"];
538
			case 'end':			return ['op' => 'LIKE',						'param' => "%$ruleInput"];
539
			case 'is':			return ['op' => '=',						'param' => "$ruleInput"];
540
			case 'isnot':		return ['op' => '!=',						'param' => "$ruleInput"];
541
			case 'sounds':		return ['op' => 'SOUNDS LIKE',				'param' => $ruleInput]; // MySQL-specific syntax
542
			case 'notsounds':	return ['op' => 'NOT SOUNDS LIKE',			'param' => $ruleInput]; // MySQL-specific syntax
543
			case 'regexp':		return ['op' => 'REGEXP',					'param' => $ruleInput]; // MySQL-specific syntax
544
			case 'notregexp':	return ['op' => 'NOT REGEXP',				'param' => $ruleInput]; // MySQL-specific syntax
545
			case 'true':		return ['op' => 'IS NOT NULL',				'param' => null];
546
			case 'false':		return ['op' => 'IS NULL',					'param' => null];
547
			case 'equal':		return ['op' => '',							'param' => $ruleInput];
548
			case 'ne':			return ['op' => 'NOT',						'param' => $ruleInput];
549
			case 'limit':		return ['op' => (string)(int)$ruleInput,	'param' => $userId];	// this is a bit hacky, userId needs to be passed as an SQL param while simple sanitation suffices for the limit
550
			default:			return ['op' => $ruleOperator,				'param' => $ruleInput]; // all numerical operators fall here
551
		}
552
	}
553
554
	/**
555
	 * Format SQL condition matching the given advanced search rule and SQL operator.
556
	 * Derived classes should override this to provide support for table-specific rules.
557
	 */
558
	protected function advFormatSqlCondition(string $rule, string $sqlOp) : string {
559
		$table = $this->getTableName();
560
		$nameCol = $this->nameColumn;
561
562
		switch ($rule) {
563
			case 'title':			return "LOWER(`$table`.`$nameCol`) $sqlOp LOWER(?)";
564
			case 'my_flagged':		return "`$table`.`starred` $sqlOp";
565
			case 'favorite':		return "(LOWER(`$table`.`$nameCol`) $sqlOp LOWER(?) AND `$table`.`starred` IS NOT NULL)"; // title search among flagged
566
			case 'myrating':		// fall throuhg, we provide no access to other people's data
567
			case 'rating':			return "`$table`.`rating` $sqlOp ?";
568
			case 'added':			return "`$table`.`created` $sqlOp ?";
569
			case 'updated':			return "`$table`.`updated` $sqlOp ?";
570
			case 'mbid':			return "`$table`.`mbid` $sqlOp ?";
571
			case 'recent_added':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `created` DESC LIMIT $sqlOp) mysqlhack)";
572
			case 'recent_updated':	return "`$table`.`id` IN (SELECT * FROM (SELECT `id` FROM `$table` WHERE `user_id` = ? ORDER BY `updated` DESC LIMIT $sqlOp) mysqlhack)";
573
			default:				throw new \DomainException("Rule '$rule' not supported on this entity type");
574
		}
575
	}
576
577
	/**
578
	 * Find an entity which has the same identity as the supplied entity.
579
	 * How the identity of the entity is defined, depends on the derived concrete class.
580
	 * @phpstan-param EntityType $entity
581
	 * @phpstan-return EntityType
582
	 */
583
	abstract protected function findUniqueEntity(Entity $entity) : Entity;
584
}
585