1 <?php
2
3 /**
4 * AfParser class file.
5 *
6 * @author Keith Burton <kburton@kappasoft.net>
7 * @package advancedfilters.components
8 */
9
10 /**
11 * This class is responsible for parsing the entered expression into segments
12 * split by the "and" and "or" delimiters, and determining whether logic should
13 * be inverted.
14 *
15 * The resulting segments are passed to AfBaseFilter to construct an
16 * appropriate filter object.
17 */
18 class AfParser extends CComponent
19 {
20 /**
21 * @var string the string used to 'or' filter expressions together.
22 * Set this to an empty string to remove this functionality.
23 */
24 public $orDelimiter = '|';
25
26 /**
27 * @var string the string used to 'and' filter expressions together.
28 * Set this to an empty string to remove this functionality.
29 */
30 public $andDelimiter = '&';
31
32 /**
33 * @var string the string which can be prepended to a delimiter string to
34 * allow its use within a filter expression.
35 * Set this to an empty string to disallow escaping.
36 */
37 public $escapeSequence = '\\';
38
39 /**
40 * @var string the string which can be prepended to a filter expression in
41 * order to invert its logic and return the opposite results.
42 * You can specify values for both $invertLogicPrefix and
43 * $invertLogicSuffix to require that the expression be enclosed
44 * between two specific strings in order to invert the logic.
45 * Set both to an empty string to prevent logic inversion.
46 */
47 public $invertLogicPrefix = '!';
48
49 /**
50 * @var string the string which can be appended to a filter expression in
51 * order to invert its logic and return the opposite results.
52 * You can specify values for both $invertLogicPrefix and
53 * $invertLogicSuffix to require that the expression be enclosed
54 * between two specific strings in order to invert the logic.
55 * Set both to an empty string to prevent logic inversion.
56 */
57 public $invertLogicSuffix = '';
58
59 /**
60 * @var CDbConnection|string either a CDbConnection object or the string
61 * name of an application component representing a CDbConnection.
62 * Defaults to 'db'.
63 */
64 public $dbConnection = 'db';
65
66 /**
67 * @var array mapping between PDO driver and database helper class path.
68 * Each database helper must extend AfBaseDbHelper.
69 * If the $dbConnection has a driver name that is not specified in this
70 * array, or it maps to null, an AfException will be thrown.
71 */
72 public $driverMap = array(
73 'pgsql'=>null, // PostgreSQL
74 'mysqli'=>'AfMysqlDbHelper', // MySQL
75 'mysql'=>'AfMysqlDbHelper', // MySQL
76 'sqlite'=>null, // sqlite 3
77 'sqlite2'=>null, // sqlite 2
78 'mssql'=>'AfMssqlDbHelper', // Mssql driver on windows
79 'dblib'=>'AfMssqlDbHelper', // dblib drivers on linux
80 'sqlsrv'=>'AfMssqlDbHelper', // Mssql
81 'oci'=>null, // Oracle driver
82 );
83
84 /**
85 * @var array the default filters to load, and the priorities of each.
86 * Lower priority values mean that the pattern will be tested against the
87 * filter earlier, so more specific filters should be given a lower number
88 * than more general filters.
89 * Override the 'active' property to specify whether each filter should be
90 * used when processing filter expressions. The default filter cannot be
91 * deactivated.
92 * Any additional configuration will be applied to the specific filter class
93 * when it is instantiated.
94 */
95 public $filterConfig = array(
96 'range'=>array(
97 'class'=>'AfRangeFilter',
98 'priority'=>10,
99 ),
100 'exact'=>array(
101 'class'=>'AfExactFilter',
102 'priority'=>20,
103 ),
104 'substring'=>array(
105 'class'=>'AfSubstringFilter',
106 'priority'=>30,
107 ),
108 'regex'=>array(
109 'class'=>'AfRegexFilter',
110 'priority'=>40,
111 ),
112 'default'=>array(
113 'class'=>'AfDefaultFilter',
114 'priority'=>50,
115 ),
116 );
117
118 /**
119 * @var array Holds a two level array of "anded" filters grouped inside
120 * "ored" filters.
121 */
122 private $filters = array();
123
124 /**
125 * Parses the provided filter expression into segments and creates an array
126 * of filters to process the full expression.
127 *
128 * This class should not be instantiated directly, but by using methods
129 * of the AdvancedFilters application component.
130 *
131 * @param string $columnExpression the disambiguated column name (or a
132 * valid SQL expression).
133 * @param string $filterExpression the entered filter expression.
134 * @param array $config the configuration array.
135 */
136 public function __construct($columnExpression, $filterExpression, $config)
137 {
138 $this->applyConfig($config);
139
140 // Get concrete database connection and helper objects
141 $dbConnection = $this->getDbConnectionObject();
142 $dbHelper = $this->getDbHelper();
143
144 $filterExpressionParts = $this->tokenize(trim($filterExpression));
145
146 foreach ($filterExpressionParts as $filterExpressionPartOr)
147 {
148 $andFilters = array();
149
150 foreach ($filterExpressionPartOr as $filterExpressionPartAnd)
151 {
152 $invertLogic = $this->processLogicInversion(
153 $filterExpressionPartAnd);
154
155 $andFilters[] = AfBaseFilter::createFilter(
156 $columnExpression, $filterExpressionPartAnd,
157 $invertLogic, $dbConnection, $dbHelper,
158 $this->filterConfig);
159 }
160
161 $this->filters[] = $andFilters;
162 }
163 }
164
165 /**
166 * Returns a CDbCriteria object created by merging the criteria returned
167 * from all filters.
168 *
169 * @return CDbCriteria the criteria object.
170 */
171 public function getCriteria()
172 {
173 $orCriteria = new CDbCriteria;
174
175 foreach ($this->filters as $andFilters)
176 {
177 $andCriteria = new CDbCriteria;
178
179 foreach ($andFilters as $andFilter)
180 $andCriteria->mergeWith($andFilter->getCriteria(), 'AND');
181
182 $orCriteria->mergeWith($andCriteria, 'OR');
183 }
184
185 return $orCriteria;
186 }
187
188 /**
189 * Updates this object's properties from a configuration array.
190 *
191 * @param array $config the configuration array.
192 */
193 private function applyConfig($config)
194 {
195 if (isset($config['orDelimiter']))
196 $this->orDelimiter = $config['orDelimiter'];
197
198 if (isset($config['andDelimiter']))
199 $this->andDelimiter = $config['andDelimiter'];
200
201 if (isset($config['invertLogicPrefix']))
202 $this->invertLogicPrefix = $config['invertLogicPrefix'];
203
204 if (isset($config['invertLogicSuffix']))
205 $this->invertLogicSuffix = $config['invertLogicSuffix'];
206
207 if (isset($config['escapeSequence']))
208 $this->escapeSequence = $config['escapeSequence'];
209
210 if (isset($config['dbConnection']))
211 $this->dbConnection = $config['dbConnection'];
212
213 if (isset($config['driverMap']))
214 $this->driverMap = CMap::mergeArray($this->driverMap,
215 $config['driverMap']);
216
217 if (isset($config['filterConfig']))
218 $this->filterConfig = CMap::mergeArray($this->filterConfig,
219 $config['filterConfig']);
220 }
221
222 /**
223 * Split the provided string on the $orDelimiter and $andDelimiter
224 * characters, removing whitespace on either side of the delimiters.
225 * $escapeSequence, $orDelimiter and $andDelimiter strings can be
226 * escaped with a preceding $escapeSequence string.
227 *
228 * @param string $filterExpression the expression to split.
229 * @return array a two level array of "and" patterns grouped inside "or"
230 * patterns.
231 */
232 private function tokenize($filterExpression)
233 {
234 // A two level array of "anded" expressions grouped inside "ored"
235 // expressions
236 $tokens = array();
237
238 // First split on unescaped $orDelimiter characters as "or" operations
239 // have lower precedence than "and" operations
240 $orSegments = $this->splitOnDelimiter($filterExpression,
241 $this->orDelimiter, $this->escapeSequence);
242
243 foreach ($orSegments as $orSegment)
244 {
245 // Now split each "or" segment on unescaped $andDelimiter characters
246 $andSegments = $this->splitOnDelimiter($orSegment,
247 $this->andDelimiter, $this->escapeSequence, true);
248
249 // Only update the tokens array if non-empty segments are found
250 if (count($andSegments))
251 $tokens[] = $andSegments;
252 }
253
254 return $tokens;
255 }
256
257 /**
258 * Split a string by unescaped delimiters, trimming each segment.
259 *
260 * @param string $string the string to split.
261 * @param string $delimiter the delimiter string. If this is an empty
262 * string, a single element array will be returned containing the original
263 * string trimmed.
264 * @param string $escapeSequence the string which can be prepended to the
265 * delimiter to prevent splitting. The escape sequence should be doubled up
266 * to prevent escaping of the delimiter. If this is an empty string, no
267 * escaping will be performed.
268 * @param boolean $removeEscapeSequences whether to remove escape sequences
269 * from the string. Escape sequences before a delimiter will always be
270 * removed. This should only be set to true when splitting a string segment
271 * for the final time and will transform doubled escape sequences into a
272 * single escape sequence, and remove the escape sequence in scenarios where
273 * it is not valid.
274 * @return array the delimiter separated string segments.
275 */
276 private function splitOnDelimiter($string, $delimiter, $escapeSequence,
277 $removeEscapeSequences = false)
278 {
279 // If the delimiter is empty, trim and return the original string
280 if ($delimiter === '')
281 return array(trim($string));
282
283 $delimiter = strtolower($delimiter);
284 $escapeSequence = strtolower($escapeSequence);
285
286 $delimLength = strlen($delimiter);
287 $escLength = strlen($escapeSequence);
288 $stringLength = strlen($string);
289
290 $segments = array();
291 $currentPos = 0;
292 $extracted = '';
293
294 while ($currentPos < $stringLength)
295 {
296 // Only deal with escapes if the escape character is not empty
297 if ($escapeSequence !== '')
298 {
299 // Check for escape character first
300 $substr = substr($string, $currentPos, $escLength);
301
302 if (strtolower($substr) === $escapeSequence)
303 {
304 // Check for an escaped escape character
305 $substr2 = substr($string, $currentPos + $escLength,
306 $escLength);
307
308 if (strtolower($substr2) === $escapeSequence)
309 {
310 // Only add the first escape if we're not removing escape
311 // characters
312 if (!$removeEscapeSequences)
313 $extracted .= $substr;
314
315 // Add the escaped character and update position
316 $extracted .= $substr2;
317 $currentPos += $escLength * 2;
318 continue;
319 }
320
321 // If we have an escaped delimiter, just add the delimiter
322 $substr = substr($string, $currentPos + $escLength,
323 $delimLength);
324
325 if (strtolower($substr) === $delimiter)
326 {
327 $extracted .= $substr;
328 $currentPos += $escLength + $delimLength;
329 continue;
330 }
331
332 // If it's an unknown escape and we're removing escape
333 // characters, skip this escape sequence
334 if ($removeEscapeSequences)
335 {
336 $currentPos += $escLength;
337 continue;
338 }
339 }
340 }
341
342 // Now check for the delimiter
343 $substr = substr($string, $currentPos, $delimLength);
344
345 // If we're at a delimiter, add the extracted string to the array
346 if (strtolower($substr) === $delimiter)
347 {
348 $extracted = trim($extracted);
349
350 // Only add non-empty segments
351 if ($extracted !== '')
352 $segments[] = $extracted;
353
354 // Update positions and clear extracted string
355 $extracted = '';
356 $currentPos += $delimLength;
357 continue;
358 }
359
360 // If there are no escapes or delimiters to deal with, append the
361 // next character to the extracted string and update positions.
362 $extracted .= substr($string, $currentPos, 1);
363 $currentPos++;
364 }
365
366 $extracted = trim($extracted);
367
368 // Add final extracted string to array if it is not empty
369 if ($extracted !== '')
370 $segments[] = $extracted;
371
372 return $segments;
373 }
374
375 /**
376 * Analyses a filter expression segment to see if it is surrounded by a
377 * logic inversion prefix and suffix. If it is, the prefix and suffix and
378 * any whitespace are trimmed from the string and true is returned.
379 * Otherwise, false is returned.
380 *
381 * @param string $filterExpression a segment which may or may not be
382 * surrounded by a logic inversion prefix and suffix. It is received by
383 * reference and will be stripped and trimmed in place if the prefix and
384 * suffix are found.
385 * @return boolean whether the condition logic should be inverted.
386 */
387 private function processLogicInversion(&$filterExpression)
388 {
389 // If logic inversion is disabled, return early
390 if ($this->invertLogicPrefix === '' && $this->invertLogicSuffix === '')
391 return false;
392
393 $prefix = strtolower($this->invertLogicPrefix);
394 $suffix = strtolower($this->invertLogicSuffix);
395 $string = $filterExpression;
396
397 $prefixLength = strlen($prefix);
398 $suffixLength = strlen($suffix);
399 $stringLength = strlen($filterExpression);
400
401 // No inversion if the string is shorter than the prefix and suffix
402 if ($stringLength < $prefixLength + $suffixLength)
403 return false;
404
405 // No inversion if the prefix doesn't match
406 if (strtolower(substr($string, 0, $prefixLength)) !== $prefix)
407 return false;
408
409 // No inversion if the suffix doesn't match
410 if (strtolower(substr($string, -$suffixLength, $suffixLength))
411 !== $suffix)
412 return false;
413
414 // Inversion has been requested, now strip the filter expression
415 $filterExpression = $stringLength === $prefixLength + $suffixLength ? ''
416 : trim(substr($string, $prefixLength,
417 $stringLength - $prefixLength - $suffixLength));
418
419 return true;
420 }
421
422 /**
423 * @var CDbConnection caches the connection object for the life of this
424 * AfParser object.
425 */
426 private $_dbConnectionObjectCache;
427
428 /**
429 * Returns an instance of CDbConnection determined by the $dbConnection
430 * property.
431 *
432 * @return CDbConnection the connection object.
433 */
434 private function getDbConnectionObject()
435 {
436 if ($this->_dbConnectionObjectCache === null)
437 {
438 $dbConnection = $this->dbConnection;
439
440 if (is_string($dbConnection))
441 $dbConnection = Yii::app()->$dbConnection;
442
443 $this->_dbConnectionObjectCache = $dbConnection;
444 }
445
446 return $this->_dbConnectionObjectCache;
447 }
448
449 /**
450 * @var array maps a class path to a concrete instance of a subclass of
451 * AfBaseDbHelper. The classes contain no state and are shared amongst all
452 * instances of AfParser.
453 */
454 private static $_dbHelperCache = array();
455
456 /**
457 * Gets a database helper object to allow database specific syntax to be
458 * used in criteria conditions.
459 * Database helpers are cached and shared amongst all instances of AfParser.
460 *
461 * @return AfBaseDbHelper the database helper object.
462 * @throws AfException if no database helper is available for the database
463 * connection driver.
464 */
465 private function getDbHelper()
466 {
467 $dbConnection = $this->getDbConnectionObject();
468 $driverName = $dbConnection->getDriverName();
469
470 // Throw an exception if no helper is available for this driver name
471 if (!isset($this->driverMap[$driverName]))
472 {
473 throw new AfException(
474 "No database helper available for driver '$driverName'.");
475 }
476
477 // Get the required helper class path
478 $dbHelperClassPath = $this->driverMap[$driverName];
479
480 // If the cache doesn't yet contain this database helper, add it
481 if (!isset(self::$_dbHelperCache[$dbHelperClassPath]))
482 {
483 self::$_dbHelperCache[$dbHelperClassPath]
484 = Yii::createComponent($dbHelperClassPath);
485 }
486
487 // Return the cached helper object
488 return self::$_dbHelperCache[$dbHelperClassPath];
489 }
490 }
491