Completed
Pull Request — master (#233)
by
unknown
11:50
created

ComparingFilter   A

Complexity

Total Complexity 8

Size/Duplication

Total Lines 54
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 2
Metric Value
wmc 8
lcom 1
cbo 2
dl 0
loc 54
rs 10

2 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 4
B toSQLQuery() 0 24 4
1
<?php
2
/**
3
 * COPS (Calibre OPDS PHP Server) class file
4
 *
5
 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6
 * @author     Klaus Broelemann <[email protected]>
7
 */
8
9
require_once('base.php');
10
11
/**
12
 * Read and filter virtual libraries
13
 */
14
class VirtualLib {
15
	const SQL_VL_KEY = "virtual_libraries";  // Key for the virtual library entries.
16
	
17
	private $db_id = null;   // Database Index for the virtual lib
18
	private $vl_id = null;   // Library Index
19
	private $filter = null;  // structured representation of the current filter 
20
	
21
	private static $currentVL = null;  // Singleton: current virtual lib
22
	
23
	/**
24
	 * The constructor parses the calibre search string and creates a array-based representation out of it.
25
	 * 
26
	 * @param int $database The database id of the new object.
27
	 * @param int $virtualLib The virtual library id of the new object.
28
	 */
29
	private function __construct($database, $virtualLib) {
30
		$this->db_id  = $database;
31
		$this->vl_id  = $virtualLib;
32
		
33
		// Get the current search string
34
		$vlList = self::getVLList($database);
35
		$vlList = array_values($vlList);
36
		$searchStr = $vlList[$virtualLib];
37
		
38
		$this->filter = self::includeBookFilter(
39
						Filter::parseFilter($searchStr)
40
					);
41
	}
42
	
43
	/**
44
	 * Includes the booke filter (see $config['cops_books_filter'])
45
	 * @param Filter $filter
46
	 * @return string
47
	 */
48
	private static function includeBookFilter($filter) {
49
		$bookFilter = getURLParam ("tag", NULL);
50
		if (empty ($bookFilter)) return $filter;
51
		
52
		$negated = false;
53
		if (preg_match ("/^!(.*)$/", $bookFilter, $matches)) {
54
			$negated = true;
55
			$bookFilter = $matches[1];
56
		}
57
		$bookFilter = new ComparingFilter("tags", $bookFilter, "=");
58
		if ($negated)
59
			$bookFilter->negate();
60
		
61
		$result = new CombinationFilter(array($filter, $bookFilter));
62
		return $result->simplify();
63
	}
64
	
65
	/**
66
	 * Returns a SQL query that finds the IDs of all books accepted by the filter.
67
	 *
68
	 * The sql statement return only one column with the name 'id'.
69
	 * This statement can be included into other sql statements in order to apply the filter, e.g. by using inner joins
70
	 * like "select books.* from books inner join ({0}) as filter on books.id = filter.id"
71
	 * @see Filter
72
	 * 
73
	 * @return string an sql query
74
	 */
75
	public function getFilterQuery() {
76
		return $this->filter->toSQLQuery();
77
	}
78
	
79
	/**
80
	 * Get the name of the virtual library.
81
	 * 
82
	 * @return string Name of the virtual library.
83
	 */
84
	public function getName() {
85
		$names = self::getVLNameList($this->db_id);
86
		return $names[$this->vl_id];
87
	}
88
	
89
	/**
90
	 * Get the current VirtualLib object. 
91
	 *  
92
	 * @param int $database The current database id.
93
	 * @param int $virtualLib The current virtual library id.
94
	 * @return VirtualLib The corresponding VirtualLib object.
95
	 */
96
	public static function getVL($database = null, $virtualLib = null) {
97
		if (is_null($database))
98
			$database = getURLParam(DB, 0);
99
		if ( is_null(self::$currentVL) || self::$currentVL->db_id != $database || (self::$currentVL->vl_id != $virtualLib && !is_null($virtualLib))) {
100
			if (is_null($virtualLib))
101
				$virtualLib = GetUrlParam (VL, 0);
102
			self::$currentVL = new VirtualLib($database, $virtualLib);
103
		}
104
		return self::$currentVL;
105
	}
106
	
107
	/**
108
	 * Checks if the support for virtual libraries is enabled in the settings.
109
	 * 
110
	 * @return boolean true, if virtual libraries are enabled.
111
	 */
112
	public static function isVLEnabled() {
113
		global $config;
114
		return ($config['enable_virtual_libraries'] == 1);
115
	}
116
	
117
	/**
118
	 * Gets a list of all virtual libraries in a database.
119
	 * If virtual libraries are disabled, only an empty entry is returned.
120
	 * 
121
	 * @param int $database id of the database
122
	 * @return array An array of virtual libraries with the names as keys and the filter strings as values.  
123
	 */
124
	public static function getVLList($database = NULL) {
125
		// Standard return if virtual libraries are not enabled
126
		if (!self::isVLEnabled())
127
			return array("" => "");
128
		// Load list from Database
129
		$vLibs = json_decode(Base::getCalibreSetting(self::SQL_VL_KEY, $database), true);
130
		// Add "All Books" at the beginning
131
		if (is_null($vLibs))
132
			return array(localize ("allbooks.title") => "");
133
		else
134
			return array_merge(array(localize ("allbooks.title") => ""), $vLibs);
135
	}
136
	
137
	/**
138
	 * Gets a list of all virtual libraries in a database.
139
	 *
140
	 * @param int $database id of the database
141
	 * @return array An array of virtual libraries with the names as keys and the filter strings as values.
142
	 */
143
	public static function getVLNameList($database = NULL) {
144
		return array_keys(self::getVLList($database));
145
	}
146
	
147
	/**
148
	 * Combines the database and virtual lib names into a merged name.
149
	 * 
150
	 * The resulting name has the form "{$dbName} - {$vlName}". If one of these parameters is empty, the dash will also be removed.
151
	 * If the support for virtual libraries is not enabled, this function simply returns the database name.
152
	 * 
153
	 * @param string $dbName The database name. If NULL, the name of the current db is taken.
154
	 * @param string $vlName The name of the virtual library. If NULL, the name of the current vl is taken.
155
	 */
156
	public static function getDisplayName($dbName = NULL, $vlName = NULL) {
157
		if (is_null($dbName))
158
			$dbName = Base::getDbName();
159
		if (is_null($vlName))
160
			$vlName = self::getVL()->getName();
161
		if (self::isVLEnabled())
162
			return trim(str_format('{0} - {1}', $dbName, $vlName), ' -');
163
		else
164
			return $dbName;
165
	}
166
}
167
168
/**
169
 * Abstract classe to store filters internally. It's derived classes represent the different filter types.
170
 *
171
 */
172
abstract class Filter {
173
	// Special settings for known attributes
174
	private static $KNOWN_ATTRIBUTES = array(
175
		"authors"    => array(),
176
		"series"     => array("link_join_on" => "series"),
177
		"publishers" => array(),
178
		"tags"       => array(),
179
		"ratings"    => array("filterColumn" => "rating"),
180
		"languages"  => array("filterColumn" => "lang_code", "link_join_on" => "lang_code"),
181
		"formats"    => array("table" => "data", "filterColumn" => "format", "link_table" => "data", "link_join_on" => "id"),
182
	);
183
	
184
	private $isNegated = false;
185
	
186
	/**
187
	 * Creates the attribute settings
188
	 * @param string $attr the name of the attribute, e.g. "tags"
189
	 * @return array an assotiative array with the keys "table", "filterColumn", "link_table", "link_join_on", "bookID".
190
	 */
191
	public static function getAttributeSettings($attr) {
192
		$attr = self::normalizeAttribute($attr);
193
		if (!array_key_exists($attr, self::$KNOWN_ATTRIBUTES))
194
			return null;
195
		return self::$KNOWN_ATTRIBUTES[$attr] + array(
196
			"table"        => $attr,
197
			"filterColumn" => "name",
198
			"link_table"   => "books_" . $attr . "_link",
199
			"link_join_on" => substr($attr, 0, strlen($attr) - 1),
200
			"bookID"       => "book"
201
		);
202
	}
203
	
204
	/**
205
	 * Normalizes the attribute. 
206
	 * 
207
	 * Some attributes can be used in plural (e.g. languages) and singular (e.g. language). This function appends a missing s and puts everything to lower case
208
	 * @param string $attr the attribute, like it was used in calibre
209
	 * @return the normalized attribute name
210
	 */
211
	public static function normalizeAttribute($attr) {
212
		$attr = strtolower($attr);
213
		if (substr($attr, -1) != 's')
214
			$attr .= 's';
215
		return $attr;
216
	}
217
	
218
	/**
219
	 * Gets the from - part of a table, its link-table and a placeholder for the filter
220
	 * 
221
	 * @param string $table a table, e.g. "authors"
222
	 * @return string a from string with a placeholder for the filter query
223
	 */
224
	public static function getLinkedTable($table) {
225
		foreach (array_keys(self::$KNOWN_ATTRIBUTES) as $attr) {
226
			$tabInfo = self::getAttributeSettings($attr);
227
			if ($tabInfo["table"] == $table) {
228
				return str_format_n(
229
						"{table} inner join {link_table} as link on {table}.id = link.{link_join_on} 
230
							inner join ({placeholder}) as filter on filter.id = link.{bookID}", $tabInfo + array("placeholder" => "{0}"));
231
			}
232
		}
233
		return $table;
234
	}
235
	/**
236
	 * Converts the calibre search string into afilter object
237
	 *
238
	 * @param string $searchStr The calibre string
239
	 * @return Filter The internal, array-based representation
240
	 */
241
	public static function parseFilter($searchStr) {
242
		// deal with empty input strings
243
		if (strlen($searchStr) == 0)
244
			return new EmptyFilter();
245
	
246
		// Simple search string pattern. It recognizes search string of the form
247
		//     [attr]:[value]
248
		// and their negation
249
		//     not [attr]:[value]
250
		// where value is either a number, a boolean or a string in double quote.
251
		// In the latter case, the string starts with an operator (= or ~), followed by the search text.
252
		// TODO: deal with more complex search terms that can contain "and", "or" and brackets
253
		$pattern = '#(?P<neg>not)?\s*(?P<attr>\w+):(?P<value>"(?P<op>=|~|\>|<|>=|<=)(?P<text>.*)"|true|false|\d+)#i';
254
		if (!preg_match($pattern, $searchStr, $match)) {
255
			trigger_error("Virtual Library Filter is not supported.", E_USER_WARNING);
256
			return new EmptyFilter();
257
		}
258
	
259
		// Create the actual filter object
260
		$value = $match["value"];
261
		$filter   = null;
0 ignored issues
show
Unused Code introduced by
$filter is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
262
		if (substr($value, 0, 1) == '"') {
263
			$filter = new ComparingFilter($match["attr"], $match["text"], $match["op"]);
264
		} elseif (preg_match("#\d+#", $value)) {
265
			$filter = new ComparingFilter($match["attr"], $value);
266
		} else {
267
			$value = (strcasecmp($value, "true") == 0);
268
			$filter = new ExistenceFilter($match["attr"], $value);
269
		}
270
	
271
		// Negate if a leading "not" is given
272
		if (strlen($match["neg"]) > 0)
273
			$filter->negate();
274
	
275
		return $filter;
276
	}
277
	
278
	/**
279
	 * Returns a SQL query that finds the IDs of all books accepted by the filter. The single columns name is id.
280
	 */
281
	public abstract function toSQLQuery();
282
	
283
	/**
284
	 * Negates the current filter. A second call will undo it.
285
	 */
286
	public function negate() {
287
		$this->isNegated = !$this->isNegated;
288
	}
289
	
290
	public function isNegated() {
291
		return $this->isNegated;
292
	}
293
}
294
295
/**
296
 * Class that represents an empty filter
297
 *
298
 */
299
class EmptyFilter extends Filter {
300
	public function __construct() {
301
		// Do Nothing
302
	}
303
	
304
	// Return all books (or no book if the filter is negated)
305
	public function toSQLQuery() {
306
		if ($this->isNegated())
307
			return "select id from books where 1 = 0";
308
		return "select id from books";
309
	}
310
}
311
312
/**
313
 * Class that represents a filter, that compares an attribute with a given value, e.g. tags with "Fiction" 
314
 *
315
 * This class allows for other comparation operators beside "="
316
 */
317
class ComparingFilter extends Filter {
318
	private $attr = null;   // The attribute that is filtered
319
	private $value = null;  // The value with which to compare
320
	private $op = null;     // The operator that is used for comparing
321
	
322
	/**
323
	 * Creates a comparing filter
324
	 * 
325
	 * @param string $attr The attribute that is filtered.
326
	 * @param mixed $value The value with which to compare.
327
	 * @param string $op The operator that is used for comparing, optional.
328
	 */
329
	public function __construct($attr, $value, $op = "=") {
330
		$this->attr = self::normalizeAttribute($attr);
331
		$this->value = $value;
332
		if ($op == "~")
333
			$op = "like";
334
		$this->op = $op;
335
		
336
		// Specialty of ratings
337
		if ($this->attr == "ratings") 
338
			$this->value *= 2;
339
		
340
		// Specialty of languages
341
		// This only works if calibre and cops use the same language!!!
342
		if ($this->attr == "languages")
343
			$this->value = Language::getLanguageCode($this->value);
344
	}
345
	
346
	public function toSQLQuery() {
347
		$queryParams = self::getAttributeSettings($this->attr);
348
		// Do not filter if attribute is not valid
349
		if (is_null($queryParams))
350
			return "select id from books";
351
		
352
		// Include parameters into the sql query 
353
		$queryParams["value"] = $this->value;
354
		$queryParams["op"] = $this->op;
355
		$queryParams["neg"] = $this->isNegated() ? "not" : "";
356
		if ($this->attr == "formats")
357
			$sql = str_format_n(
358
					"select distinct {table}.{bookID} as id ".
359
					"from {table} ".
360
					"where {neg} ({table}.{filterColumn} {op} '{value}')",
361
					$queryParams);
362
		else
363
			$sql = str_format_n(
364
					"select distinct {link_table}.{bookID} as id ".
365
					"from {table} inner join {link_table} on {table}.id = {link_table}.{link_join_on} ".
366
					"where {neg} ({table}.{filterColumn} {op} '{value}')",
367
					$queryParams);
368
		return $sql;
369
	}
370
}
371
372
/**
373
 * Class that represents a filter, that checks if a given attribute exists for a book.
374
 */
375
class ExistenceFilter extends Filter {
376
	private $attr = null;   // The attribute that is filtered
377
378
	/**
379
	 * Creates an existence filter
380
	 *
381
	 * @param string $attr The attribute that is filtered.
382
	 * @param boolean $value True, if objects with that attribute are accepted by the filter, false if not.
383
	 */
384
	public function __construct($attr, $value = true) {
385
		$this->attr = $attr;
386
		
387
		// $value == false is the negation of $value == true 
388
		if (!$value)
389
			$this->negate();
390
	}
391
	
392
	public function toSQLQuery() {
393
		$queryParams = self::getAttributeSettings($this->attr);
394
		// Do not filter if attribute is not valid
395
		if (is_null($queryParams))
396
			return "select id from books";
397
	
398
		// Include parameters into the sql query
399
		$queryParams["op"] = $this->isNegated() ? "==" : ">";
400
		$sql = str_format_n(
401
				"select books.id as id from books left join {link_table} as link on link.{bookID} = books.id group by books.id having count(link.{link_join_on}) {op} 0",
402
				$queryParams);
403
		return $sql;
404
	}
405
}
406
407
/**
408
 * Filter class that represents the combination of two or more filter using and / or
409
 *
410
 */
411
class CombinationFilter extends Filter {
412
	private $op = null;
413
	private $parts = array();
414
415
	/**
416
	 * Constructor that combines multiple filters to one filter
417
	 * @param array $parts An array of Filter objects
418
	 * @param string $op The operator for combining. Either "and" or "or" (case insensitive).
419
	 */
420
	public function __construct($parts, $op = "and") {
421
		$this->parts = $parts;
422
		$this->op = strtolower($op);
423
	}
424
	
425
	/**
426
	 * Checks if the combination is a conjunction (=and)
427
	 * @return boolean true, iff the filter is a conjunction
428
	 */
429
	public function isConjunction() {
430
		return ($this->op == "and");
431
	}
432
	
433
	/**
434
	 * Simplifys the filter, by merging parts that are CombinationFilter objects into this filter, if they have the same operator.
435
	 * This means "(x and y) and z" becomes "x and y and z". 
436
	 * Furthermore, empty filters are removed and if only one part exists, this part is returned. 
437
	 * 
438
	 * @return the simplified filter. The result might not be a CombinationFilter 
439
	 */
440
	public function simplify() {
441
		$newParts = array();
442
		foreach ($this->parts as $part) {
443
			// Simplify inner combination filters
444
			if ($part instanceof CombinationFilter)
445
				$part = $part->simplify();
446
				
447
			if ($part instanceof CombinationFilter && $part->isConjunction() == $this->isConjunction()) {
448
				// Part is a CombinationFilter with the same operator --> merge it's parts into this filter 
449
				foreach ($part->parts as $innerPart)
450
					array_push($newParts, $innerPart);
451
			} elseif ($part instanceof EmptyFilter) {
452
				// A positiv empty filter can be ignored in a conjunction and makes a disjunction always positiv.
453
				// A negative empty filter can be ignored in a disjunction and makes a conjunction always negative.
454
				if ($part->isNegated() == $this->isConjunction())
455
					return $part;
456
			} else 
457
				array_push($newParts, $part);
458
		}
459
		$this->parts = $newParts;
460
		return $this;
461
	}
462
	
463
	public function negate() {
464
		// Use De Morgan's laws to avoid direct negation
465
		
466
		// 1. Negate the operator
467
		if ($this->isConjunction())
468
			$this->op = "or";
469
		else
470
			$this->op = "and";
471
		
472
		// 2. negate all parts
473
		foreach ($this->parts as $filter)
474
			$filer->negate();
0 ignored issues
show
Bug introduced by
The variable $filer does not exist. Did you mean $filter?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
475
	}
476
	
477
	public function toSQLQuery() {
478
		$sql = "";
0 ignored issues
show
Unused Code introduced by
$sql is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
479
		if ($this->isConjunction()) {
480
			// combining all parts by inner joins implements the "and" logic
481
			$sql = str_format("({0}) as f0", $this->parts[0]->toSQLQuery());
482
			for ($i = 1; $i < count($this->parts); $i++)
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
483
				$sql .= str_format(" inner join ({0}) as f{1} on f0.id = f{1}.id", $this->parts[$i]->toSQLQuery(), $i);
484
		} else {
485
			// combining all parts by union implements the "or" logic
486
			$sql = str_format("{0}", $this->parts[0]->toSQLQuery());
487
			for ($i = 1; $i < count($this->parts); $i++)
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
488
				$sql .= str_format(" union {0}", $this->parts[$i]->toSQLQuery());
489
		}
490
		return $sql;
491
	}
492
}