Storage   F
last analyzed

Complexity

Total Complexity 161

Size/Duplication

Total Lines 773
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 284
dl 0
loc 773
rs 2
c 3
b 0
f 0
wmc 161

15 Methods

Rating   Name   Duplication   Size   Complexity  
C get_rows() 0 30 13
B read_customfields() 0 30 11
A is_multiple() 0 4 3
A read() 0 12 5
A data_merge() 0 11 4
A search() 0 6 1
A save() 0 14 6
A get_cf_field() 0 3 1
B __construct() 0 50 11
A get_cf_name() 0 3 1
F process_search() 0 282 78
C save_customfields() 0 33 14
A cf_match() 0 24 4
B delete() 0 22 8
A is_cf() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like Storage 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 Storage, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * EGroupware generalized SQL Storage Object with build in custom field support
4
 *
5
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
6
 * @package api
7
 * @subpackage storage
8
 * @link http://www.egroupware.org
9
 * @author Ralf Becker <[email protected]>
10
 * @copyright 2009-16 by [email protected]
11
 * @version $Id$
12
 */
13
14
namespace EGroupware\Api;
15
16
use EGroupware\Api\Storage\Customfields;
17
18
/**
19
 * Generalized SQL Storage Object with build in custom field support
20
 *
21
 * This class allows to display, search, order and filter by custom fields simply by replacing Storage\Base
22
 * by it and adding custom field widgets to the eTemplates of an applications.
23
 * It's inspired by the code from Klaus Leithoff, which does the same thing limited to addressbook.
24
 *
25
 * The schema of the custom fields table should be like (the lenght of the cf name is nowhere enfored and
26
 * varies throughout eGW from 40-255, the value column from varchar(255) to longtext!):
27
 *
28
 * 'egw_app_extra' => array(
29
 * 	'fd' => array(
30
 * 		'prefix_id' => array('type' => 'int','precision' => '4','nullable' => False),
31
 * 		'prefix_name' => array('type' => 'string','precision' => '64','nullable' => False),
32
 * 		'prefix_value' => array('type' => 'text'),
33
 * 	),
34
 *  'pk' => array('prefix_id','prefix_name'),
35
 *	'fk' => array(),
36
 *	'ix' => array(),
37
 *	'uc' => array()
38
 * )
39
 *
40
 * @package etemplate
41
 * @subpackage api
42
 * @author RalfBecker-AT-outdoor-training.de
43
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
44
 */
45
class Storage extends Storage\Base
46
{
47
	/**
48
	 * Prefix used by the class
49
	 */
50
	const CF_PREFIX = '#';
51
52
	/**
53
	 * name of customefields table
54
	 *
55
	 * @var string
56
	 */
57
	var $extra_table;
58
59
	/**
60
	 * name of id column, defaults to the regular tables auto id
61
	 *
62
	 * @var string
63
	 */
64
	var $extra_id = '_id';
65
66
	/**
67
	 * Name of key (cf name) column or just a postfix added to the table prefix
68
	 *
69
	 * @var string
70
	 */
71
	var $extra_key = '_name';
72
73
	/**
74
	 * Name of value column or just a postfix added to the table prefix
75
	 *
76
	 * @var string
77
	 */
78
	var $extra_value = '_value';
79
80
	var $extra_join;
81
	var $extra_join_order;
82
	var $extra_join_filter;
83
84
	/**
85
	 * Does extra table has a unique index (over id and name)
86
	 *
87
	 * @var boolean
88
	 */
89
	var $extra_has_unique_index;
90
91
	/**
92
	 * Custom fields of $app, read by the constructor
93
	 *
94
	 * @var array
95
	 */
96
	var $customfields;
97
98
	/**
99
	 * Do we allow AND store multiple values for a cf (1:N) relations
100
	 *
101
	 * @var boolean
102
	 */
103
	var $allow_multiple_values = false;
104
105
	/**
106
	 * constructor of the class
107
	 *
108
	 * Please note the different params compared to Storage\Base!
109
	 *
110
	 * @param string $app application name to load table schemas
111
	 * @param string $table name of the table to use
112
	 * @param string $extra_table name of the custom field table
113
	 * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it
114
	 * @param string $extra_key ='_name' column name for cf name column (will be prefixed with colum prefix, if starting with _)
115
	 * @param string $extra_value ='_value' column name for cf value column (will be prefixed with colum prefix, if starting with _)
116
	 * @param string $extra_id ='_id' column name for cf id column (will be prefixed with colum prefix, if starting with _)
117
	 * @param Db $db =null database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database
118
	 * @param boolean $no_clone =true can we avoid to clone the db-object, default yes (different from Storage\Base!)
119
	 * 	new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object
120
	 * @param boolean $allow_multiple_values =false should we allow AND store multiple values (1:N relations)
121
	 * @param string $timestamp_type =null default null=leave them as is, 'ts'|'integer' use integer unix timestamps, 'object' use DateTime objects
122
	 */
123
	function __construct($app,$table,$extra_table,$column_prefix='',
124
		$extra_key='_name',$extra_value='_value',$extra_id='_id',
125
		Db $db=null,$no_clone=true,$allow_multiple_values=false,$timestamp_type=null)
126
	{
127
		// calling the Storage\Base constructor
128
		parent::__construct($app,$table,$db,$column_prefix,$no_clone,$timestamp_type);
129
130
		$this->allow_multiple_values = $allow_multiple_values;
131
		$this->extra_table = $extra_table;
132
		if (!$this->extra_id) $this->extra_id = $this->autoinc_id;	// default to auto id of regular table
133
134
		// if names from columns of extra table are only postfixes (starting with _), prepend column prefix
135
		if (!($prefix=$column_prefix))
136
		{
137
			list($prefix) = explode('_',$this->autoinc_id);
138
		}
139
		elseif(substr($prefix,-1) == '_')
140
		{
141
			$prefix = substr($prefix,0,-1);	// remove trailing underscore from column prefix parameter
142
		}
143
		foreach(array(
144
			'extra_id' => $extra_id,
145
			'extra_key' => $extra_key,
146
			'extra_value' => $extra_value
147
		) as $col => $val)
148
		{
149
			$this->$col = $col_name = $val;
150
			if ($col_name[0] == '_') $this->$col = $prefix . $val;
151
		}
152
		// some sanity checks, maybe they should be active only for development
153
		if (!($extra_defs = $this->db->get_table_definitions($app,$extra_table)))
154
		{
155
			throw new Exception\WrongParameter("extra table $extra_table is NOT defined!");
156
		}
157
		foreach(array('extra_id','extra_key','extra_value') as $col)
158
		{
159
			if (!$this->$col || !isset($extra_defs['fd'][$this->$col]))
160
			{
161
				throw new Exception\WrongParameter("$col column $extra_table.{$this->$col} is NOT defined!");
162
			}
163
		}
164
		// check if our extra table has a unique index (if not we have to delete the old values, as replacing does not work!)
165
		$this->extra_has_unique_index = $extra_defs['pk'] || $extra_defs['uc'];
166
167
		// setting up our extra joins, now we know table and column names
168
		$this->extra_join = " LEFT JOIN $extra_table ON $table.$this->autoinc_id=$extra_table.$this->extra_id";
169
		$this->extra_join_order = " LEFT JOIN $extra_table extra_order ON $table.$this->autoinc_id=extra_order.$this->extra_id";
170
		$this->extra_join_filter = " JOIN $extra_table extra_filter ON $table.$this->autoinc_id=extra_filter.$this->extra_id";
171
172
		$this->customfields = Storage\Customfields::get($app, false, null, $db);
173
	}
174
175
	/**
176
	 * Read all customfields of the given id's
177
	 *
178
	 * @param int|array $ids one ore more id's
179
	 * @param array $field_names =null custom fields to read, default all
180
	 * @return array id => $this->cf_field(name) => value
181
	 */
182
	function read_customfields($ids,$field_names=null)
183
	{
184
		if (is_null($field_names)) $field_names = array_keys($this->customfields);
185
186
		foreach((array)$ids as $key => $id)
187
		{
188
			if (!(int)$id && is_array($ids)) unset($ids[$key]);
189
		}
190
		if (!$ids || !$field_names) return array();	// nothing to do
0 ignored issues
show
Bug Best Practice introduced by
The expression $field_names of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
191
192
		$entries = array();
193
		foreach($this->db->select($this->extra_table,'*',array(
194
			$this->extra_id => $ids,
195
			$this->extra_key => $field_names,
196
		),__LINE__,__FILE__,false,'',$this->app) as $row)
197
		{
198
			$entry =& $entries[$row[$this->extra_id]];
199
			if (!is_array($entry)) $entry = array();
200
			$field = $this->get_cf_field($row[$this->extra_key]);
201
202
			if ($this->allow_multiple_values && $this->is_multiple($row[$this->extra_key]))
203
			{
204
				$entry[$field][] = $row[$this->extra_value];
205
			}
206
			else
207
			{
208
				$entry[$field] = $row[$this->extra_value];
209
			}
210
		}
211
		return $entries;
212
	}
213
214
	/**
215
	* saves custom field data
216
	*
217
	* @param array $data data to save (cf's have to be prefixed with self::CF_PREFIX = #)
218
	* @param array $extra_cols =array() extra-data to be saved
219
	* @return bool false on success, errornumber on failure
220
	*/
221
	function save_customfields(&$data, array $extra_cols=array())
222
	{
223
		$id = isset($data[$this->autoinc_id]) ? $data[$this->autoinc_id] : $data[$this->db_key_cols[$this->autoinc_id]];
224
225
		Customfields::handle_files($this->app, $id, $data, $this->customfields);
226
227
		foreach (array_keys((array)$this->customfields) as $name)
228
		{
229
			if (!isset($data[$field = $this->get_cf_field($name)])) continue;
230
231
			$where = array(
232
				$this->extra_id    => $id,
233
				$this->extra_key   => $name,
234
			);
235
			$is_multiple = $this->is_multiple($name);
236
237
			// we explicitly need to delete fields, if value is empty or field allows multiple values or we have no unique index
238
			if(empty($data[$field]) || $is_multiple || !$this->extra_has_unique_index)
239
			{
240
				$this->db->delete($this->extra_table,$where,__LINE__,__FILE__,$this->app);
241
				if (empty($data[$field])) continue;	// nothing else to do for empty values
242
			}
243
			foreach($is_multiple && !is_array($data[$field]) ? explode(',',$data[$field]) :
244
				// regular custom fields (!$is_multiple) eg. addressbook store multiple values comma-separated
245
				(array)(!$is_multiple && is_array($data[$field]) ? implode(',', $data[$field]) : $data[$field]) as $value)
246
			{
247
				if (!$this->db->insert($this->extra_table,array($this->extra_value => $value)+$extra_cols,$where,__LINE__,__FILE__,$this->app))
248
				{
249
					return $this->db->Errno;
250
				}
251
			}
252
		}
253
		return false;	// no error
254
	}
255
256
	/**
257
	 * merges in new values from the given new data-array
258
	 *
259
	 * reimplemented to also merge the customfields
260
	 *
261
	 * @param $new array in form col => new_value with values to set
262
	 */
263
	function data_merge($new)
264
	{
265
		parent::data_merge($new);
266
267
		if ($this->customfields)
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
268
		{
269
			foreach(array_keys($this->customfields) as $name)
270
			{
271
				if (isset($new[$field = $this->get_cf_field($name)]))
272
				{
273
					$this->data[$field] = $new[$field];
274
				}
275
			}
276
		}
277
	}
278
279
	/**
280
	 * reads row matched by key and puts all cols in the data array
281
	 *
282
	 * reimplented to also read the custom fields
283
	 *
284
	 * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key
285
	 * @param string|array $extra_cols string or array of strings to be added to the SELECT, eg. "count(*) as num"
286
	 * @param string $join sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or
287
	 * @return array|boolean data if row could be retrived else False
288
	 */
289
	function read($keys,$extra_cols='',$join='')
290
	{
291
		if (!parent::read($keys,$extra_cols,$join))
292
		{
293
			return false;
294
		}
295
		if (($id = (int)$this->data[$this->db_key_cols[$this->autoinc_id]]) && $this->customfields &&
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
296
			($cfs = $this->read_customfields($id)))
297
		{
298
			$this->data = array_merge($this->data,$cfs[$id]);
299
		}
300
		return $this->data;
301
	}
302
303
	/**
304
	 * saves the content of data to the db
305
	 *
306
	 * reimplented to also save the custom fields
307
	 *
308
	 * @param array $keys if given $keys are copied to data before saveing => allows a save as
309
	 * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows!
310
	 * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected
311
	 */
312
	function save($keys=null,$extra_where=null)
313
	{
314
		if (is_array($keys) && count($keys) && !isset($keys[0]))	// allow to use an etag, eg array('etag=etag+1')
315
		{
316
			$this->data_merge($keys);
317
			$keys = null;
318
		}
319
		$ret = parent::save($keys,$extra_where);
320
321
		if ($ret == 0 && $this->customfields)
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
322
		{
323
			$this->save_customfields($this->data);
324
		}
325
		return $ret;
326
	}
327
328
	/**
329
	 * deletes row representing keys in internal data or the supplied $keys if != null
330
	 *
331
	 * reimplented to also delete the custom fields
332
	 *
333
	 * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id
334
	 * @param boolean $only_return_ids =false return $ids of delete call to db object, but not run it (can be used by extending classes!)
335
	 * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids
336
	 */
337
	function delete($keys=null,$only_return_ids=false)
338
	{
339
		if ($this->customfields || $only_return_ids)
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
340
		{
341
			$query = parent::delete($keys,true);
342
			// check if query contains more then the id's
343
			if (!isset($query[$this->autoinc_id]) || count($query) != 1)
344
			{
345
				foreach($this->db->select($this->table_name,$this->autoinc_id,$query,__LINE__,__FILE__,false,'',$this->app) as $row)
346
				{
347
					$ids[] = $row[$this->autoinc_id];
348
				}
349
				if (!$ids) return 0;	// no rows affected
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $ids seems to be defined by a foreach iteration on line 345. Are you sure the iterator is never empty, otherwise this variable is not defined?
Loading history...
350
			}
351
			else
352
			{
353
				$ids = (array)$query[$this->autoinc_id];
354
			}
355
			if ($only_return_ids) return $ids;
356
			$this->db->delete($this->extra_table,array($this->extra_id => $ids),__LINE__,__FILE__);
357
		}
358
		return parent::delete($keys);
359
	}
360
361
	/**
362
	 * query rows for the nextmatch widget
363
	 *
364
	 * Reimplemented to also read the custom fields (if enabled via $query['selectcols']).
365
	 *
366
	 * Please note: the name of the nextmatch-customfields has to be 'customfields'!
367
	 *
368
	 * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter'
369
	 *	For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class.
370
	 * @param array &$rows returned rows/competitions
371
	 * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class
372
	 * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or
373
	 *	"LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join!
374
	 * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false
375
	 * @param mixed $only_keys =false, see search
376
	 * @param string|array $extra_cols =array()
377
	 * @return int total number of rows
378
	 */
379
	function get_rows($query,&$rows,&$readonlys,$join='',$need_full_no_count=false,$only_keys=false,$extra_cols=array())
380
	{
381
		parent::get_rows($query,$rows,$readonlys,$join,$need_full_no_count,$only_keys,$extra_cols);
382
383
		$selectcols = $query['selectcols'] ? (is_string($query['selectcols']) ? explode(',',$query['selectcols']) :$query['selectcols']): array();
384
385
		if ($rows && $this->customfields && (!$selectcols || in_array('customfields',$selectcols)))
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
386
		{
387
			$id2keys = array();
388
			foreach($rows as $key => $row)
389
			{
390
				$id2keys[$row[$this->db_key_cols[$this->autoinc_id]]] = $key;
391
			}
392
			// check if only certain cf's to show
393
			if (!in_array('customfields', $selectcols))
394
			{
395
				foreach($selectcols as $col)
396
				{
397
					if ($this->is_cf($col)) $fields[] = $this->get_cf_name($col);
398
				}
399
			}
400
			if (($cfs = $this->read_customfields(array_keys($id2keys),$fields)))
401
			{
402
				foreach($cfs as $id => $data)
403
				{
404
					$rows[$id2keys[$id]] = array_merge($rows[$id2keys[$id]],$data);
405
				}
406
			}
407
		}
408
		return $this->total;
409
	}
410
411
	/**
412
	 * Return SQL fragment to search custom-fields for given $pattern
413
	 *
414
	 * To be or-ed to query for $_pattern in regular columns of main-table.
415
	 *
416
	 * @param string $_pattern search pattern incl. * or ? as wildcard, if no wildcards used we append and prepend one!
417
	 * @return string with SQL fragment running on main table: "id IN (SELECT id FROM extra-table WHERE extra_value like '$pattern')"
418
	 */
419
	public function cf_match($_pattern)
420
	{
421
		static $private_cfs=null;
422
423
		if (!$this->customfields) return '';	// no custom-fields --> no search
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customfields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
424
425
		$sql = ' OR '.$this->table_name.'.'.$this->autoinc_id.' IN (SELECT '.$this->extra_id.
426
			' FROM '.$this->extra_table.' WHERE '.$this->extra_value.' '.
427
			$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.
428
			$GLOBALS['egw']->db->quote($_pattern);
429
430
		// check if there are private cfs not available to current user --> filter by available cfs
431
		if (!isset($private_cfs))
432
		{
433
			$private_cfs = array_diff_key(
434
				Storage\Customfields::get($this->app, true, null, $this->db),	// true: get private cfs too
435
				$this->customfields);
436
			//error_log(__METHOD__."() private_cfs=".array2string($private_cfs));
437
		}
438
		if ($private_cfs)
439
		{
440
			$sql .= ' AND '.$this->db->expression($this->extra_table, array($this->extra_key => array_keys($this->customfields)));
441
		}
442
		return $sql.')';
443
	}
444
445
	/**
446
	 * searches db for rows matching searchcriteria
447
	 *
448
	 * Reimplemented to search, order and filter by custom fields
449
	 *
450
	 * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards)
451
	 * @param boolean|string/array $only_keys =true True returns only keys, False returns all cols. or
0 ignored issues
show
Documentation Bug introduced by
The doc comment boolean|string/array at position 2 could not be parsed: Unknown type name 'string/array' at position 2 in boolean|string/array.
Loading history...
452
	 *	comma seperated list or array of columns to return
453
	 * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY)
454
	 * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num"
455
	 * @param string $wildcard ='' appended befor and after each criteria
456
	 * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row
457
	 * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together
458
	 * @param mixed $start =false if != false, return only maxmatch rows begining with start, or array($start,$num), or 'UNION' for a part of a union query
459
	 * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards
460
	 * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or
461
	 *	"LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!!
462
	 * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false
463
	 * @return array|NULL array of matching rows (the row is an array of the cols) or NULL
464
	 */
465
	function &search($criteria,$only_keys=True,$order_by='',$extra_cols='',$wildcard='',$empty=False,$op='AND',$start=false,$filter=null,$join='',$need_full_no_count=false)
466
	{
467
		//error_log(__METHOD__.'('.array2string(array_combine(array_slice(array('criteria','only_keys','order_by','extra_cols','wildcard','empty','op','start','filter','join','need_full_no_count'), 0, count(func_get_args())), func_get_args())).')');
468
		$this->process_search($criteria, $only_keys, $order_by, $extra_cols, $wildcard, $op, $filter, $join);
469
470
		return parent::search($criteria, $only_keys, $order_by, $extra_cols, $wildcard, $empty, $op, $start, $filter, $join, $need_full_no_count);
471
	}
472
473
	/**
474
	 * Full logic of search to be reused in custom search methods
475
	 *
476
	 * Works by modifying the parameters so search calls this method and then it's parent with the modified parameters.
477
	 *
478
	 * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards)
479
	 * @param boolean|string/array $only_keys =true True returns only keys, False returns all cols. or
0 ignored issues
show
Documentation Bug introduced by
The doc comment boolean|string/array at position 2 could not be parsed: Unknown type name 'string/array' at position 2 in boolean|string/array.
Loading history...
480
	 *	comma seperated list or array of columns to return
481
	 * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY)
482
	 * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num"
483
	 * @param string $wildcard ='' appended befor and after each criteria
484
	 * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together
485
	 * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards
486
	 * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or
487
	 *	"LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!!
488
	 */
489
	protected function process_search(&$criteria, &$only_keys=True, &$order_by='', &$extra_cols='', &$wildcard='', &$op='AND', &$filter=null, &$join='')
490
	{
491
		if ($only_keys === false)
492
		{
493
			$only_keys = $this->table_name.'.*';
494
		}
495
		$extra_join_added = $join && strpos($join, $this->extra_join) !== false;
496
		if ($criteria && is_string($criteria))
497
		{
498
			$extra_join_added = true;	// we have NOT added the join, as we use a sub-query and therefore not need it
499
500
			$criteria = $this->search2criteria($criteria, $wildcard, $op);
501
		}
502
		if ($criteria && is_array($criteria))
503
		{
504
			// check if we search in the custom fields
505
			if (isset($criteria[$this->extra_value]))
506
			{
507
				// we should check if the CF is (still) available, but that makes the slow search even slower :(
508
				if (($negate = $criteria[$this->extra_value][0] === '!'))
509
				{
510
					$criteria[$this->extra_value] = substr($criteria[$this->extra_value],1);
511
				}
512
				$criteria[] = $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : '').
513
					$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' .
514
					$this->db->quote($wildcard.$criteria[$this->extra_value].$wildcard);
515
				unset($criteria[$this->extra_value]);
516
			}
517
			// replace ambiguous auto-id with (an exact match of) table_name.autoid
518
			if (isset($criteria[$this->autoinc_id]))
519
			{
520
				if ($criteria[$this->autoinc_id])
521
				{
522
					$criteria[] = $this->db->expression($this->table_name,$this->table_name.'.',
523
						array($this->autoinc_id => $criteria[$this->autoinc_id]));
524
				}
525
				unset($criteria[$this->autoinc_id]);
526
			}
527
			// replace ambiguous column with (an exact match of) table_name.column
528
			foreach($criteria as $name => $val)
529
			{
530
				// only add extra_join, if we really need it
531
				if (!$extra_join_added && (
532
					is_int($name) && strpos($val, $this->extra_value) !== false ||
533
					is_string($name) && $this->is_cf($name)
534
				))
535
				{
536
					$join .= $this->extra_join;
537
					$extra_join_added = true;
538
				}
539
				$extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table);
540
				if(is_string($name) && $extra_columns['fd'][array_search($name, $this->db_cols)])
541
				{
542
					$criteria[] = $this->db->expression($this->table_name,$this->table_name.'.',array(
543
						array_search($name, $this->db_cols) => $val,
544
					));
545
					unset($criteria[$name]);
546
				}
547
				elseif (is_string($name) && $this->is_cf($name))
548
				{
549
					if ($op != 'AND')
550
					{
551
						$name = substr($name, 1);
552
						if (($negate = $criteria[$name][0] === '!'))
553
						{
554
							$val = substr($val,1);
555
						}
556
						$cfcriteria[] = '(' . $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : '').
557
							$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' .
558
							$this->db->quote($wildcard.$val.$wildcard) . ' AND ' .
559
							$this->extra_table.'.'.$this->extra_key . ' = ' . $this->db->quote($name) .
560
							')';
561
						unset($criteria[self::CF_PREFIX.$name]);
562
					}
563
					else
564
					{
565
						// criteria operator is AND we remap the criteria to be transformed to filters
566
						$filter[$name] = $val;
567
						unset($criteria[$name]);
568
					}
569
				}
570
			}
571
			if ($cfcriteria && $op =='OR') $criteria[] = implode(' OR ',$cfcriteria);
572
		}
573
		if($only_keys === true)
574
		{
575
			// Expand to keys here, so table_name can be prepended below
576
			$only_keys = array_values($this->db_key_cols);
577
		}
578
		// replace ambiguous column with (an exact match of) table_name.column
579
		if(is_array($only_keys))
580
		{
581
			foreach($only_keys as $key => &$col)
582
			{
583
				if(is_numeric($key) && in_array($col, $this->db_cols, true))
584
				{
585
					$col = $this->table_name .'.'.array_search($col, $this->db_cols).' AS '.$col;
0 ignored issues
show
Bug introduced by
Are you sure array_search($col, $this->db_cols) of type false|integer|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

585
					$col = $this->table_name .'.'./** @scrutinizer ignore-type */ array_search($col, $this->db_cols).' AS '.$col;
Loading history...
586
				}
587
				// Check to make sure our order by doesn't have aliases that won't work
588
				else if (stripos($col, 'AS') !== false && $order_by)
589
				{
590
					list($value, $alias) = explode(' AS ', $col);
591
					if(stripos($order_by, $alias) !== FALSE && stripos($value, $this->table_name) === FALSE)
592
					{
593
						$order_by = str_replace($alias, $value, $order_by);
594
					}
595
				}
596
			}
597
		}
598
		// check if we order by a custom field --> join cf table for given cf and order by it's value
599
		if (strpos($order_by,self::CF_PREFIX) !== false)
600
		{
601
			// if $order_by contains more then order by columns (eg. group by) split it off before
602
			if (($pos = stripos($order_by, 'order by')) !== false)
603
			{
604
				$group_by = substr($order_by, 0, $pos+9);
605
				$order_by = substr($order_by, $pos+9);
606
			}
607
			// fields to order by, as cutomfields may have names with spaces, we examine each order by criteria
608
			$fields2order = explode(',',$order_by);
609
			foreach($fields2order as $v)
610
			{
611
				if (strpos($v,self::CF_PREFIX) !== false)
612
				{
613
					// we found a customfield, so we split that part by space char in order to get Sorting Direction and Fieldname
614
					$buff = explode(' ',trim($v));
615
					$orderDir = array_pop($buff);
616
					$key = substr(trim(implode(' ',$buff)), 1);
617
					if (!isset($this->customfields[$key]))
618
					{
619
						$order_by = preg_replace('/'.preg_quote($v, '/').',?/', '', $order_by);
620
						continue;	// ignore unavaiable CF
621
					}
622
					switch($this->customfields[$key]['type'])
623
					{
624
						case 'int':
625
							$order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'.
626
								$this->db->to_int('extra_order.'.$this->extra_value).' '.$orderDir, $order_by);
627
							break;
628
						case 'float':
629
							$order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'.
630
								$this->db->to_double('extra_order.'.$this->extra_value).' '.$orderDir, $order_by);
631
							break;
632
						default:
633
							$order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,extra_order.'.
634
								$this->extra_value.' '.$orderDir, $order_by);
635
					}
636
					// postgres requires that expressions in order by appear in the columns of a distinct select
637
					if ($this->db->Type != 'mysql')
638
					{
639
						if (!is_array($extra_cols))
640
						{
641
							$extra_cols = $extra_cols ? explode(',', $extra_cols) : array();
642
						}
643
						$extra_cols[] = 'extra_order.'.$this->extra_value;
644
						$extra_cols[] = 'extra_order.'.$this->extra_value.' IS NULL';
645
					}
646
					$join .= $this->extra_join_order.' AND extra_order.'.$this->extra_key.'='.$this->db->quote($key);
647
				}
648
			}
649
			// add group by again
650
			if (isset($group_by))
651
			{
652
				$order_by = $group_by.$order_by;
653
			}
654
		}
655
		// check if we filter by a custom field
656
		if (is_array($filter))
657
		{
658
			$_cfnames = array_keys($this->customfields);
659
			$extra_filter = null;
660
			foreach($filter as $name => $val)
661
			{
662
				// replace ambiguous auto-id with (an exact match of) table_name.autoid
663
				if (is_string($name) && $name == $this->autoinc_id)
664
				{
665
					if ((int)$filter[$this->autoinc_id])
666
					{
667
						$filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array(
668
							$this->autoinc_id => $filter[$this->autoinc_id],
669
						));
670
					}
671
					unset($filter[$this->autoinc_id]);
672
				}
673
				// replace ambiguous column with (an exact match of) table_name.column
674
				elseif (is_string($name) && $val!=null && in_array($name, $this->db_cols))
675
				{
676
					$extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table);
677
					if ($extra_columns['fd'][array_search($name, $this->db_cols)])
678
					{
679
						$filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array(
680
							array_search($name, $this->db_cols) => $val,
681
						));
682
						unset($filter[$name]);
683
					}
684
				}
685
				elseif (is_string($name) && $this->is_cf($name))
686
				{
687
					$cf_name = $this->get_cf_name($name);
688
					if (!isset($this->customfields[$cf_name]))
689
					{
690
						unset($filter[$name]);
691
						continue;	// ignore unavailable CF
692
					}
693
					if (!empty($val))	// empty -> dont filter
694
					{
695
						if ($val[0] === '!')	// negative filter
696
						{
697
							$sql_filter = 'extra_filter.'.$this->extra_value.'!='.$this->db->quote(substr($val,1));
698
						}
699
						else	// using Db::expression to allow to use array() with possible values or NULL
700
						{
701
							if($this->customfields[$cf_name]['type'] == 'select' &&
702
								$this->customfields[$cf_name]['rows'] > 1)
703
							{
704
								// Multi-select - any entry with the filter value selected matches
705
								$sql_filter = str_replace($this->extra_value,'extra_filter.'.
706
									$this->extra_value,$this->db->expression($this->extra_table,array(
707
										$this->db->concat("','",$this->extra_value,"','").' '.$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote('%,'.$val.',%')
708
									))
709
								);
710
							}
711
							elseif ($this->customfields[$cf_name]['type'] == 'text')
712
							{
713
								$sql_filter = str_replace($this->extra_value,'extra_filter.'.$this->extra_value,
714
										$this->db->expression($this->extra_table,array(
715
										$this->extra_value.' '.$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote($wildcard.$val.$wildcard)
716
									))
717
								);
718
							}
719
							else
720
							{
721
								$sql_filter = str_replace($this->extra_value,'extra_filter.'.
722
									$this->extra_value,$this->db->expression($this->extra_table,array($this->extra_value => $val)));
723
							}
724
						}
725
						// need to use a LEFT JOIN for negative search or to allow NULL values
726
						$need_left_join = $val[0] === '!' || strpos($sql_filter,'IS NULL') !== false ? ' LEFT ' : '';
727
						$join .= str_replace('extra_filter','extra_filter'.$extra_filter,$need_left_join.$this->extra_join_filter.
728
							' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($cf_name).
729
							' AND '.$sql_filter);
730
						++$extra_filter;
731
					}
732
					unset($filter[$name]);
733
				}
734
				elseif(is_int($name) && $this->is_cf($val))	// lettersearch: #cfname LIKE 's%'
735
				{
736
					$_cf = explode(' ',$val);
737
					foreach($_cf as $cf_np)
738
					{
739
						// building cf_name by glueing parts together (, in case someone used whitespace in their custom field names)
740
						$tcf_name = ($tcf_name?$tcf_name.' ':'').$cf_np;
741
						// reacts on the first one found that matches an existing customfield, should be better then the old behavior of
742
						// simply splitting by " " and using the first part
743
						if ($this->is_cf($tcf_name) && ($cfn = $this->get_cf_name($tcf_name)) && array_search($cfn,(array)$_cfnames,true)!==false )
744
						{
745
							$cf = $tcf_name;
746
							break;
747
						}
748
					}
749
					unset($filter[$name]);
750
					$cf_name = $this->get_cf_name($cf);
751
					if (!isset($this->customfields[$cf_name])) continue;	// ignore unavailable CF
752
					$join .= str_replace('extra_filter','extra_filter'.$extra_filter,$this->extra_join_filter.
753
						' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($cf_name).
754
						' AND '.str_replace($cf,'extra_filter.'.$this->extra_value,$val));
755
					++$extra_filter;
756
				}
757
			}
758
		}
759
		// add DISTINCT as by joining custom fields for search a row can be returned multiple times
760
		if ($join && strpos($join, $this->extra_join) !== false)
761
		{
762
			if (is_array($only_keys))
763
			{
764
				$only_keys = array_values($only_keys);
765
				$only_keys[0] = 'DISTINCT '.($only_keys[0] != $this->autoinc_id ? $only_keys[0] :
766
					$this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id);
767
			}
768
			else
769
			{
770
				$only_keys = 'DISTINCT '.$only_keys;
771
			}
772
		}
773
	}
774
775
	/**
776
	 * Function to test if $field is a custom field: check for the prefix
777
	 *
778
	 * @param string $field
779
	 * @return boolean true if $name is a custom field, false otherwise
780
	 */
781
	function is_cf($field)
782
	{
783
		return $field[0] == self::CF_PREFIX;
784
	}
785
786
	/**
787
	 * Get name part from a custom field: remove the prefix
788
	 *
789
	 * @param string $field
790
	 * @return string name without prefix
791
	 */
792
	function get_cf_name($field)
793
	{
794
		return substr($field,1);
795
	}
796
797
	/**
798
	 * Get the field-name from the name of a custom field: prepend the prefix
799
	 *
800
	 * @param string $name
801
	 * @return string prefix-name
802
	 */
803
	function get_cf_field($name)
804
	{
805
		return self::CF_PREFIX.$name;
806
	}
807
808
	/**
809
	 * Check if cf is stored as 1:N relation in DB and array in memory
810
	 *
811
	 * @param string $name
812
	 * @return string
813
	 */
814
	function is_multiple($name)
815
	{
816
		return $this->allow_multiple_values && in_array($this->customfields[$name]['type'],array('select','select-account')) &&
817
			$this->customfields[$name]['rows'] > 1;
818
	}
819
}
820