diff --git a/api/src/Storage.php b/api/src/Storage.php new file mode 100644 index 0000000000..342c188f9c --- /dev/null +++ b/api/src/Storage.php @@ -0,0 +1,744 @@ + + * @copyright 2009-16 by RalfBecker@outdoor-training.de + * @version $Id$ + */ + +namespace EGroupware\Api; + +/** + * Generalized SQL Storage Object with build in custom field support + * + * This class allows to display, search, order and filter by custom fields simply by replacing Storage\Base + * by it and adding custom field widgets to the eTemplates of an applications. + * It's inspired by the code from Klaus Leithoff, which does the same thing limited to addressbook. + * + * The schema of the custom fields table should be like (the lenght of the cf name is nowhere enfored and + * varies throughout eGW from 40-255, the value column from varchar(255) to longtext!): + * + * 'egw_app_extra' => array( + * 'fd' => array( + * 'prefix_id' => array('type' => 'int','precision' => '4','nullable' => False), + * 'prefix_name' => array('type' => 'string','precision' => '64','nullable' => False), + * 'prefix_value' => array('type' => 'text'), + * ), + * 'pk' => array('prefix_id','prefix_name'), + * 'fk' => array(), + * 'ix' => array(), + * 'uc' => array() + * ) + * + * @package etemplate + * @subpackage api + * @author RalfBecker-AT-outdoor-training.de + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ +class Storage extends Storage\Base +{ + /** + * Prefix used by the class + */ + const CF_PREFIX = '#'; + + /** + * name of customefields table + * + * @var string + */ + var $extra_table; + + /** + * name of id column, defaults to the regular tables auto id + * + * @var string + */ + var $extra_id = '_id'; + + /** + * Name of key (cf name) column or just a postfix added to the table prefix + * + * @var string + */ + var $extra_key = '_name'; + + /** + * Name of value column or just a postfix added to the table prefix + * + * @var string + */ + var $extra_value = '_value'; + + var $extra_join; + var $extra_join_order; + var $extra_join_filter; + + /** + * Does extra table has a unique index (over id and name) + * + * @var boolean + */ + var $extra_has_unique_index; + + /** + * Custom fields of $app, read by the constructor + * + * @var array + */ + var $customfields; + + /** + * Do we allow AND store multiple values for a cf (1:N) relations + * + * @var boolean + */ + var $allow_multiple_values = false; + + /** + * constructor of the class + * + * Please note the different params compared to Storage\Base! + * + * @param string $app application name to load table schemas + * @param string $table name of the table to use + * @param string $extra_table name of the custom field table + * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it + * @param string $extra_key ='_name' column name for cf name column (will be prefixed with colum prefix, if starting with _) + * @param string $extra_value ='_value' column name for cf value column (will be prefixed with colum prefix, if starting with _) + * @param string $extra_id ='_id' column name for cf id column (will be prefixed with colum prefix, if starting with _) + * @param Db $db =null database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database + * @param boolean $no_clone =true can we avoid to clone the db-object, default yes (different from Storage\Base!) + * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object + * @param boolean $allow_multiple_values =false should we allow AND store multiple values (1:N relations) + * @param string $timestamp_type =null default null=leave them as is, 'ts'|'integer' use integer unix timestamps, 'object' use DateTime objects + */ + function __construct($app,$table,$extra_table,$column_prefix='', + $extra_key='_name',$extra_value='_value',$extra_id='_id', + Db $db=null,$no_clone=true,$allow_multiple_values=false,$timestamp_type=null) + { + // calling the Storage\Base constructor + parent::__construct($app,$table,$db,$column_prefix,$no_clone,$timestamp_type); + + $this->allow_multiple_values = $allow_multiple_values; + $this->extra_table = $extra_table; + if (!$this->extra_id) $this->extra_id = $this->autoinc_id; // default to auto id of regular table + + // if names from columns of extra table are only postfixes (starting with _), prepend column prefix + if (!($prefix=$column_prefix)) + { + list($prefix) = explode('_',$this->autoinc_id); + } + elseif(substr($prefix,-1) == '_') + { + $prefix = substr($prefix,0,-1); // remove trailing underscore from column prefix parameter + } + foreach(array( + 'extra_id' => $extra_id, + 'extra_key' => $extra_key, + 'extra_value' => $extra_value + ) as $col => $val) + { + $this->$col = $col_name = $val; + if ($col_name[0] == '_') $this->$col = $prefix . $val; + } + // some sanity checks, maybe they should be active only for development + if (!($extra_defs = $this->db->get_table_definitions($app,$extra_table))) + { + throw new Exception\WrongParameter("extra table $extra_table is NOT defined!"); + } + foreach(array('extra_id','extra_key','extra_value') as $col) + { + if (!$this->$col || !isset($extra_defs['fd'][$this->$col])) + { + throw new Exception\WrongParameter("$col column $extra_table.{$this->$col} is NOT defined!"); + } + } + // check if our extra table has a unique index (if not we have to delete the old values, as replacing does not work!) + $this->extra_has_unique_index = $extra_defs['pk'] || $extra_defs['uc']; + + // setting up our extra joins, now we know table and column names + $this->extra_join = " LEFT JOIN $extra_table ON $table.$this->autoinc_id=$extra_table.$this->extra_id"; + $this->extra_join_order = " LEFT JOIN $extra_table extra_order ON $table.$this->autoinc_id=extra_order.$this->extra_id"; + $this->extra_join_filter = " JOIN $extra_table extra_filter ON $table.$this->autoinc_id=extra_filter.$this->extra_id"; + + $this->customfields = Customfields::get($app, false, null, $db); + } + + /** + * Read all customfields of the given id's + * + * @param int|array $ids one ore more id's + * @param array $field_names =null custom fields to read, default all + * @return array id => $this->cf_field(name) => value + */ + function read_customfields($ids,$field_names=null) + { + if (is_null($field_names)) $field_names = array_keys($this->customfields); + + foreach((array)$ids as $key => $id) + { + if (!(int)$id && is_array($ids)) unset($ids[$key]); + } + if (!$ids || !$field_names) return array(); // nothing to do + + $entries = array(); + foreach($this->db->select($this->extra_table,'*',array( + $this->extra_id => $ids, + $this->extra_key => $field_names, + ),__LINE__,__FILE__,false,'',$this->app) as $row) + { + $entry =& $entries[$row[$this->extra_id]]; + if (!is_array($entry)) $entry = array(); + $field = $this->get_cf_field($row[$this->extra_key]); + + if ($this->allow_multiple_values && $this->is_multiple($row[$this->extra_key])) + { + $entry[$field][] = $row[$this->extra_value]; + } + else + { + $entry[$field] = $row[$this->extra_value]; + } + } + return $entries; + } + + /** + * saves custom field data + * + * @param array $data data to save (cf's have to be prefixed with self::CF_PREFIX = #) + * @param array $extra_cols =array() extra-data to be saved + * @return bool false on success, errornumber on failure + */ + function save_customfields($data, array $extra_cols=array()) + { + foreach (array_keys((array)$this->customfields) as $name) + { + if (!isset($data[$field = $this->get_cf_field($name)])) continue; + + $where = array( + $this->extra_id => isset($data[$this->autoinc_id]) ? $data[$this->autoinc_id] : $data[$this->db_key_cols[$this->autoinc_id]], + $this->extra_key => $name, + ); + $is_multiple = $this->is_multiple($name); + + // we explicitly need to delete fields, if value is empty or field allows multiple values or we have no unique index + if(empty($data[$field]) || $is_multiple || !$this->extra_has_unique_index) + { + $this->db->delete($this->extra_table,$where,__LINE__,__FILE__,$this->app); + if (empty($data[$field])) continue; // nothing else to do for empty values + } + foreach($is_multiple && !is_array($data[$field]) ? explode(',',$data[$field]) : + // regular custom fields (!$is_multiple) eg. addressbook store multiple values comma-separated + (array)(!$is_multiple && is_array($data[$field]) ? implode(',', $data[$field]) : $data[$field]) as $value) + { + if (!$this->db->insert($this->extra_table,array($this->extra_value => $value)+$extra_cols,$where,__LINE__,__FILE__,$this->app)) + { + return $this->db->Errno; + } + } + } + return false; // no error + } + + /** + * merges in new values from the given new data-array + * + * reimplemented to also merge the customfields + * + * @param $new array in form col => new_value with values to set + */ + function data_merge($new) + { + parent::data_merge($new); + + if ($this->customfields) + { + foreach(array_keys($this->customfields) as $name) + { + if (isset($new[$field = $this->get_cf_field($name)])) + { + $this->data[$field] = $new[$field]; + } + } + } + } + + /** + * reads row matched by key and puts all cols in the data array + * + * reimplented to also read the custom fields + * + * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key + * @param string|array $extra_cols string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @param string $join sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or + * @return array|boolean data if row could be retrived else False + */ + function read($keys,$extra_cols='',$join='') + { + if (!parent::read($keys,$extra_cols,$join)) + { + return false; + } + if (($id = (int)$this->data[$this->db_key_cols[$this->autoinc_id]]) && $this->customfields && + ($cfs = $this->read_customfields($id))) + { + $this->data = array_merge($this->data,$cfs[$id]); + } + return $this->data; + } + + /** + * saves the content of data to the db + * + * reimplented to also save the custom fields + * + * @param array $keys if given $keys are copied to data before saveing => allows a save as + * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows! + * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected + */ + function save($keys=null,$extra_where=null) + { + if (is_array($keys) && count($keys) && !isset($keys[0])) // allow to use an etag, eg array('etag=etag+1') + { + $this->data_merge($keys); + $keys = null; + } + $ret = parent::save($keys,$extra_where); + + if ($ret == 0 && $this->customfields) + { + $this->save_customfields($this->data); + } + return $ret; + } + + /** + * deletes row representing keys in internal data or the supplied $keys if != null + * + * reimplented to also delete the custom fields + * + * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id + * @param boolean $only_return_ids =false return $ids of delete call to db object, but not run it (can be used by extending classes!) + * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids + */ + function delete($keys=null,$only_return_ids=false) + { + if ($this->customfields || $only_return_ids) + { + $query = parent::delete($keys,true); + // check if query contains more then the id's + if (!isset($query[$this->autoinc_id]) || count($query) != 1) + { + foreach($this->db->select($this->table_name,$this->autoinc_id,$query,__LINE__,__FILE__,false,'',$this->app) as $row) + { + $ids[] = $row[$this->autoinc_id]; + } + if (!$ids) return 0; // no rows affected + } + else + { + $ids = (array)$query[$this->autoinc_id]; + } + if ($only_return_ids) return $ids; + $this->db->delete($this->extra_table,array($this->extra_id => $ids),__LINE__,__FILE__); + } + return parent::delete($keys); + } + + /** + * query rows for the nextmatch widget + * + * Reimplemented to also read the custom fields (if enabled via $query['selectcols']). + * + * Please note: the name of the nextmatch-customfields has to be 'customfields'! + * + * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter' + * For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class. + * @param array &$rows returned rows/competitions + * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class + * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or + * "LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join! + * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false + * @param mixed $only_keys =false, see search + * @param string|array $extra_cols =array() + * @return int total number of rows + */ + function get_rows($query,&$rows,&$readonlys,$join='',$need_full_no_count=false,$only_keys=false,$extra_cols=array()) + { + parent::get_rows($query,$rows,$readonlys,$join,$need_full_no_count,$only_keys,$extra_cols); + + $selectcols = $query['selectcols'] ? explode(',',$query['selectcols']) : array(); + + if ($rows && $this->customfields && (!$selectcols || in_array('customfields',$selectcols))) + { + $id2keys = array(); + foreach($rows as $key => $row) + { + $id2keys[$row[$this->db_key_cols[$this->autoinc_id]]] = $key; + } + // check if only certain cf's to show + if (!in_array('customfields', $selectcols)) + { + foreach($selectcols as $col) + { + if ($this->is_cf($col)) $fields[] = $this->get_cf_name($col); + } + } + if (($cfs = $this->read_customfields(array_keys($id2keys),$fields))) + { + foreach($cfs as $id => $data) + { + $rows[$id2keys[$id]] = array_merge($rows[$id2keys[$id]],$data); + } + } + } + return $this->total; + } + + /** + * searches db for rows matching searchcriteria + * + * Reimplemented to search, order and filter by custom fields + * + * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards) + * @param boolean|string/array $only_keys =true True returns only keys, False returns all cols. or + * comma seperated list or array of columns to return + * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY) + * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @param string $wildcard ='' appended befor and after each criteria + * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row + * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together + * @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 + * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards + * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or + * "LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!! + * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false + * @return array|NULL array of matching rows (the row is an array of the cols) or NULL + */ + 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) + { + //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())).')'); + if (!$this->customfields) + { + return parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,$start,$filter,$join,$need_full_no_count); + } + if ($only_keys === false) + { + $only_keys = $this->table_name.'.*'; + } + // if string given as criteria --> search in all (or $this->columns_to_search) columns including custom fields + if ($criteria && is_string($criteria)) + { + $criteria = $this->search2criteria($criteria,$wildcard,$op); + } + if ($criteria && is_array($criteria)) + { + // check if we search in the custom fields + if (isset($criteria[$this->extra_value])) + { + if (($negate = $criteria[$this->extra_value][0] === '!')) + { + $criteria[$this->extra_value] = substr($criteria[$this->extra_value],1); + } + $criteria[] = $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : ''). + $this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' . + $this->db->quote($wildcard.$criteria[$this->extra_value].$wildcard); + unset($criteria[$this->extra_value]); + } + // replace ambiguous auto-id with (an exact match of) table_name.autoid + if (isset($criteria[$this->autoinc_id])) + { + if ($criteria[$this->autoinc_id]) + { + $criteria[] = $this->db->expression($this->table_name,$this->table_name.'.', + array($this->autoinc_id => $criteria[$this->autoinc_id])); + } + unset($criteria[$this->autoinc_id]); + } + // replace ambiguous column with (an exact match of) table_name.column + $extra_join_added = $join && strpos($join, $this->extra_join) !== false; + foreach($criteria as $name => $val) + { + // only add extra_join, if we really need it + if (!$extra_join_added && ( + is_int($name) && strpos($val, $this->extra_value) !== false || + is_string($name) && $this->is_cf($name) + )) + { + $join .= $this->extra_join; + $extra_join_added = true; + } + $extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table); + if(is_string($name) && $extra_columns['fd'][array_search($name, $this->db_cols)]) + { + $criteria[] = $this->db->expression($this->table_name,$this->table_name.'.',array( + array_search($name, $this->db_cols) => $val, + )); + unset($criteria[$name]); + } + elseif (is_string($name) && $this->is_cf($name)) + { + if ($op != 'AND') + { + $name = substr($name, 1); + if (($negate = $criteria[$name][0] === '!')) + { + $val = substr($val,1); + } + $cfcriteria[] = '(' . $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : ''). + $this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' . + $this->db->quote($wildcard.$val.$wildcard) . ' AND ' . + $this->extra_table.'.'.$this->extra_key . ' = ' . $this->db->quote($name) . + ')'; + unset($criteria[self::CF_PREFIX.$name]); + } + else + { + // criteria operator is AND we remap the criteria to be transformed to filters + $filter[$name] = $val; + unset($criteria[$name]); + } + } + } + if ($cfcriteria && $op =='OR') $criteria[] = implode(' OR ',$cfcriteria); + } + if($only_keys === true) + { + // Expand to keys here, so table_name can be prepended below + $only_keys = array_values($this->db_key_cols); + } + // replace ambiguous column with (an exact match of) table_name.column + if(is_array($only_keys)) + { + foreach($only_keys as $key => &$col) + { + if(is_numeric($key) && in_array($col, $this->db_cols, true)) + { + $col = $this->table_name .'.'.array_search($col, $this->db_cols).' AS '.$col; + } + } + } + // check if we order by a custom field --> join cf table for given cf and order by it's value + if (strpos($order_by,self::CF_PREFIX) !== false) + { + // fields to order by, as cutomfields may have names with spaces, we examine each order by criteria + $fields2order = explode(',',$order_by); + foreach($fields2order as $v) + { + if (strpos($v,self::CF_PREFIX) !== false) + { + // we found a customfield, so we split that part by space char in order to get Sorting Direction and Fieldname + $buff = explode(' ',trim($v)); + $orderDir = array_pop($buff); + $key = substr(trim(implode(' ',$buff)), 1); + switch($this->customfields[$key]['type']) + { + case 'int': + $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'. + $this->db->to_int('extra_order.'.$this->extra_value).' '.$orderDir, $order_by); + break; + case 'float': + $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'. + $this->db->to_double('extra_order.'.$this->extra_value).' '.$orderDir, $order_by); + break; + default: + $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,extra_order.'. + $this->extra_value.' '.$orderDir, $order_by); + } + // postgres requires that expressions in order by appear in the columns of a distinct select + if ($this->db->Type != 'mysql') + { + if (!is_array($extra_cols)) + { + $extra_cols = $extra_cols ? explode(',', $extra_cols) : array(); + } + $extra_cols[] = 'extra_order.'.$this->extra_value; + $extra_cols[] = 'extra_order.'.$this->extra_value.' IS NULL'; + } + $join .= $this->extra_join_order.' AND extra_order.'.$this->extra_key.'='.$this->db->quote($key); + } + } + } + // check if we filter by a custom field + if (is_array($filter)) + { + $_cfnames = array_keys($this->customfields); + $extra_filter = null; + foreach($filter as $name => $val) + { + // replace ambiguous auto-id with (an exact match of) table_name.autoid + if (is_string($name) && $name == $this->autoinc_id) + { + if ((int)$filter[$this->autoinc_id]) + { + $filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array( + $this->autoinc_id => $filter[$this->autoinc_id], + )); + } + unset($filter[$this->autoinc_id]); + } + // replace ambiguous column with (an exact match of) table_name.column + elseif (is_string($name) && $val!=null && in_array($name, $this->db_cols)) + { + $extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table); + if ($extra_columns['fd'][array_search($name, $this->db_cols)]) + { + $filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array( + array_search($name, $this->db_cols) => $val, + )); + unset($filter[$name]); + } + } + elseif (is_string($name) && $this->is_cf($name)) + { + if (!empty($val)) // empty -> dont filter + { + if ($val[0] === '!') // negative filter + { + $sql_filter = 'extra_filter.'.$this->extra_value.'!='.$this->db->quote(substr($val,1)); + } + else // using Db::expression to allow to use array() with possible values or NULL + { + if($this->customfields[$this->get_cf_name($name)]['type'] == 'select' && + $this->customfields[$this->get_cf_name($name)]['rows'] > 1) + { + // Multi-select - any entry with the filter value selected matches + $sql_filter = str_replace($this->extra_value,'extra_filter.'. + $this->extra_value,$this->db->expression($this->extra_table,array( + $this->db->concat("','",$this->extra_value,"','").' '.$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote('%,'.$val.',%') + )) + ); + } + elseif ($this->customfields[$this->get_cf_name($name)]['type'] == 'text') + { + $sql_filter = str_replace($this->extra_value,'extra_filter.'.$this->extra_value, + $this->db->expression($this->extra_table,array( + $this->extra_value.' '.$this->db->capabilities[Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote($wildcard.$val.$wildcard) + )) + ); + } + else + { + $sql_filter = str_replace($this->extra_value,'extra_filter.'. + $this->extra_value,$this->db->expression($this->extra_table,array($this->extra_value => $val))); + } + } + // need to use a LEFT JOIN for negative search or to allow NULL values + $need_left_join = $val[0] === '!' || strpos($sql_filter,'IS NULL') !== false ? ' LEFT ' : ''; + $join .= str_replace('extra_filter','extra_filter'.$extra_filter,$need_left_join.$this->extra_join_filter. + ' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($this->get_cf_name($name)). + ' AND '.$sql_filter); + ++$extra_filter; + } + unset($filter[$name]); + } + elseif(is_int($name) && $this->is_cf($val)) // lettersearch: #cfname LIKE 's%' + { + $_cf = explode(' ',$val); + foreach($_cf as $cf_np) + { + // building cf_name by glueing parts together (, in case someone used whitespace in their custom field names) + $tcf_name = ($tcf_name?$tcf_name.' ':'').$cf_np; + // reacts on the first one found that matches an existing customfield, should be better then the old behavior of + // simply splitting by " " and using the first part + if ($this->is_cf($tcf_name) && ($cfn = $this->get_cf_name($tcf_name)) && array_search($cfn,(array)$_cfnames,true)!==false ) + { + $cf = $tcf_name; + break; + } + } + $join .= str_replace('extra_filter','extra_filter'.$extra_filter,$this->extra_join_filter. + ' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($this->get_cf_name($cf)). + ' AND '.str_replace($cf,'extra_filter.'.$this->extra_value,$val)); + ++$extra_filter; + unset($filter[$name]); + } + } + } + // add DISTINCT as by joining custom fields for search a row can be returned multiple times + if ($join && strpos($join, $this->extra_join) !== false) + { + if (is_array($only_keys)) + { + $only_keys = array_values($only_keys); + $only_keys[0] = 'DISTINCT '.($only_keys[0] != $this->autoinc_id ? $only_keys[0] : + $this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id); + } + else + { + $only_keys = 'DISTINCT '.$only_keys; + } + } + return parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,$start,$filter,$join,$need_full_no_count); + } + + /** + * Get a default list of columns to search + * + * Reimplemented to search custom fields by default. + * + * @return array of column names + */ + protected function get_default_search_columns() + { + $cols = parent::get_default_search_columns(); + if ($this->customfields && !isset($this->columns_to_search)) + { + $cols[] = $this->extra_table.'.'.$this->extra_value; + } + //error_log(__METHOD__."() this->columns_to_search=".array2string($this->columns_to_search).' returning '.array2string($cols)); + return $cols; + } + + /** + * Function to test if $field is a custom field: check for the prefix + * + * @param string $field + * @return boolean true if $name is a custom field, false otherwise + */ + function is_cf($field) + { + return $field[0] == self::CF_PREFIX; + } + + /** + * Get name part from a custom field: remove the prefix + * + * @param string $field + * @return string name without prefix + */ + function get_cf_name($field) + { + return substr($field,1); + } + + /** + * Get the field-name from the name of a custom field: prepend the prefix + * + * @param string $name + * @return string prefix-name + */ + function get_cf_field($name) + { + return self::CF_PREFIX.$name; + } + + /** + * Check if cf is stored as 1:N relation in DB and array in memory + * + * @param string $name + * @return string + */ + function is_multiple($name) + { + return $this->allow_multiple_values && in_array($this->customfields[$name]['type'],array('select','select-account')) && + $this->customfields[$name]['rows'] > 1; + } +} diff --git a/api/src/Storage/Base.php b/api/src/Storage/Base.php new file mode 100644 index 0000000000..d98bd23777 --- /dev/null +++ b/api/src/Storage/Base.php @@ -0,0 +1,1646 @@ + + * @copyright 2002-16 by RalfBecker@outdoor-training.de + * @version $Id$ + */ + +namespace EGroupware\Api\Storage; + +use EGroupware\Api; + +/** + * generalized SQL Storage Object + * + * the class can be used in following ways: + * 1) by calling the constructor with an app and table-name or + * 2) by setting the following documented class-vars in a class derived from this one + * Of cause you can derive from the class and call the constructor with params. + * + * @todo modify search() to return an interator instead of an array + */ +class Base +{ + /** + * need to be set in the derived class to the db-table-name + * + * @var string + */ + var $table_name; + /** + * db-col-name of autoincrement id or '' + * + * @var string + */ + var $autoinc_id = ''; + /** + * all cols in data which are not (direct)in the db, for data_merge + * + * @var array + */ + var $non_db_cols = array(); + /** + * 4 turns on the so_sql debug-messages, default 0 + * + * @var int + */ + var $debug = 0; + /** + * string to be written to db if a col-value is '', eg. "''" or 'NULL' (default) + * + * @var string + */ + var $empty_on_write = 'NULL'; + /** + * total number of entries of last search with start != false + * + * @var int|boolean + */ + var $total = false; + /** + * protected instance or reference (depeding on $no_clone param of constructor) of the db-object + * + * @var Api\Db + */ + protected $db; + /** + * unique keys/index, set by derived class or via so_sql($app,$table) + * + * @var array + */ + var $db_uni_cols = array(); + /** + * db-col-name / internal-name pairs, set by derived calls or via so_sql($app,$table) + * + * @var array + */ + var $db_key_cols = array(); + /** + * db-col-name / internal-name pairs, set by derived calls or via so_sql($app,$table) + * + * @var array + */ + var $db_data_cols = array(); + /** + * @var array $db_cols all columns = $db_key_cols + $db_data_cols, set in the constructor + */ + var $db_cols = array(); + /** + * eGW table definition + * + * @var array + */ + var $table_def = array(); + /** + * Appname to use in all queries, set via constructor + * + * @var string + */ + var $app; + /** + * holds the content of all columns + * + * @var array + */ + var $data = array(); + /** + * Timestaps that need to be adjusted to user-time on reading or saving + * + * @var array + */ + var $timestamps = array(); + /** + * Type of timestamps returned by this class (read and search methods), default null means leave them unchanged + * + * Possible values: + * - 'ts'|'integer' convert every timestamp to an integer unix timestamp + * - 'string' convert every timestamp to a 'Y-m-d H:i:s' string + * - 'object' convert every timestamp to a Api\DateTime object + * + * @var string + */ + public $timestamp_type; + /** + * Offset in secconds between user and server-time, it need to be add to a server-time to get the user-time + * or substracted from a user-time to get the server-time + * + * @var int + * @deprecated use Api\DateTime methods instead, as the offset between user and server time is only valid for current time + */ + var $tz_offset_s; + /** + * Current time in user timezone + * + * @var int|string|DateTime format depends on $this->timestamp_type + */ + var $now; + /** + * Which columns should be searched, if a non-empty string is passed to criteria parameter of search() + * + * If not set (by extending class), all data columns will be searched. + * + * @var array + */ + var $columns_to_search; + + /** + * Table has boolean fields, which need automatic conversation, got set automatic by call to setup_table + * + * Set it to false, if you dont want automatic conversation + * + * @var boolean + */ + protected $has_bools = false; + + /** + * Should search return an iterator (true) or an array (false = default) + * + * @var boolean + */ + public $search_return_iterator = false; + + /** + * constructor of the class + * + * NEED to be called from the constructor of the derived class !!! + * + * @param string $app should be set if table-defs to be read from /setup/tables_current.inc.php + * @param string $table should be set if table-defs to be read from /setup/tables_current.inc.php + * @param Api\Db $db database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database + * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it + * @param boolean $no_clone =false can we avoid to clone the db-object, default no + * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object + * @param string $timestamp_type =null default null=leave them as is, 'ts'|'integer' use integer unix timestamps, + * 'object' use Api\DateTime objects or 'string' use DB timestamp (Y-m-d H:i:s) string + */ + function __construct($app='',$table='',Api\Db $db=null,$column_prefix='',$no_clone=false,$timestamp_type=null) + { + if ($no_clone) + { + $this->db = is_object($db) ? $db : $GLOBALS['egw']->db; + } + else + { + $this->db = is_object($db) ? clone($db) : clone($GLOBALS['egw']->db); + } + $this->db_cols = $this->db_key_cols + $this->db_data_cols; + + if ($app) + { + $this->app = $app; + + if (!$no_clone) $this->db->set_app($app); + + if ($table) $this->setup_table($app,$table,$column_prefix); + } + $this->init(); + + if ((int) $this->debug >= 4) + { + echo "

".__METHOD__."('$app','$table')

\n"; + _debug_array($this); + } + $this->set_times($timestamp_type); + } + + /** + * Set class vars timestamp_type, now and tz_offset_s + * + * @param string|boolean $timestamp_type =false default false do NOT set time_stamptype, + * null=leave them as is, 'ts'|'integer' use integer unix timestamps, 'object' use Api\DateTime objects, + * 'string' use DB timestamp (Y-m-d H:i:s) string + */ + public function set_times($timestamp_type=false) + { + if ($timestamp_type !== false) $this->timestamp_type = $timestamp_type; + + // set current time + switch($this->timestamp_type) + { + case 'object': + $this->now = new Api\DateTime('now'); + break; + case 'string': + $this->now = Api\DateTime::to('now',Api\DateTime::DATABASE); + break; + default: + $this->now = Api\DateTime::to('now','ts'); + } + $this->tz_offset_s = Api\DateTime::tz_offset_s(); + } + + /** + * sets up the class for an app and table (by using the table-definition of $app/setup/tables_current.inc.php + * + * If you need a more complex conversation then just removing the column_prefix, you have to do so in a derifed class !!! + * + * @param string $app app-name $table belongs too + * @param string $table table-name + * @param string $colum_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it + */ + function setup_table($app,$table,$colum_prefix='') + { + $this->table_name = $table; + $this->table_def = $this->db->get_table_definitions($app,$table); + if (!$this->table_def || !is_array($this->table_def['fd'])) + { + throw new Api\Exception\WrongParameter(__METHOD__."('$app','$table'): No table definition for '$table' found !!!"); + } + $this->db_key_cols = $this->db_data_cols = $this->db_cols = array(); + $this->autoinc_id = ''; + $len_prefix = strlen($colum_prefix); + foreach($this->table_def['fd'] as $col => $def) + { + $name = $col; + if ($len_prefix && substr($name,0,$len_prefix) == $colum_prefix) + { + $name = substr($col,$len_prefix); + } + if (in_array($col,$this->table_def['pk'])) + { + $this->db_key_cols[$col] = $name; + } + else + { + $this->db_data_cols[$col] = $name; + } + $this->db_cols[$col] = $name; + + if ($def['type'] == 'auto') + { + $this->autoinc_id = $col; + } + if ($def['type'] == 'bool') $this->has_bools = true; + + foreach($this->table_def['uc'] as $k => $uni_index) + { + if (is_array($uni_index) && in_array($name,$uni_index)) + { + $this->db_uni_cols[$k][$col] = $name; + } + elseif($name === $uni_index) + { + $this->db_uni_cols[$col] = $name; + } + } + } + } + + /** + * Add all timestamp fields to $this->timestamps to get automatically converted to usertime + * + */ + function convert_all_timestamps() + { + $check_already_included = !empty($this->timestamps); + foreach($this->table_def['fd'] as $name => $data) + { + if ($data['type'] == 'timestamp' && (!$check_already_included || !in_array($name,$this->timestamps))) + { + $this->timestamps[] = $name; + } + } + } + + /** + * merges in new values from the given new data-array + * + * @param $new array in form col => new_value with values to set + */ + function data_merge($new) + { + if ((int) $this->debug >= 4) echo "

so_sql::data_merge(".print_r($new,true).")

\n"; + + if (!is_array($new) || !count($new)) + { + return; + } + foreach($this->db_cols as $db_col => $col) + { + if (array_key_exists($col,$new)) + { + $this->data[$col] = $new[$col]; + } + } + foreach($this->non_db_cols as $db_col => $col) + { + if (array_key_exists($col,$new)) + { + $this->data[$col] = $new[$col]; + } + } + if (isset($new[self::USER_TIMEZONE_READ])) + { + $this->data[self::USER_TIMEZONE_READ] = $new[self::USER_TIMEZONE_READ]; + } + if ((int) $this->debug >= 4) _debug_array($this->data); + } + + /** + * changes the data from the db-format to your work-format + * + * It gets called everytime when data is read from the db. + * This default implementation only converts the timestamps mentioned in $this->timestamps from server to user time. + * You can reimplement it in a derived class like this: + * + * function db2data($data=null) + * { + * if (($intern = !is_array($data))) + * { + * $data =& $this->data; + * } + * // do your own modifications here + * + * return parent::db2data($intern ? null : $data); // important to use null, if $intern! + * } + * + * @param array $data =null if given works on that array and returns result, else works on internal data-array + * @return array + */ + function db2data($data=null) + { + if (!is_array($data)) + { + $data = &$this->data; + } + if ($this->timestamps) + { + foreach($this->timestamps as $name) + { + if (isset($data[$name]) && $data[$name]) + { + if ($data[$name] === '0000-00-00 00:00:00') + { + $data[$name] = null; + } + else + { + $data[$name] = Api\DateTime::server2user($data[$name],$this->timestamp_type); + } + } + } + } + // automatic convert booleans (eg. PostgreSQL stores 't' or 'f', which both evaluate to true!) + if ($this->has_bools !== false) + { + if (!isset($this->table_def)) + { + $this->table_def = $this->db->get_table_definitions($this->app, $this->table); + if (!$this->table_def || !is_array($this->table_def['fd'])) + { + throw new Api\Exception\WrongParameter(__METHOD__."(): No table definition for '$this->table' found !!!"); + } + } + foreach($this->table_def['fd'] as $col => $def) + { + if ($def['type'] == 'bool' && isset($data[$col])) + { + $data[$col] = $this->db->from_bool($data[$col]); + } + } + } + return $data; + } + + /** + * changes the data from your work-format to the db-format + * + * It gets called everytime when data gets writen into db or on keys for db-searches. + * This default implementation only converts the timestamps mentioned in $this->timestampfs from user to server time. + * You can reimplement it in a derived class like this: + * + * function data2db($data=null) + * { + * if (($intern = !is_array($data))) + * { + * $data =& $this->data; + * } + * // do your own modifications here + * + * return parent::data2db($intern ? null : $data); // important to use null, if $intern! + * } + * + * @param array $data =null if given works on that array and returns result, else works on internal data-array + * @return array + */ + function data2db($data=null) + { + if (!is_array($data)) + { + $data = &$this->data; + } + if ($this->timestamps) + { + foreach($this->timestamps as $name) + { + if (isset($data[$name]) && $data[$name]) + { + $data[$name] = Api\DateTime::user2server($data[$name],$this->timestamp_type); + } + } + } + return $data; + } + + /** + * initializes data with the content of key + * + * @param array $keys =array() array with keys in form internalName => value + * @return array internal data after init + */ + function init($keys=array()) + { + $this->data = array(); + + $this->db2data(); + + $this->data_merge($keys); + + return $this->data; + } + + /** + * Name of automatically set user timezone field from read + */ + const USER_TIMEZONE_READ = 'user_timezone_read'; + + /** + * reads row matched by key and puts all cols in the data array + * + * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key + * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or + * @return array|boolean data if row could be retrived else False + */ + function read($keys,$extra_cols='',$join='') + { + if (!is_array($keys)) + { + $pk = array_values($this->db_key_cols); + if ($pk) $keys = array($pk[0] => $keys); + } + + $this->init($keys); + $this->data2db(); + + $query = false; + foreach ($this->db_key_cols as $db_col => $col) + { + if ($this->data[$col] != '') + { + $query[$db_col] = $this->data[$col]; + } + } + if (!$query) // no primary key in keys, lets try the data_cols for a unique key + { + foreach($this->db_uni_cols as $db_col => $col) + { + if (!is_array($col) && $this->data[$col] != '') + { + $query[$db_col] = $this->data[$col]; + } + elseif(is_array($col)) + { + $q = array(); + foreach($col as $db_c => $c) + { + if ($this->data[$col] == '') + { + $q = null; + break; + } + $q[$db_c] = $this->data[$c]; + } + if ($q) $query += $q; + } + } + } + if (!$query) // no unique key in keys, lets try everything else + { + foreach($this->db_data_cols as $db_col => $col) + { + if ($this->data[$col] != '') + { + $query[$db_col] = $this->data[$col]; + } + } + } + if (!$query) // keys has no cols + { + $this->db2data(); + + return False; + } + if ($join) // Prefix the columns with the table-name, as they might exist in the join + { + foreach($query as $col => $val) + { + if (is_int($col) || strpos($join,$col) === false) continue; + $query[] = $this->db->expression($this->table_name,$this->table_name.'.',array($col=>$val)); + unset($query[$col]); + } + } + foreach($this->db->select($this->table_name,'*'.($extra_cols?','.(is_array($extra_cols)?implode(',',$extra_cols):$extra_cols):''), + $query,__LINE__,__FILE__,False,'',$this->app,0,$join) as $row) + { + $cols = $this->db_cols; + if ($extra_cols) // extra columns to report + { + foreach(is_array($extra_cols) ? $extra_cols : array($extra_cols) as $col) + { + if (FALSE!==stripos($col,' as ')) $col = preg_replace('/^.* as *([a-z0-9_]+) *$/i','\\1',$col); + $cols[$col] = $col; + } + } + foreach ($cols as $db_col => $col) + { + $this->data[$col] = $row[$db_col]; + } + $this->db2data(); + + // store user timezone used for reading + $this->data[self::USER_TIMEZONE_READ] = Api\DateTime::$user_timezone->getName(); + + if ((int) $this->debug >= 4) + { + echo "data =\n"; _debug_array($this->data); + } + return $this->data; + } + if ($this->autoinc_id) + { + unset($this->data[$this->db_key_cols[$this->autoinc_id]]); + } + if ((int) $this->debug >= 4) echo "nothing found !!!

\n"; + + $this->db2data(); + + return False; + } + + /** + * saves the content of data to the db + * + * @param array $keys =null if given $keys are copied to data before saveing => allows a save as + * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows! + * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected + */ + function save($keys=null,$extra_where=null) + { + if (is_array($keys) && count($keys)) $this->data_merge($keys); + + // check if data contains user timezone during read AND user changed timezone since then + // --> load old timezone for the rest of this request + // this only a grude hack, better handle this situation in app code: + // history logging eg. depends on old data read before calling save, which is then in new timezone! + // anyway it's better fixing it here then not fixing it at all ;-) + if (isset($this->data[self::USER_TIMEZONE_READ]) && $this->data[self::USER_TIMEZONE_READ] != Api\DateTime::$user_timezone->getName()) + { + //echo "

".__METHOD__."() User change TZ since read! tz-read=".$this->data[self::USER_TIMEZONE_READ].' != current-tz='.Api\DateTime::$user_timezone->getName()." --> fixing

\n"; + error_log(__METHOD__."() User changed TZ since read! tz-read=".$this->data[self::USER_TIMEZONE_READ].' != current-tz='.Api\DateTime::$user_timezone->getName()." --> fixing

"); + $GLOBALS['egw_info']['user']['preferences']['common']['tz'] = $this->data[self::USER_TIMEZONE_READ]; + Api\DateTime::setUserPrefs($this->data[self::USER_TIMEZONE_READ]); + $this->set_times(); + } + $this->data2db(); + + if ((int) $this->debug >= 4) { echo "so_sql::save(".print_r($keys,true).") autoinc_id='$this->autoinc_id', data="; _debug_array($this->data); } + + if ($this->autoinc_id && !$this->data[$this->db_key_cols[$this->autoinc_id]]) // insert with auto id + { + foreach($this->db_cols as $db_col => $col) + { + if (!$this->autoinc_id || $db_col != $this->autoinc_id) // not write auto-inc-id + { + if (!array_key_exists($col,$this->data) && // handling of unset columns in $this->data + (isset($this->table_def['fd'][$db_col]['default']) || // we have a default value + !isset($this->table_def['fd'][$db_col]['nullable']) || $this->table_def['fd'][$db_col]['nullable'])) // column is nullable + { + continue; // no need to write that (unset) column + } + if ($this->table_def['fd'][$db_col]['type'] == 'varchar' && + strlen($this->data[$col]) > $this->table_def['fd'][$db_col]['precision']) + { + // truncate the field to mamimum length, if upper layers didn't care + $data[$db_col] = substr($this->data[$col],0,$this->table_def['fd'][$db_col]['precision']); + } + else + { + $data[$db_col] = (string) $this->data[$col] === '' && $this->empty_on_write == 'NULL' ? null : $this->data[$col]; + } + } + } + $this->db->insert($this->table_name,$data,false,__LINE__,__FILE__,$this->app); + + if ($this->autoinc_id) + { + $this->data[$this->db_key_cols[$this->autoinc_id]] = $this->db->get_last_insert_id($this->table_name,$this->autoinc_id); + } + } + else // insert in table without auto id or update of existing row, dont write colums unset in $this->data + { + foreach($this->db_data_cols as $db_col => $col) + { + // we need to update columns set to null: after a $this->data[$col]=null: + // - array_key_exits($col,$this->data) === true + // - isset($this->data[$col]) === false + if (!array_key_exists($col,$this->data) && // handling of unset columns in $this->data + ($this->autoinc_id || // update of table with auto id or + isset($this->table_def['fd'][$db_col]['default']) || // we have a default value or + !isset($this->table_def['fd'][$db_col]['nullable']) || $this->table_def['fd'][$db_col]['nullable'])) // column is nullable + { + continue; // no need to write that (unset) column + } + $data[$db_col] = !is_object($this->data[$col]) && (string) $this->data[$col] === '' && $this->empty_on_write == 'NULL' ? null : $this->data[$col]; + } + // allow to add direct sql updates, eg. "etag=etag+1" with int keys + if (is_array($keys) && isset($keys[0])) + { + for($n=0; isset($keys[$n]); ++$n) + { + $data[] = $keys[$n]; + } + } + $keys = $extra_where; + foreach($this->db_key_cols as $db_col => $col) + { + $keys[$db_col] = $this->data[$col]; + } + if (!$data && !$this->autoinc_id) // happens if all columns are in the primary key + { + $data = $keys; + $keys = False; + } + if ($this->autoinc_id) + { + $this->db->update($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app); + if (($nothing_affected = !$this->db->Errno && !$this->db->affected_rows()) && $extra_where) + { + return true; // extra_where not met, eg. etag wrong + } + } + // always try an insert if we have no autoinc_id, as we dont know if the data exists + if (!$this->autoinc_id || $nothing_affected) + { + $this->db->insert($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app); + } + } + $this->db2data(); + + return $this->db->Errno; + } + + /** + * Update only the given fields, if the primary key is not given, it will be taken from $this->data + * + * @param array $_fields + * @param boolean $merge =true if true $fields will be merged with $this->data (after update!), otherwise $this->data will be just $fields + * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected + */ + function update($_fields,$merge=true) + { + if ($merge) $this->data_merge($_fields); + + $fields = $this->data2db($_fields); + + // extract the keys from $fields or - if not set there - from $this->data + $keys = array(); + foreach($this->db_key_cols as $col => $name) + { + $keys[$col] = isset($fields[$name]) ? $fields[$name] : $this->data[$name]; + unset($fields[$name]); + } + // extract the data from $fields + $data = array(); + foreach($this->db_data_cols as $col => $name) + { + if (array_key_exists($name,$fields)) + { + $data[$col] = $fields[$name]; + unset($fields[$name]); + } + } + // add direct sql like 'etag=etag+1' (it has integer keys) + foreach($fields as $key => $value) + { + if (is_int($key)) + { + $data[] = $value; + } + } + if (!$data) + { + return 0; // nothing to update + } + if (!$this->db->update($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app)) + { + return $this->db->Errno; + } + return 0; + } + + /** + * deletes row representing keys in internal data or the supplied $keys if != null + * + * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id + * @param boolean $only_return_query =false return $query of delete call to db object, but not run it (used by so_sql_cf!) + * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids + */ + function delete($keys=null,$only_return_query=false) + { + if ($this->autoinc_id && $keys && !is_array($keys)) + { + $keys = array($this->autoinc_id => $keys); + } + if (!is_array($keys) || !count($keys)) // use internal data + { + $data = $this->data; + $keys = $this->db_key_cols; + } + else // data and keys are supplied in $keys + { + $data = $keys; $keys = array(); + foreach($this->db_cols as $db_col => $col) + { + if (isset($data[$col])) + { + $keys[$db_col] = $col; + } + } + } + $data = $this->data2db($data); + + foreach($keys as $db_col => $col) + { + $query[$db_col] = $data[$col]; + } + if ($only_return_query) return $query; + + $this->db->delete($this->table_name,$query,__LINE__,__FILE__,$this->app); + + return $this->db->affected_rows(); + } + + /** + * searches db for rows matching searchcriteria + * + * '*' and '?' are replaced with sql-wildcards '%' and '_' + * + * For a union-query you call search for each query with $start=='UNION' and one more with only $order_by and $start set to run the union-query. + * + * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards) + * @param boolean|string|array $only_keys =true True returns only keys, False returns all cols. or + * comma seperated list or array of columns to return + * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY) + * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @param string $wildcard ='' appended befor and after each criteria + * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row + * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together + * @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 + * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards + * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or + * "LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!! + * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false + * @todo return an interator instead of an array + * @return array|NULL array of matching rows (the row is an array of the cols) or NULL + */ + 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) + { + //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())).')'); + if ((int) $this->debug >= 4) echo "

so_sql::search(".print_r($criteria,true).",'$only_keys','$order_by',".print_r($extra_cols,true).",'$wildcard','$empty','$op','$start',".print_r($filter,true).",'$join')

\n"; + + // if extending class or instanciator set columns to search, convert string criteria to array + if ($criteria && !is_array($criteria)) + { + $search = $this->search2criteria($criteria,$wildcard,$op); + $criteria = array($search); + } + if (!is_array($criteria)) + { + $query = $criteria; + } + else + { + $criteria = $this->data2db($criteria); + foreach($criteria as $col => $val) + { + if (is_int($col)) + { + $query[] = $val; + } + elseif ($empty || $val != '') + { + if (!($db_col = array_search($col,$this->db_cols))) + { + $db_col = $col; + } + if ($val === '') + { + if (isset($this->table_def['fd'][$db_col]) && + $this->table_def['fd'][$db_col]['type'] == 'varchar' && + $this->table_def['fd'][$db_col]['nullable'] !== false) + { + unset($criteria[$col]); + $query[] = '(' . $db_col . ' IS NULL OR ' . $db_col . " = '')"; + } + else + { + $query[$db_col] = ''; + } + } + elseif ($wildcard || $criteria[$col][0] == '!' || + is_string($criteria[$col]) && (strpos($criteria[$col],'*')!==false || strpos($criteria[$col],'?')!==false)) + { + // if search pattern alread contains a wildcard, do NOT add further ones automatic + if (is_string($criteria[$col]) && (strpos($criteria[$col],'*')!==false || strpos($criteria[$col],'?')!==false)) + { + $wildcard = ''; + } + $cmp_op = ' '.$this->db->capabilities['case_insensitive_like'].' '; + $negate = false; + if ($criteria[$col][0] == '!') + { + $cmp_op = ' NOT'.$cmp_op; + $criteria[$col] = substr($criteria[$col],1); + $negate = true; + } + foreach(explode(' ',$criteria[$col]) as $crit) + { + $query[] = ($negate ? ' ('.$db_col.' IS NULL OR ' : '').$db_col.$cmp_op. + $this->db->quote($wildcard.str_replace(array('%','_','*','?'),array('\\%','\\_','%','_'),$crit).$wildcard). + ($negate ? ') ' : ''); + } + } + elseif (strpos($db_col,'.') !== false) // we have a table-name specified + { + list($table,$only_col) = explode('.',$db_col); + $type = $this->db->get_column_attribute($only_col, $table, true, 'type'); + if (empty($type)) + { + throw new Api\Db\Exception("Can not determine type of column '$only_col' in table '$table'!"); + } + if (is_array($val) && count($val) > 1) + { + foreach($val as &$v) + { + $v = $this->db->quote($v, $type); + } + $query[] = $sql = $db_col.' IN (' .implode(',',$val).')'; + } + else + { + $query[] = $db_col.'='.$this->db->quote(is_array($val)?array_shift($val):$val,$type); + } + } + else + { + $query[$db_col] = $criteria[$col]; + } + } + } + if (is_array($query) && $op != 'AND') $query = $this->db->column_data_implode(' '.$op.' ',$query); + } + if (is_array($filter)) + { + $db_filter = array(); + $data2db_filter = $this->data2db($filter); + if (!is_array($data2db_filter)) { + echo function_backtrace()."
\n"; + echo "filter=";_debug_array($filter); + echo "data2db(filter)=";_debug_array($data2db_filter); + } + foreach($data2db_filter as $col => $val) + { + if ($val !== '') + { + // check if a db-internal name conversation necessary + if (!is_int($col) && ($c = array_search($col,$this->db_cols))) + { + $col = $c; + } + if(is_int($col)) + { + $db_filter[] = $val; + } + elseif ($val === "!''") + { + $db_filter[] = $this->table_name . '.' .$col." != ''"; + } + else + { + $db_filter[$this->table_name . '.' .$col] = $val; + } + } + } + if ($query) + { + if ($op != 'AND') + { + $db_filter[] = '('.$this->db->column_data_implode(' '.$op.' ',$query).')'; + } + else + { + $db_filter = array_merge($db_filter,$query); + } + } + $query = $db_filter; + } + if ((int) $this->debug >= 4) + { + echo "

so_sql::search(,only_keys=$only_keys,order_by='$order_by',wildcard='$wildcard',empty=$empty,$op,start='$start',".print_r($filter,true).") query=".print_r($query,true).", total='$this->total'

\n"; + echo "
criteria = "; _debug_array($criteria); + } + if ($only_keys === true) + { + $colums = array_keys($this->db_key_cols); + foreach($colums as &$column) + { + $column = $this->table_name . '.' . $column; + } + } + elseif (is_array($only_keys)) + { + $colums = array(); + foreach($only_keys as $key => $col) + { + //Convert ambiguous columns to prefixed tablename.column name + $colums[] = ($db_col = array_search($col,$this->db_cols)) ? $this->table_name .'.'.$db_col.' AS '.$col :$col; + } + } + elseif (!$only_keys) + { + $colums = '*'; + } + else + { + $colums = $only_keys; + } + if ($extra_cols) + { + if (!is_array($colums)) + { + $colums .= ','.(is_array($extra_cols) ? implode(',', $extra_cols) : $extra_cols); + } + else + { + $colums = array_merge($colums, is_array($extra_cols) ? $extra_cols : explode(',', $extra_cols)); + } + } + + // add table-name to otherwise ambiguous id over which we join (incl. "AS id" to return it with the right name) + if ($join && $this->autoinc_id) + { + if (is_array($colums) && ($key = array_search($this->autoinc_id, $colums)) !== false) + { + $colums[$key] = $this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id; + } + elseif (!is_array($colums) && strpos($colums,$this->autoinc_id) !== false) + { + $colums = preg_replace('/(?autoinc_id).'([ ,]+)/','\\1'.$this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id.'\\2',$colums); + } + } + $num_rows = 0; // as spec. in max_matches in the user-prefs + if (is_array($start)) list($start,$num_rows) = $start; + + // fix GROUP BY clause to contain all non-aggregate selected columns + if ($order_by && stripos($order_by,'GROUP BY') !== false) + { + $order_by = $this->fix_group_by_columns($order_by, $colums, $this->table_name, $this->autoinc_id); + } + elseif ($order_by && stripos($order_by,'ORDER BY')===false && stripos($order_by,'GROUP BY')===false && stripos($order_by,'HAVING')===false) + { + $order_by = 'ORDER BY '.$order_by; + } + if (is_array($colums)) + { + $colums = implode(',', $colums); + } + static $union = array(); + static $union_cols = array(); + if ($start === 'UNION' || $union) + { + if ($start === 'UNION') + { + $union[] = array( + 'table' => $this->table_name, + 'cols' => $colums, + 'where' => $query, + 'append' => $order_by, + 'join' => $join, + ); + if (!$union_cols) // union used the colum-names of the first query + { + $union_cols = $this->_get_columns($only_keys,$extra_cols); + } + return true; // waiting for further calls, before running the union-query + } + // running the union query now + if ($start !== false) // need to get the total too, saved in $this->total + { + if ($this->db->Type == 'mysql' && $this->db->ServerInfo['version'] >= 4.0) + { + $union[0]['cols'] = ($mysql_calc_rows = 'SQL_CALC_FOUND_ROWS ').$union[0]['cols']; + } + else // cant do a count, have to run the query without limit + { + $this->total = $this->db->union($union,__LINE__,__FILE__)->NumRows(); + } + } + $rs = $this->db->union($union,__LINE__,__FILE__,$order_by,$start,$num_rows); + if ($this->debug) error_log(__METHOD__."() ".$this->db->Query_ID->sql); + + $cols = $union_cols; + $union = $union_cols = array(); + } + else // no UNION + { + if ($start !== false) // need to get the total too, saved in $this->total + { + if ($this->db->Type == 'mysql' && $this->db->ServerInfo['version'] >= 4.0) + { + $mysql_calc_rows = 'SQL_CALC_FOUND_ROWS '; + } + elseif (!$need_full_no_count && (!$join || stripos($join,'LEFT JOIN')!==false)) + { + $this->total = $this->db->select($this->table_name,'COUNT(*)',$query,__LINE__,__FILE__,false,'',$this->app,0,$join)->fetchColumn(); + } + else // cant do a count, have to run the query without limit + { + $this->total = $this->db->select($this->table_name,$colums,$query,__LINE__,__FILE__,false,$order_by,false,0,$join)->NumRows(); + } + } + $rs = $this->db->select($this->table_name,$mysql_calc_rows.$colums,$query,__LINE__,__FILE__, + $start,$order_by,$this->app,$num_rows,$join); + if ($this->debug) error_log(__METHOD__."() ".$this->db->Query_ID->sql); + $cols = $this->_get_columns($only_keys,$extra_cols); + } + if ((int) $this->debug >= 4) echo "

sql='{$this->db->Query_ID->sql}'

\n"; + + if ($mysql_calc_rows) + { + $this->total = $this->db->query('SELECT FOUND_ROWS()')->fetchColumn(); + } + // ToDo: Implement that as an iterator, as $rs is also an interator and we could return one instead of an array + if ($this->search_return_iterator) + { + return new so_sql_db2data_iterator($this,$rs); + } + $arr = array(); + $n = 0; + if ($rs) foreach($rs as $row) + { + $data = array(); + foreach($cols as $db_col => $col) + { + $data[$col] = (isset($row[$db_col]) ? $row[$db_col] : $row[$col]); + } + $arr[] = $this->db2data($data); + $n++; + } + return $n ? $arr : null; + } + + /** + * Fix GROUP BY clause to contain all non-aggregate selected columns + * + * No need to call for MySQL because MySQL does NOT give an error in above case. + * (Of cause developer has to make sure to group by enough columns, eg. a unique key, for selected columns to be defined.) + * + * MySQL also does not allow to use [tablename.]* in GROUP BY, which PostgreSQL allows! + * (To use this for MySQL too, we would have to replace * with all columns of a table.) + * + * @param string $group_by [GROUP BY ...[HAVING ...]][ORDER BY ...] + * @param string|array $columns better provide an array as exploding by comma can lead to error with functions containing one + * @param string $table_name table-name + * @param string $autoinc_id id-column + * @return string + */ + public static function fix_group_by_columns($group_by, &$columns, $table_name, $autoinc_id) + { + $matches = null; + if ($GLOBALS['egw']->db->Type == 'mysql' || !preg_match('/(GROUP BY .*)(HAVING.*|ORDER BY.*)?$/iU', $group_by, $matches)) + { + return $group_by; // nothing to do + } + $changes = 0; + $group_by_cols = preg_split('/, */', trim(substr($matches[1], 9))); + + if (!is_array($columns)) + { + $columns = preg_split('/, */', $columns); + + // fix columns containing commas as part of function calls + for($n = 0; $n < count($columns); ++$n) + { + $col =& $columns[$n]; + while (substr_count($col, '(') > substr_count($col, ')') && ++$n < count($columns)) + { + $col .= ','.$columns[$n]; + unset($columns[$n]); + } + } + unset($col); + } + foreach($columns as $n => $col) + { + if ($col == '*') + { + // MySQL does NOT allow to GROUP BY table.* + $col = $columns[$n] = $table_name.'.'.($GLOBALS['egw']->db->Type == 'mysql' ? $autoinc_id : '*'); + ++$changes; + } + // only check columns and non-aggregate functions + if (strpos($col, '(') === false || !preg_match('/(COUNT|MIN|MAX|AVG|SUM|BIT_[A-Z]+|STD[A-Z_]*|VAR[A-Z_]*|ARRAY_AGG)\(/i', $col)) + { + if (($pos = stripos($col, 'DISTINCT ')) !== false) + { + $col = substr($col, $pos+9); + } + $alias = $col; + if (stripos($col, ' AS ')) list($col, $alias) = preg_split('/ +AS +/i', $col); + // do NOT group by constant expressions + if (preg_match('/^ *(-?[0-9]+|".*"|\'.*\'|NULL) *$/i', $col)) continue; + if (!in_array($col, $group_by_cols) && !in_array($alias, $group_by_cols)) + { + // instead of aliased primary key, we have to use original column incl. table-name as alias is ambigues + $group_by_cols[] = $col == $table_name.'.'.$autoinc_id ? $col : $alias; + //error_log(__METHOD__."() col=$col, alias=$alias --> group_by_cols=".array2string($group_by_cols)); + ++$changes; + } + } + } + $ret = $group_by; + if ($changes) + { + $ret = str_replace($matches[1], 'GROUP BY '.implode(',', $group_by_cols).' ', $group_by); + //error_log(__METHOD__."('$group_by', ".array2string($columns).") group_by_cols=".array2string($group_by_cols)." changed to $ret"); + } + return $ret; + } + + /** + * Return criteria array for a given search pattern + * + * @param string $_pattern search pattern incl. * or ? as wildcard, if no wildcards used we append and prepend one! + * @param string &$wildcard ='' on return wildcard char to use, if pattern does not already contain wildcards! + * @param string &$op ='AND' on return boolean operation to use, if pattern does not start with ! we use OR else AND + * @param string $extra_col =null extra column to search + * @param array $search_cols =array() List of columns to search. If not provided, all columns in $this->db_cols will be considered + * @return array or column => value pairs + */ + public function search2criteria($_pattern,&$wildcard='',&$op='AND',$extra_col=null, $search_cols = array()) + { + $pattern = trim($_pattern); + // This function can get called multiple times. Make sure it doesn't re-process. + if (empty($pattern) || is_array($pattern)) return $pattern; + if(strpos($pattern, 'CAST(COALESCE(') !== false) + { + return $pattern; + } + + $criteria = array(); + $filter = array(); + $columns = array(); + + /* + * Special handling for numeric columns. They are only considered if the pattern is numeric. + * If the pattern is numeric, an equality search is used instead. + */ + $numeric_types = array('auto', 'int', 'float', 'double', 'decimal'); + $numeric_columns = array(); + + if(!$search_cols) + { + $search_cols = $this->get_default_search_columns(); + } + // Concat all fields to be searched together, so the conditions operate across the whole record + foreach($search_cols as $col) + { + $col_name = $col; + $table = $this->table_name; + if (strpos($col,'.') !== false) + { + list($table,$col_name) = explode('.',$col); + } + $table_def = $table == $this->table_name ? $this->table_def : $this->db->get_table_definitions(true,$table); + if ($table_def['fd'][$col_name] && in_array($table_def['fd'][$col_name]['type'], $numeric_types)) + { + $numeric_columns[] = $col; + continue; + } + if ($this->db->Type == 'mysql' && $table_def['fd'][$col_name]['type'] === 'ascii' && preg_match('/[\x80-\xFF]/', $_pattern)) + { + continue; // will only give sql error + } + $columns[] = sprintf($this->db->capabilities[Api\Db::CAPABILITY_CAST_AS_VARCHAR],"COALESCE($col,'')"); + } + if(!$columns) + { + return array(); + } + + // Break the search string into tokens + $break = ' '; + $token = strtok($pattern, $break); + + while($token) + { + if($token == strtoupper(lang('AND')) || $token == 'AND') + { + $token = '+'.strtok($break); + } + elseif ($token == strtoupper(lang('OR')) || $token == 'OR') + { + $token = strtok($break); + continue; + } + elseif ($token == strtoupper(lang('NOT')) || $token == 'NOT') + { + $token = '-'.strtok($break); + } + if ($token[0]=='"') + { + $token = substr($token, 1,strlen($token)); + if(substr($token, -1) != '"') + { + $token .= ' '.strtok('"'); + } + else + { + $token = substr($token, 0, -1); + } + } + + // prepend and append extra wildcard %, if pattern does NOT already contain wildcards + if (strpos($token,'*') === false && strpos($token,'?') === false) + { + $wildcard = '%'; // if pattern contains no wildcards, add them before AND after the pattern + } + else + { + $wildcard = ''; // no extra wildcard, if pattern already contains some + } + + switch($token[0]) + { + case '+': + $op = 'AND'; + $token = substr($token, 1, strlen($token)); + break; + case '-': + case '!': + $op = 'NOT'; + $token = substr($token, 1, strlen($token)); + break; + default: + $op = 'OR'; + break; + } + $token_filter = ' '.call_user_func_array(array($GLOBALS['egw']->db,'concat'),$columns).' '. + $this->db->capabilities['case_insensitive_like'] . ' ' . + $GLOBALS['egw']->db->quote($wildcard.str_replace(array('%','_','*','?'),array('\\%','\\_','%','_'),$token).$wildcard); + + // Compare numeric token as equality for numeric columns + // skip user-wildcards (*,?) in is_numeric test, but not SQL wildcards, which get escaped and give sql-error + if (is_numeric(str_replace(array('*','?'), '', $token))) + { + $numeric_filter = array(); + foreach($numeric_columns as $col) + { + if($wildcard == '') + { + // Token has a wildcard from user, use LIKE + $numeric_filter[] = "($col IS NOT NULL AND CAST($col AS CHAR) " . + $this->db->capabilities['case_insensitive_like'] . ' ' . + $GLOBALS['egw']->db->quote(str_replace(array('*','?'), array('%','_'), $token)) . ')'; + } + else + { + $numeric_filter[] = "($col IS NOT NULL AND $col = $token)"; + } + } + if(count($numeric_filter) > 0) + { + $token_filter = '(' . $token_filter . ' OR ' . implode(' OR ', $numeric_filter) . ')'; + } + } + $criteria[$op][] = $token_filter; + + $token = strtok($break); + } + + if($criteria['NOT']) + { + $filter[] = 'NOT (' . implode(' OR ', $criteria['NOT']) . ') '; + } + if($criteria['AND']) + { + $filter[] = implode(' AND ', $criteria['AND']) . ' '; + } + if($criteria['OR']) + { + $filter[] = '(' . implode(' OR ', $criteria['OR']) . ') '; + } + + if(count($filter)) + { + $result = '(' . implode(' AND ', $filter) . ')'; + } + + // OR extra column on the end so a null or blank won't block a hit in the main columns + if ($extra_col) + { + $result .= (strlen($result) ? ' OR ' : ' ') . "$extra_col = " . $GLOBALS['egw']->db->quote($pattern); + } + + $op = 'OR'; + return array('(' . $result . ')'); + } + + /** + * Get a default list of columns to search + * This is to be used as a fallback, for when the extending class does not define + * $this->columns_to_search. All the columns are considered, and any with $skip_columns_with in + * their name are discarded because these columns are expected to be foreign keys or other numeric + * values with no meaning to the user. + * + * @return array of column names + */ + protected function get_default_search_columns() + { + $skip_columns_with = array('_id', 'modified', 'modifier', 'status', 'cat_id', 'owner'); + $search_cols = is_null($this->columns_to_search) ? $this->db_cols : $this->columns_to_search; + $numeric_types = array('auto', 'int', 'float', 'double'); + + // Skip some numeric columns that don't make sense to search if we have to default to all columns + if(is_null($this->columns_to_search)) + { + foreach($search_cols as $key => &$col) + { + // If the name as given isn't a real column name, and adding the prefix doesn't help, skip it + if(!$this->table_def['fd'][$col] && !($col = $this->prefix.array_search($col, $search_cols))) { + // Can't search this column + unset($search_cols[$key]); + continue; + } + if(in_array($this->table_def['fd'][$col]['type'], $numeric_types)) + { + foreach($skip_columns_with as $bad) + { + if(strpos($col, $bad) !== false) + { + unset($search_cols[$key]); + continue 2; + } + } + } + // Prefix with table name to avoid ambiguity + $col = $this->table_name.'.'.$col; + } + } + return $search_cols; + } + + /** + * extract the requested columns from $only_keys and $extra_cols param of a search + * + * @internal + * @param boolean|string $only_keys =true True returns only keys, False returns all cols. comma seperated list of keys to return + * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @return array with columns as db-name => internal-name pairs + */ + function _get_columns($only_keys,$extra_cols) + { + //echo "_get_columns() only_keys="; _debug_array($only_keys); echo "extra_cols="; _debug_array($extra_cols); + if ($only_keys === true) // only primary key + { + $cols = $this->db_key_cols; + } + else + { + $cols = array(); + $distinct_checked = false; + foreach(is_array($only_keys) ? $only_keys : explode(',', $only_keys) as $col) + { + if (!$distinct_checked) + { + if (stripos($col, 'DISTINCT ') === 0) $col = substr($col, 9); + $distinct_checked = true; + } + if (!$col || $col == '*' || $col == $this->table_name.'.*') // all columns + { + $cols = array_merge($cols,$this->db_cols); + } + else // only the specified columns + { + if (stripos($col,'as')) // if there's already an explicit naming of the column, just use it + { + $col = preg_replace('/^.*as +([a-z0-9_]+) *$/i','\\1',$col); + $cols[$col] = $col; + continue; + } + if (($db_col = array_search($col,$this->db_cols)) !== false) + { + $cols[$db_col] = $col; + } + else + { + $cols[$col] = isset($this->db_cols[$col]) ? $this->db_cols[$col] : $col; + } + } + } + } + if ($extra_cols) // extra columns to report + { + foreach(is_array($extra_cols) ? $extra_cols : explode(',',$extra_cols) as $col) + { + if (stripos($col,'as ')!==false) $col = preg_replace('/^.*as +([a-z0-9_]+) *$/i','\\1',$col); + if (($db_col = array_search($col,$this->db_cols)) !== false) + { + $cols[$db_col] = $col; + } + else + { + $cols[$col] = isset($this->db_cols[$col]) ? $this->db_cols[$col] : $col; + } + } + } + return $cols; + } + + /** + * query rows for the nextmatch widget + * + * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter' + * For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class. + * @param array &$rows returned rows/competitions + * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class + * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or + * "LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join! + * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false + * @param mixed $only_keys =false, see search + * @param string|array $extra_cols =array() + * @return int total number of rows + */ + function get_rows($query,&$rows,&$readonlys,$join='',$need_full_no_count=false,$only_keys=false,$extra_cols=array()) + { + unset($readonlys); // required by function signature, but not used in this default implementation + if ((int) $this->debug >= 4) + { + echo "

so_sql::get_rows(".print_r($query,true).",,)

\n"; + } + $criteria = array(); + $op = 'AND'; + if ($query['search']) + { + $criteria = $query['search']; + } + $rows = $this->search($criteria,$only_keys,$query['order']?$query['order'].' '.$query['sort']:'',$extra_cols, + '',false,$op,$query['num_rows']?array((int)$query['start'],$query['num_rows']):(int)$query['start'], + $query['col_filter'],$join,$need_full_no_count); + + if (!$rows) $rows = array(); // otherwise false returned from search would be returned as array(false) + + return $this->total; + } + + /** + * Check if values for unique keys and the primary keys are unique are unique + * + * @param array $data =null data-set to check, defaults to $this->data + * @return int 0: all keys are unique, 1: first key not unique, 2: ... + */ + function not_unique($data=null) + { + if (!is_array($data)) + { + $data = $this->data; + } + $n = 1; + $uni_keys = $this->db_uni_cols; + // add the primary key, only if it's NOT an auto id + if (!$this->autoinc_id) + { + $uni_keys[] = $this->db_key_cols; + } + foreach($uni_keys as $db_col => $col) + { + if (is_array($col)) + { + $query = array(); + foreach($col as $db_c => $c) + { + $query[$db_c] = $data[$c]; + } + } + else + { + $query = array($db_col => $data[$col]); + } + foreach($this->db->select($this->table_name,$this->db_key_cols,$query,__LINE__,__FILE__,false,'',$this->app) as $other) + { + foreach($this->db_key_cols as $key_col) + { + if ($data[$key_col] != $other[$key_col]) + { + if ((int) $this->debug >= 4) + { + echo "

not_unique in ".array2string($col)." as for '$key_col': '${data[$key_col]}' != '${other[$key_col]}'

\n"; + } + return $n; // different entry => $n not unique + } + } + } + ++$n; + } + return 0; + } + + /** + * Query DB for a list / array with one colum as key and an other one(s) as value, eg. id => title pairs + * + * We do some caching as these kind of function is usualy called multiple times, eg. for option-lists. + * + * @param string $value_col array of column-names for the values of the array, can also be an expression aliased with AS, + * if more then one column given, an array with keys identical to the given ones is returned and not just the value of the column + * @param string $key_col ='' column-name for the keys, default '' = same as (first) $value_col: returns a distinct list + * @param array $filter =array() to filter the entries + * @param string $order ='' order, default '' = same as (first) $value_col + * @return array with key_col => value_col pairs or array if more then one value_col given (keys as in value_col) + */ + function query_list($value_col,$key_col='',$filter=array(),$order='') + { + static $cache = array(); + + $cache_key = serialize($value_col).'-'.$key_col.'-'.serialize($filter).'-'.$order; + + if (isset($cache[$cache_key])) + { + return $cache[$cache_key]; + } + if (!is_array($value_col)) $value_col = array($value_col); + + $cols = $ret = array(); + foreach($value_col as $key => $col) + { + $matches = null; + $cols[$key] = preg_match('/AS ([a-z_0-9]+)$/i',$col,$matches) ? $matches[1] : $col; + } + if (!$order) $order = current($cols); + + if (($search =& $this->search(array(),($key_col ? $key_col.',' : 'DISTINCT ').implode(',',$value_col),$order,'','',false,'AND',false,$filter))) + { + if (preg_match('/AS ([a-z_0-9]+)$/i',$key_col,$matches)) + { + $key_col = $matches[1]; + } + elseif (!$key_col) + { + $key_col = current($cols); + } + foreach($search as $row) + { + if (count($cols) > 1) + { + $data = array(); + foreach($cols as $key => $col) + { + $data[$key] = $row[$col]; + } + } + else + { + $data = $row[current($cols)]; + } + if ($data) $ret[$row[$key_col]] = $data; + } + } + return $cache[$cache_key] =& $ret; + } + + /** + * Get comments for all columns or a specific one + * + * @param string $column =null name of column or null for all (default) + * @return array|string array with internal-name => comment pairs, or string with comment, if $column given + */ + public function get_comments($column=null) + { + static $comments=null; + + if (is_null($comments)) + { + foreach($this->db_cols as $db_col => $col) + { + $comments[$col] = $this->table_def['fd'][$db_col]['comment']; + } + } + return is_null($column) ? $comments : $comments[$column]; + } +} diff --git a/api/src/Storage/Base2.php b/api/src/Storage/Base2.php new file mode 100644 index 0000000000..37d334b9a6 --- /dev/null +++ b/api/src/Storage/Base2.php @@ -0,0 +1,114 @@ + + * @copyright 2007-16 by RalfBecker@outdoor-training.de + * @version $Id$ + */ + +namespace EGroupware\Api\Storage; + +use EGroupware\Api; + +/** + * generalized SQL Storage Object + * + * the class can be used in following ways: + * 1) by calling the constructor with an app and table-name or + * 2) by setting the following documented class-vars in a class derifed from this one + * Of cause can you derife the class and call the constructor with params. + * + * The Base2 class uses a privat $data array and __get and __set methods to set its data. + * Please note: + * You have to explicitly declare other object-properties of derived classes, which should NOT + * be handled by that mechanism! + */ +class Base2 extends Base +{ + /** + * Private array containing all the object-data + * + * Colides with the original definition in Storage\Base and I dont want to change it there at the moment. + * + * @var array + */ + //private $data; + + /** + * constructor of the class + * + * NEED to be called from the constructor of the derived class !!! + * + * @param string $app should be set if table-defs to be read from /setup/tables_current.inc.php + * @param string $table should be set if table-defs to be read from /setup/tables_current.inc.php + * @param Api\Db $db database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database + * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it + * @param boolean $no_clone =false can we avoid to clone the db-object, default no + * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object + * + * @return so_sql2 + */ + function __construct($app='',$table='',Api\Db $db=null,$column_prefix='',$no_clone=false) + { + parent::__construct($app,$table,$db,$column_prefix,$no_clone); + } + + /** + * magic method to read a property from $this->data + * + * The special property 'id' always refers to the auto-increment id of the object, independent of its name. + * + * @param string $property + * @return mixed + */ + function __get($property) + { + switch($property) + { + case 'id': + $property = $this->autoinc_id; + break; + } + if (in_array($property,$this->db_cols) || in_array($property,$this->non_db_cols)) + { + return $this->data[$property]; + } + } + + /** + * magic method to set a property in $this->data + * + * The special property 'id' always refers to the auto-increment id of the object, independent of its name. + * + * @param string $property + * @param mixed $value + */ + function __set($property,$value) + { + switch($property) + { + case 'id': + $property = $this->autoinc_id; + break; + } + if (in_array($property,$this->db_cols) || in_array($property,$this->non_db_cols)) + { + $this->data[$property] = $value; + } + } + + /** + * Return the whole object-data as array, it's a cast of the object to an array + * + * @return array + */ + function as_array() + { + return $this->data; + } +} diff --git a/api/src/Storage/Db2DataIterator.php b/api/src/Storage/Db2DataIterator.php new file mode 100644 index 0000000000..ee04312e33 --- /dev/null +++ b/api/src/Storage/Db2DataIterator.php @@ -0,0 +1,130 @@ + + * @copyright 2002-16 by RalfBecker@outdoor-training.de + * @version $Id$ + */ + +namespace EGroupware\Api\Storage; + +/** + * Iterator applying a Storage's db2data method on each element retrived + * + */ +class Db2DataIterator implements \Iterator +{ + /** + * Reference of Storage\Base class to use it's db2data method + * + * @var Base + */ + private $storage; + + /** + * Instance of ADOdb record set to iterate + * + * @var \Traversible + */ + private $rs; + + /** + * Total count of entries + * + * @var int + */ + public $total; + + /** + * Constructor + * + * @param Base $storage + * @param \Traversable $rs + */ + public function __construct(Base $storage, \Traversable $rs=null) + { + $this->storage = $storage; + + $this->total = $storage->total; + + if (is_a($rs,'IteratorAggregate')) + { + $this->rs = $rs->getIterator(); + } + else + { + $this->rs = $rs; + } + } + + /** + * Return the current element + * + * @return array + */ + public function current() + { + if (is_a($this->rs,'iterator')) + { + $data = $this->rs->current(); + + return $this->storage->data2db($data); + } + return null; + } + + /** + * Return the key of the current element + * + * @return int + */ + public function key() + { + if (is_a($this->rs,'iterator')) + { + return $this->rs->key(); + } + return 0; + } + + /** + * Move forward to next element (called after each foreach loop) + */ + public function next() + { + if (is_a($this->rs,'iterator')) + { + return $this->rs->next(); + } + } + + /** + * Rewind the Iterator to the first element (called at beginning of foreach loop) + */ + public function rewind() + { + if (is_a($this->rs,'iterator')) + { + return $this->rs->rewind(); + } + } + + /** + * Checks if current position is valid + * + * @return boolean + */ + public function valid () + { + if (is_a($this->rs,'iterator')) + { + return $this->rs->valid(); + } + return false; + } +} diff --git a/etemplate/inc/class.so_sql.inc.php b/etemplate/inc/class.so_sql.inc.php index 76afa3d744..06ce29cad2 100644 --- a/etemplate/inc/class.so_sql.inc.php +++ b/etemplate/inc/class.so_sql.inc.php @@ -1,15 +1,17 @@ - * @copyright 2002-14 by RalfBecker@outdoor-training.de + * @copyright 2002-16 by RalfBecker@outdoor-training.de * @version $Id$ */ +use EGroupware\Api; + /** * generalized SQL Storage Object * @@ -18,1746 +20,13 @@ * 2) by setting the following documented class-vars in a class derived from this one * Of cause you can derive from the class and call the constructor with params. * - * @package etemplate - * @subpackage api - * @author RalfBecker-AT-outdoor-training.de - * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License - * @todo modify search() to return an interator instead of an array + * @deprecated use Api\Storage\Base */ -class so_sql -{ - /** - * need to be set in the derived class to the db-table-name - * - * @var string - */ - var $table_name; - /** - * db-col-name of autoincrement id or '' - * - * @var string - */ - var $autoinc_id = ''; - /** - * all cols in data which are not (direct)in the db, for data_merge - * - * @var array - */ - var $non_db_cols = array(); - /** - * 4 turns on the so_sql debug-messages, default 0 - * - * @var int - */ - var $debug = 0; - /** - * string to be written to db if a col-value is '', eg. "''" or 'NULL' (default) - * - * @var string - */ - var $empty_on_write = 'NULL'; - /** - * total number of entries of last search with start != false - * - * @var int|boolean - */ - var $total = false; - /** - * protected instance or reference (depeding on $no_clone param of constructor) of the db-object - * - * @var egw_db - */ - protected $db; - /** - * unique keys/index, set by derived class or via so_sql($app,$table) - * - * @var array - */ - var $db_uni_cols = array(); - /** - * db-col-name / internal-name pairs, set by derived calls or via so_sql($app,$table) - * - * @var array - */ - var $db_key_cols = array(); - /** - * db-col-name / internal-name pairs, set by derived calls or via so_sql($app,$table) - * - * @var array - */ - var $db_data_cols = array(); - /** - * @var array $db_cols all columns = $db_key_cols + $db_data_cols, set in the constructor - */ - var $db_cols = array(); - /** - * eGW table definition - * - * @var array - */ - var $table_def = array(); - /** - * Appname to use in all queries, set via constructor - * - * @var string - */ - var $app; - /** - * holds the content of all columns - * - * @var array - */ - var $data = array(); - /** - * Timestaps that need to be adjusted to user-time on reading or saving - * - * @var array - */ - var $timestamps = array(); - /** - * Type of timestamps returned by this class (read and search methods), default null means leave them unchanged - * - * Possible values: - * - 'ts'|'integer' convert every timestamp to an integer unix timestamp - * - 'string' convert every timestamp to a 'Y-m-d H:i:s' string - * - 'object' convert every timestamp to a egw_time object - * - * @var string - */ - public $timestamp_type; - /** - * Offset in secconds between user and server-time, it need to be add to a server-time to get the user-time - * or substracted from a user-time to get the server-time - * - * @var int - * @deprecated use egw_time methods instead, as the offset between user and server time is only valid for current time - */ - var $tz_offset_s; - /** - * Current time in user timezone - * - * @var int|string|DateTime format depends on $this->timestamp_type - */ - var $now; - /** - * Which columns should be searched, if a non-empty string is passed to criteria parameter of search() - * - * If not set (by extending class), all data columns will be searched. - * - * @var array - */ - var $columns_to_search; - - /** - * Table has boolean fields, which need automatic conversation, got set automatic by call to setup_table - * - * Set it to false, if you dont want automatic conversation - * - * @var boolean - */ - protected $has_bools = false; - - /** - * Should search return an iterator (true) or an array (false = default) - * - * @var boolean - */ - public $search_return_iterator = false; - - /** - * constructor of the class - * - * NEED to be called from the constructor of the derived class !!! - * - * @param string $app should be set if table-defs to be read from /setup/tables_current.inc.php - * @param string $table should be set if table-defs to be read from /setup/tables_current.inc.php - * @param egw_db $db database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database - * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it - * @param boolean $no_clone =false can we avoid to clone the db-object, default no - * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object - * @param string $timestamp_type =null default null=leave them as is, 'ts'|'integer' use integer unix timestamps, - * 'object' use egw_time objects or 'string' use DB timestamp (Y-m-d H:i:s) string - * - * @return so_sql - */ - function __construct($app='',$table='',$db=null,$column_prefix='',$no_clone=false,$timestamp_type=null) - { - if ($no_clone) - { - $this->db = is_object($db) ? $db : $GLOBALS['egw']->db; - } - else - { - $this->db = is_object($db) ? clone($db) : clone($GLOBALS['egw']->db); - } - $this->db_cols = $this->db_key_cols + $this->db_data_cols; - - if ($app) - { - $this->app = $app; - - if (!$no_clone) $this->db->set_app($app); - - if ($table) $this->setup_table($app,$table,$column_prefix); - } - $this->init(); - - if ((int) $this->debug >= 4) - { - echo "

so_sql('$app','$table')

\n"; - _debug_array($this); - } - $this->set_times($timestamp_type); - } - - /** - * Set class vars timestamp_type, now and tz_offset_s - * - * @param string|boolean $timestamp_type =false default false do NOT set time_stamptype, - * null=leave them as is, 'ts'|'integer' use integer unix timestamps, 'object' use egw_time objects, - * 'string' use DB timestamp (Y-m-d H:i:s) string - */ - public function set_times($timestamp_type=false) - { - if ($timestamp_type !== false) $this->timestamp_type = $timestamp_type; - - // set current time - switch($this->timestamp_type) - { - case 'object': - $this->now = new egw_time('now'); - break; - case 'string': - $this->now = egw_time::to('now',egw_time::DATABASE); - break; - default: - $this->now = egw_time::to('now','ts'); - } - $this->tz_offset_s = egw_time::tz_offset_s(); - } - - /** - * sets up the class for an app and table (by using the table-definition of $app/setup/tables_current.inc.php - * - * If you need a more complex conversation then just removing the column_prefix, you have to do so in a derifed class !!! - * - * @param string $app app-name $table belongs too - * @param string $table table-name - * @param string $colum_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it - */ - function setup_table($app,$table,$colum_prefix='') - { - $this->table_name = $table; - $this->table_def = $this->db->get_table_definitions($app,$table); - if (!$this->table_def || !is_array($this->table_def['fd'])) - { - throw new egw_exception_wrong_parameter(__METHOD__."('$app','$table'): No table definition for '$table' found !!!"); - } - $this->db_key_cols = $this->db_data_cols = $this->db_cols = array(); - $this->autoinc_id = ''; - $len_prefix = strlen($colum_prefix); - foreach($this->table_def['fd'] as $col => $def) - { - $name = $col; - if ($len_prefix && substr($name,0,$len_prefix) == $colum_prefix) - { - $name = substr($col,$len_prefix); - } - if (in_array($col,$this->table_def['pk'])) - { - $this->db_key_cols[$col] = $name; - } - else - { - $this->db_data_cols[$col] = $name; - } - $this->db_cols[$col] = $name; - - if ($def['type'] == 'auto') - { - $this->autoinc_id = $col; - } - if ($def['type'] == 'bool') $this->has_bools = true; - - foreach($this->table_def['uc'] as $k => $uni_index) - { - if (is_array($uni_index) && in_array($name,$uni_index)) - { - $this->db_uni_cols[$k][$col] = $name; - } - elseif($name === $uni_index) - { - $this->db_uni_cols[$col] = $name; - } - } - } - } - - /** - * Add all timestamp fields to $this->timestamps to get automatically converted to usertime - * - */ - function convert_all_timestamps() - { - $check_already_included = !empty($this->timestamps); - foreach($this->table_def['fd'] as $name => $data) - { - if ($data['type'] == 'timestamp' && (!$check_already_included || !in_array($name,$this->timestamps))) - { - $this->timestamps[] = $name; - } - } - } - - /** - * merges in new values from the given new data-array - * - * @param $new array in form col => new_value with values to set - */ - function data_merge($new) - { - if ((int) $this->debug >= 4) echo "

so_sql::data_merge(".print_r($new,true).")

\n"; - - if (!is_array($new) || !count($new)) - { - return; - } - foreach($this->db_cols as $db_col => $col) - { - if (array_key_exists($col,$new)) - { - $this->data[$col] = $new[$col]; - } - } - foreach($this->non_db_cols as $db_col => $col) - { - if (array_key_exists($col,$new)) - { - $this->data[$col] = $new[$col]; - } - } - if (isset($new[self::USER_TIMEZONE_READ])) - { - $this->data[self::USER_TIMEZONE_READ] = $new[self::USER_TIMEZONE_READ]; - } - if ((int) $this->debug >= 4) _debug_array($this->data); - } - - /** - * changes the data from the db-format to your work-format - * - * It gets called everytime when data is read from the db. - * This default implementation only converts the timestamps mentioned in $this->timestamps from server to user time. - * You can reimplement it in a derived class like this: - * - * function db2data($data=null) - * { - * if (($intern = !is_array($data))) - * { - * $data =& $this->data; - * } - * // do your own modifications here - * - * return parent::db2data($intern ? null : $data); // important to use null, if $intern! - * } - * - * @param array $data =null if given works on that array and returns result, else works on internal data-array - * @return array - */ - function db2data($data=null) - { - if (!is_array($data)) - { - $data = &$this->data; - } - if ($this->timestamps) - { - foreach($this->timestamps as $name) - { - if (isset($data[$name]) && $data[$name]) - { - if ($data[$name] === '0000-00-00 00:00:00') - { - $data[$name] = null; - } - else - { - $data[$name] = egw_time::server2user($data[$name],$this->timestamp_type); - } - } - } - } - // automatic convert booleans (eg. PostgreSQL stores 't' or 'f', which both evaluate to true!) - if ($this->has_bools !== false) - { - if (!isset($this->table_def)) - { - $this->table_def = $this->db->get_table_definitions($this->app, $this->table); - if (!$this->table_def || !is_array($this->table_def['fd'])) - { - throw new egw_exception_wrong_parameter(__METHOD__."(): No table definition for '$this->table' found !!!"); - } - } - foreach($this->table_def['fd'] as $col => $def) - { - if ($def['type'] == 'bool' && isset($data[$col])) - { - $data[$col] = $this->db->from_bool($data[$col]); - } - } - } - return $data; - } - - /** - * changes the data from your work-format to the db-format - * - * It gets called everytime when data gets writen into db or on keys for db-searches. - * This default implementation only converts the timestamps mentioned in $this->timestampfs from user to server time. - * You can reimplement it in a derived class like this: - * - * function data2db($data=null) - * { - * if (($intern = !is_array($data))) - * { - * $data =& $this->data; - * } - * // do your own modifications here - * - * return parent::data2db($intern ? null : $data); // important to use null, if $intern! - * } - * - * @param array $data =null if given works on that array and returns result, else works on internal data-array - * @return array - */ - function data2db($data=null) - { - if (!is_array($data)) - { - $data = &$this->data; - } - if ($this->timestamps) - { - foreach($this->timestamps as $name) - { - if (isset($data[$name]) && $data[$name]) - { - $data[$name] = egw_time::user2server($data[$name],$this->timestamp_type); - } - } - } - return $data; - } - - /** - * initializes data with the content of key - * - * @param array $keys =array() array with keys in form internalName => value - * @return array internal data after init - */ - function init($keys=array()) - { - $this->data = array(); - - $this->db2data(); - - $this->data_merge($keys); - - return $this->data; - } - - /** - * Name of automatically set user timezone field from read - */ - const USER_TIMEZONE_READ = 'user_timezone_read'; - - /** - * reads row matched by key and puts all cols in the data array - * - * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key - * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" - * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or - * @return array|boolean data if row could be retrived else False - */ - function read($keys,$extra_cols='',$join='') - { - if (!is_array($keys)) - { - $pk = array_values($this->db_key_cols); - if ($pk) $keys = array($pk[0] => $keys); - } - - $this->init($keys); - $this->data2db(); - - $query = false; - foreach ($this->db_key_cols as $db_col => $col) - { - if ($this->data[$col] != '') - { - $query[$db_col] = $this->data[$col]; - } - } - if (!$query) // no primary key in keys, lets try the data_cols for a unique key - { - foreach($this->db_uni_cols as $db_col => $col) - { - if (!is_array($col) && $this->data[$col] != '') - { - $query[$db_col] = $this->data[$col]; - } - elseif(is_array($col)) - { - $q = array(); - foreach($col as $db_c => $c) - { - if ($this->data[$col] == '') - { - $q = null; - break; - } - $q[$db_c] = $this->data[$c]; - } - if ($q) $query += $q; - } - } - } - if (!$query) // no unique key in keys, lets try everything else - { - foreach($this->db_data_cols as $db_col => $col) - { - if ($this->data[$col] != '') - { - $query[$db_col] = $this->data[$col]; - } - } - } - if (!$query) // keys has no cols - { - $this->db2data(); - - return False; - } - if ($join) // Prefix the columns with the table-name, as they might exist in the join - { - foreach($query as $col => $val) - { - if (is_int($col) || strpos($join,$col) === false) continue; - $query[] = $this->db->expression($this->table_name,$this->table_name.'.',array($col=>$val)); - unset($query[$col]); - } - } - foreach($this->db->select($this->table_name,'*'.($extra_cols?','.(is_array($extra_cols)?implode(',',$extra_cols):$extra_cols):''), - $query,__LINE__,__FILE__,False,'',$this->app,0,$join) as $row) - { - $cols = $this->db_cols; - if ($extra_cols) // extra columns to report - { - foreach(is_array($extra_cols) ? $extra_cols : array($extra_cols) as $col) - { - if (FALSE!==stripos($col,' as ')) $col = preg_replace('/^.* as *([a-z0-9_]+) *$/i','\\1',$col); - $cols[$col] = $col; - } - } - foreach ($cols as $db_col => $col) - { - $this->data[$col] = $row[$db_col]; - } - $this->db2data(); - - // store user timezone used for reading - $this->data[self::USER_TIMEZONE_READ] = egw_time::$user_timezone->getName(); - - if ((int) $this->debug >= 4) - { - echo "data =\n"; _debug_array($this->data); - } - return $this->data; - } - if ($this->autoinc_id) - { - unset($this->data[$this->db_key_cols[$this->autoinc_id]]); - } - if ((int) $this->debug >= 4) echo "nothing found !!!

\n"; - - $this->db2data(); - - return False; - } - - /** - * saves the content of data to the db - * - * @param array $keys =null if given $keys are copied to data before saveing => allows a save as - * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows! - * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected - */ - function save($keys=null,$extra_where=null) - { - if (is_array($keys) && count($keys)) $this->data_merge($keys); - - // check if data contains user timezone during read AND user changed timezone since then - // --> load old timezone for the rest of this request - // this only a grude hack, better handle this situation in app code: - // history logging eg. depends on old data read before calling save, which is then in new timezone! - // anyway it's better fixing it here then not fixing it at all ;-) - if (isset($this->data[self::USER_TIMEZONE_READ]) && $this->data[self::USER_TIMEZONE_READ] != egw_time::$user_timezone->getName()) - { - //echo "

".__METHOD__."() User change TZ since read! tz-read=".$this->data[self::USER_TIMEZONE_READ].' != current-tz='.egw_time::$user_timezone->getName()." --> fixing

\n"; - error_log(__METHOD__."() User changed TZ since read! tz-read=".$this->data[self::USER_TIMEZONE_READ].' != current-tz='.egw_time::$user_timezone->getName()." --> fixing

"); - $GLOBALS['egw_info']['user']['preferences']['common']['tz'] = $this->data[self::USER_TIMEZONE_READ]; - egw_time::setUserPrefs($this->data[self::USER_TIMEZONE_READ]); - $this->set_times(); - } - $this->data2db(); - - if ((int) $this->debug >= 4) { echo "so_sql::save(".print_r($keys,true).") autoinc_id='$this->autoinc_id', data="; _debug_array($this->data); } - - if ($this->autoinc_id && !$this->data[$this->db_key_cols[$this->autoinc_id]]) // insert with auto id - { - foreach($this->db_cols as $db_col => $col) - { - if (!$this->autoinc_id || $db_col != $this->autoinc_id) // not write auto-inc-id - { - if (!array_key_exists($col,$this->data) && // handling of unset columns in $this->data - (isset($this->table_def['fd'][$db_col]['default']) || // we have a default value - !isset($this->table_def['fd'][$db_col]['nullable']) || $this->table_def['fd'][$db_col]['nullable'])) // column is nullable - { - continue; // no need to write that (unset) column - } - if ($this->table_def['fd'][$db_col]['type'] == 'varchar' && - strlen($this->data[$col]) > $this->table_def['fd'][$db_col]['precision']) - { - // truncate the field to mamimum length, if upper layers didn't care - $data[$db_col] = substr($this->data[$col],0,$this->table_def['fd'][$db_col]['precision']); - } - else - { - $data[$db_col] = (string) $this->data[$col] === '' && $this->empty_on_write == 'NULL' ? null : $this->data[$col]; - } - } - } - $this->db->insert($this->table_name,$data,false,__LINE__,__FILE__,$this->app); - - if ($this->autoinc_id) - { - $this->data[$this->db_key_cols[$this->autoinc_id]] = $this->db->get_last_insert_id($this->table_name,$this->autoinc_id); - } - } - else // insert in table without auto id or update of existing row, dont write colums unset in $this->data - { - foreach($this->db_data_cols as $db_col => $col) - { - // we need to update columns set to null: after a $this->data[$col]=null: - // - array_key_exits($col,$this->data) === true - // - isset($this->data[$col]) === false - if (!array_key_exists($col,$this->data) && // handling of unset columns in $this->data - ($this->autoinc_id || // update of table with auto id or - isset($this->table_def['fd'][$db_col]['default']) || // we have a default value or - !isset($this->table_def['fd'][$db_col]['nullable']) || $this->table_def['fd'][$db_col]['nullable'])) // column is nullable - { - continue; // no need to write that (unset) column - } - $data[$db_col] = !is_object($this->data[$col]) && (string) $this->data[$col] === '' && $this->empty_on_write == 'NULL' ? null : $this->data[$col]; - } - // allow to add direct sql updates, eg. "etag=etag+1" with int keys - if (is_array($keys) && isset($keys[0])) - { - for($n=0; isset($keys[$n]); ++$n) - { - $data[] = $keys[$n]; - } - } - $keys = $extra_where; - foreach($this->db_key_cols as $db_col => $col) - { - $keys[$db_col] = $this->data[$col]; - } - if (!$data && !$this->autoinc_id) // happens if all columns are in the primary key - { - $data = $keys; - $keys = False; - } - if ($this->autoinc_id) - { - $this->db->update($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app); - if (($nothing_affected = !$this->db->Errno && !$this->db->affected_rows()) && $extra_where) - { - return true; // extra_where not met, eg. etag wrong - } - } - // always try an insert if we have no autoinc_id, as we dont know if the data exists - if (!$this->autoinc_id || $nothing_affected) - { - $this->db->insert($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app); - } - } - $this->db2data(); - - return $this->db->Errno; - } - - /** - * Update only the given fields, if the primary key is not given, it will be taken from $this->data - * - * @param array $_fields - * @param boolean $merge =true if true $fields will be merged with $this->data (after update!), otherwise $this->data will be just $fields - * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected - */ - function update($_fields,$merge=true) - { - if ($merge) $this->data_merge($_fields); - - $fields = $this->data2db($_fields); - - // extract the keys from $fields or - if not set there - from $this->data - $keys = array(); - foreach($this->db_key_cols as $col => $name) - { - $keys[$col] = isset($fields[$name]) ? $fields[$name] : $this->data[$name]; - unset($fields[$name]); - } - // extract the data from $fields - $data = array(); - foreach($this->db_data_cols as $col => $name) - { - if (array_key_exists($name,$fields)) - { - $data[$col] = $fields[$name]; - unset($fields[$name]); - } - } - // add direct sql like 'etag=etag+1' (it has integer keys) - foreach($fields as $key => $value) - { - if (is_int($key)) - { - $data[] = $value; - } - } - if (!$data) - { - return 0; // nothing to update - } - if (!$this->db->update($this->table_name,$data,$keys,__LINE__,__FILE__,$this->app)) - { - return $this->db->Errno; - } - return 0; - } - - /** - * deletes row representing keys in internal data or the supplied $keys if != null - * - * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id - * @param boolean $only_return_query =false return $query of delete call to db object, but not run it (used by so_sql_cf!) - * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids - */ - function delete($keys=null,$only_return_query=false) - { - if ($this->autoinc_id && $keys && !is_array($keys)) - { - $keys = array($this->autoinc_id => $keys); - } - if (!is_array($keys) || !count($keys)) // use internal data - { - $data = $this->data; - $keys = $this->db_key_cols; - } - else // data and keys are supplied in $keys - { - $data = $keys; $keys = array(); - foreach($this->db_cols as $db_col => $col) - { - if (isset($data[$col])) - { - $keys[$db_col] = $col; - } - } - } - $data = $this->data2db($data); - - foreach($keys as $db_col => $col) - { - $query[$db_col] = $data[$col]; - } - if ($only_return_query) return $query; - - $this->db->delete($this->table_name,$query,__LINE__,__FILE__,$this->app); - - return $this->db->affected_rows(); - } - - /** - * searches db for rows matching searchcriteria - * - * '*' and '?' are replaced with sql-wildcards '%' and '_' - * - * For a union-query you call search for each query with $start=='UNION' and one more with only $order_by and $start set to run the union-query. - * - * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards) - * @param boolean|string|array $only_keys =true True returns only keys, False returns all cols. or - * comma seperated list or array of columns to return - * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY) - * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" - * @param string $wildcard ='' appended befor and after each criteria - * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row - * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together - * @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 - * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards - * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or - * "LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!! - * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false - * @todo return an interator instead of an array - * @return array|NULL array of matching rows (the row is an array of the cols) or NULL - */ - 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) - { - //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())).')'); - if ((int) $this->debug >= 4) echo "

so_sql::search(".print_r($criteria,true).",'$only_keys','$order_by',".print_r($extra_cols,true).",'$wildcard','$empty','$op','$start',".print_r($filter,true).",'$join')

\n"; - - // if extending class or instanciator set columns to search, convert string criteria to array - if ($criteria && !is_array($criteria)) - { - $search = $this->search2criteria($criteria,$wildcard,$op); - $criteria = array($search); - } - if (!is_array($criteria)) - { - $query = $criteria; - } - else - { - $criteria = $this->data2db($criteria); - foreach($criteria as $col => $val) - { - if (is_int($col)) - { - $query[] = $val; - } - elseif ($empty || $val != '') - { - if (!($db_col = array_search($col,$this->db_cols))) - { - $db_col = $col; - } - if ($val === '') - { - if (isset($this->table_def['fd'][$db_col]) && - $this->table_def['fd'][$db_col]['type'] == 'varchar' && - $this->table_def['fd'][$db_col]['nullable'] !== false) - { - unset($criteria[$col]); - $query[] = '(' . $db_col . ' IS NULL OR ' . $db_col . " = '')"; - } - else - { - $query[$db_col] = ''; - } - } - elseif ($wildcard || $criteria[$col][0] == '!' || - is_string($criteria[$col]) && (strpos($criteria[$col],'*')!==false || strpos($criteria[$col],'?')!==false)) - { - // if search pattern alread contains a wildcard, do NOT add further ones automatic - if (is_string($criteria[$col]) && (strpos($criteria[$col],'*')!==false || strpos($criteria[$col],'?')!==false)) - { - $wildcard = ''; - } - $cmp_op = ' '.$this->db->capabilities['case_insensitive_like'].' '; - $negate = false; - if ($criteria[$col][0] == '!') - { - $cmp_op = ' NOT'.$cmp_op; - $criteria[$col] = substr($criteria[$col],1); - $negate = true; - } - foreach(explode(' ',$criteria[$col]) as $crit) - { - $query[] = ($negate ? ' ('.$db_col.' IS NULL OR ' : '').$db_col.$cmp_op. - $this->db->quote($wildcard.str_replace(array('%','_','*','?'),array('\\%','\\_','%','_'),$crit).$wildcard). - ($negate ? ') ' : ''); - } - } - elseif (strpos($db_col,'.') !== false) // we have a table-name specified - { - list($table,$only_col) = explode('.',$db_col); - $type = $this->db->get_column_attribute($only_col, $table, true, 'type'); - if (empty($type)) - { - throw new egw_exception_db("Can not determine type of column '$only_col' in table '$table'!"); - } - if (is_array($val) && count($val) > 1) - { - foreach($val as &$v) - { - $v = $this->db->quote($v, $type); - } - $query[] = $sql = $db_col.' IN (' .implode(',',$val).')'; - } - else - { - $query[] = $db_col.'='.$this->db->quote(is_array($val)?array_shift($val):$val,$type); - } - } - else - { - $query[$db_col] = $criteria[$col]; - } - } - } - if (is_array($query) && $op != 'AND') $query = $this->db->column_data_implode(' '.$op.' ',$query); - } - if (is_array($filter)) - { - $db_filter = array(); - $data2db_filter = $this->data2db($filter); - if (!is_array($data2db_filter)) { - echo function_backtrace()."
\n"; - echo "filter=";_debug_array($filter); - echo "data2db(filter)=";_debug_array($data2db_filter); - } - foreach($data2db_filter as $col => $val) - { - if ($val !== '') - { - // check if a db-internal name conversation necessary - if (!is_int($col) && ($c = array_search($col,$this->db_cols))) - { - $col = $c; - } - if(is_int($col)) - { - $db_filter[] = $val; - } - elseif ($val === "!''") - { - $db_filter[] = $this->table_name . '.' .$col." != ''"; - } - else - { - $db_filter[$this->table_name . '.' .$col] = $val; - } - } - } - if ($query) - { - if ($op != 'AND') - { - $db_filter[] = '('.$this->db->column_data_implode(' '.$op.' ',$query).')'; - } - else - { - $db_filter = array_merge($db_filter,$query); - } - } - $query = $db_filter; - } - if ((int) $this->debug >= 4) - { - echo "

so_sql::search(,only_keys=$only_keys,order_by='$order_by',wildcard='$wildcard',empty=$empty,$op,start='$start',".print_r($filter,true).") query=".print_r($query,true).", total='$this->total'

\n"; - echo "
criteria = "; _debug_array($criteria); - } - if ($only_keys === true) - { - $colums = array_keys($this->db_key_cols); - foreach($colums as &$column) - { - $column = $this->table_name . '.' . $column; - } - } - elseif (is_array($only_keys)) - { - $colums = array(); - foreach($only_keys as $key => $col) - { - //Convert ambiguous columns to prefixed tablename.column name - $colums[] = ($db_col = array_search($col,$this->db_cols)) ? $this->table_name .'.'.$db_col.' AS '.$col :$col; - } - } - elseif (!$only_keys) - { - $colums = '*'; - } - else - { - $colums = $only_keys; - } - if ($extra_cols) - { - if (!is_array($colums)) - { - $colums .= ','.(is_array($extra_cols) ? implode(',', $extra_cols) : $extra_cols); - } - else - { - $colums = array_merge($colums, is_array($extra_cols) ? $extra_cols : explode(',', $extra_cols)); - } - } - - // add table-name to otherwise ambiguous id over which we join (incl. "AS id" to return it with the right name) - if ($join && $this->autoinc_id) - { - if (is_array($colums) && ($key = array_search($this->autoinc_id, $colums)) !== false) - { - $colums[$key] = $this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id; - } - elseif (!is_array($colums) && strpos($colums,$this->autoinc_id) !== false) - { - $colums = preg_replace('/(?autoinc_id).'([ ,]+)/','\\1'.$this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id.'\\2',$colums); - } - } - $num_rows = 0; // as spec. in max_matches in the user-prefs - if (is_array($start)) list($start,$num_rows) = $start; - - // fix GROUP BY clause to contain all non-aggregate selected columns - if ($order_by && stripos($order_by,'GROUP BY') !== false) - { - $order_by = $this->fix_group_by_columns($order_by, $colums, $this->table_name, $this->autoinc_id); - } - elseif ($order_by && stripos($order_by,'ORDER BY')===false && stripos($order_by,'GROUP BY')===false && stripos($order_by,'HAVING')===false) - { - $order_by = 'ORDER BY '.$order_by; - } - if (is_array($colums)) - { - $colums = implode(',', $colums); - } - static $union = array(); - static $union_cols = array(); - if ($start === 'UNION' || $union) - { - if ($start === 'UNION') - { - $union[] = array( - 'table' => $this->table_name, - 'cols' => $colums, - 'where' => $query, - 'append' => $order_by, - 'join' => $join, - ); - if (!$union_cols) // union used the colum-names of the first query - { - $union_cols = $this->_get_columns($only_keys,$extra_cols); - } - return true; // waiting for further calls, before running the union-query - } - // running the union query now - if ($start !== false) // need to get the total too, saved in $this->total - { - if ($this->db->Type == 'mysql' && $this->db->ServerInfo['version'] >= 4.0) - { - $union[0]['cols'] = ($mysql_calc_rows = 'SQL_CALC_FOUND_ROWS ').$union[0]['cols']; - } - else // cant do a count, have to run the query without limit - { - $this->total = $this->db->union($union,__LINE__,__FILE__)->NumRows(); - } - } - $rs = $this->db->union($union,__LINE__,__FILE__,$order_by,$start,$num_rows); - if ($this->debug) error_log(__METHOD__."() ".$this->db->Query_ID->sql); - - $cols = $union_cols; - $union = $union_cols = array(); - } - else // no UNION - { - if ($start !== false) // need to get the total too, saved in $this->total - { - if ($this->db->Type == 'mysql' && $this->db->ServerInfo['version'] >= 4.0) - { - $mysql_calc_rows = 'SQL_CALC_FOUND_ROWS '; - } - elseif (!$need_full_no_count && (!$join || stripos($join,'LEFT JOIN')!==false)) - { - $this->total = $this->db->select($this->table_name,'COUNT(*)',$query,__LINE__,__FILE__,false,'',$this->app,0,$join)->fetchColumn(); - } - else // cant do a count, have to run the query without limit - { - $this->total = $this->db->select($this->table_name,$colums,$query,__LINE__,__FILE__,false,$order_by,false,0,$join)->NumRows(); - } - } - $rs = $this->db->select($this->table_name,$mysql_calc_rows.$colums,$query,__LINE__,__FILE__, - $start,$order_by,$this->app,$num_rows,$join); - if ($this->debug) error_log(__METHOD__."() ".$this->db->Query_ID->sql); - $cols = $this->_get_columns($only_keys,$extra_cols); - } - if ((int) $this->debug >= 4) echo "

sql='{$this->db->Query_ID->sql}'

\n"; - - if ($mysql_calc_rows) - { - $this->total = $this->db->query('SELECT FOUND_ROWS()')->fetchColumn(); - } - // ToDo: Implement that as an iterator, as $rs is also an interator and we could return one instead of an array - if ($this->search_return_iterator) - { - return new so_sql_db2data_iterator($this,$rs); - } - $arr = array(); - $n = 0; - if ($rs) foreach($rs as $row) - { - $data = array(); - foreach($cols as $db_col => $col) - { - $data[$col] = (isset($row[$db_col]) ? $row[$db_col] : $row[$col]); - } - $arr[] = $this->db2data($data); - $n++; - } - return $n ? $arr : null; - } - - /** - * Fix GROUP BY clause to contain all non-aggregate selected columns - * - * No need to call for MySQL because MySQL does NOT give an error in above case. - * (Of cause developer has to make sure to group by enough columns, eg. a unique key, for selected columns to be defined.) - * - * MySQL also does not allow to use [tablename.]* in GROUP BY, which PostgreSQL allows! - * (To use this for MySQL too, we would have to replace * with all columns of a table.) - * - * @param string $group_by [GROUP BY ...[HAVING ...]][ORDER BY ...] - * @param string|array $columns better provide an array as exploding by comma can lead to error with functions containing one - * @param string $table_name table-name - * @param string $autoinc_id id-column - * @return string - */ - public static function fix_group_by_columns($group_by, &$columns, $table_name, $autoinc_id) - { - $matches = null; - if ($GLOBALS['egw']->db->Type == 'mysql' || !preg_match('/(GROUP BY .*)(HAVING.*|ORDER BY.*)?$/iU', $group_by, $matches)) - { - return $group_by; // nothing to do - } - $changes = 0; - $group_by_cols = preg_split('/, */', trim(substr($matches[1], 9))); - - if (!is_array($columns)) - { - $columns = preg_split('/, */', $columns); - - // fix columns containing commas as part of function calls - for($n = 0; $n < count($columns); ++$n) - { - $col =& $columns[$n]; - while (substr_count($col, '(') > substr_count($col, ')') && ++$n < count($columns)) - { - $col .= ','.$columns[$n]; - unset($columns[$n]); - } - } - unset($col); - } - foreach($columns as $n => $col) - { - if ($col == '*') - { - // MySQL does NOT allow to GROUP BY table.* - $col = $columns[$n] = $table_name.'.'.($GLOBALS['egw']->db->Type == 'mysql' ? $autoinc_id : '*'); - ++$changes; - } - // only check columns and non-aggregate functions - if (strpos($col, '(') === false || !preg_match('/(COUNT|MIN|MAX|AVG|SUM|BIT_[A-Z]+|STD[A-Z_]*|VAR[A-Z_]*|ARRAY_AGG)\(/i', $col)) - { - if (($pos = stripos($col, 'DISTINCT ')) !== false) - { - $col = substr($col, $pos+9); - } - $alias = $col; - if (stripos($col, ' AS ')) list($col, $alias) = preg_split('/ +AS +/i', $col); - // do NOT group by constant expressions - if (preg_match('/^ *(-?[0-9]+|".*"|\'.*\'|NULL) *$/i', $col)) continue; - if (!in_array($col, $group_by_cols) && !in_array($alias, $group_by_cols)) - { - // instead of aliased primary key, we have to use original column incl. table-name as alias is ambigues - $group_by_cols[] = $col == $table_name.'.'.$autoinc_id ? $col : $alias; - //error_log(__METHOD__."() col=$col, alias=$alias --> group_by_cols=".array2string($group_by_cols)); - ++$changes; - } - } - } - $ret = $group_by; - if ($changes) - { - $ret = str_replace($matches[1], 'GROUP BY '.implode(',', $group_by_cols).' ', $group_by); - //error_log(__METHOD__."('$group_by', ".array2string($columns).") group_by_cols=".array2string($group_by_cols)." changed to $ret"); - } - return $ret; - } - - /** - * Return criteria array for a given search pattern - * - * @param string $_pattern search pattern incl. * or ? as wildcard, if no wildcards used we append and prepend one! - * @param string &$wildcard ='' on return wildcard char to use, if pattern does not already contain wildcards! - * @param string &$op ='AND' on return boolean operation to use, if pattern does not start with ! we use OR else AND - * @param string $extra_col =null extra column to search - * @param array $search_cols =array() List of columns to search. If not provided, all columns in $this->db_cols will be considered - * @return array or column => value pairs - */ - public function search2criteria($_pattern,&$wildcard='',&$op='AND',$extra_col=null, $search_cols = array()) - { - $pattern = trim($_pattern); - // This function can get called multiple times. Make sure it doesn't re-process. - if (empty($pattern) || is_array($pattern)) return $pattern; - if(strpos($pattern, 'CAST(COALESCE(') !== false) - { - return $pattern; - } - - $criteria = array(); - $filter = array(); - $columns = array(); - - /* - * Special handling for numeric columns. They are only considered if the pattern is numeric. - * If the pattern is numeric, an equality search is used instead. - */ - $numeric_types = array('auto', 'int', 'float', 'double', 'decimal'); - $numeric_columns = array(); - - if(!$search_cols) - { - $search_cols = $this->get_default_search_columns(); - } - // Concat all fields to be searched together, so the conditions operate across the whole record - foreach($search_cols as $col) - { - $col_name = $col; - $table = $this->table_name; - if (strpos($col,'.') !== false) - { - list($table,$col_name) = explode('.',$col); - } - $table_def = $table == $this->table_name ? $this->table_def : $this->db->get_table_definitions(true,$table); - if ($table_def['fd'][$col_name] && in_array($table_def['fd'][$col_name]['type'], $numeric_types)) - { - $numeric_columns[] = $col; - continue; - } - if ($this->db->Type == 'mysql' && $table_def['fd'][$col_name]['type'] === 'ascii' && preg_match('/[\x80-\xFF]/', $_pattern)) - { - continue; // will only give sql error - } - $columns[] = sprintf($this->db->capabilities[egw_db::CAPABILITY_CAST_AS_VARCHAR],"COALESCE($col,'')"); - } - if(!$columns) - { - return array(); - } - - // Break the search string into tokens - $break = ' '; - $token = strtok($pattern, $break); - - while($token) - { - if($token == strtoupper(lang('AND')) || $token == 'AND') - { - $token = '+'.strtok($break); - } - elseif ($token == strtoupper(lang('OR')) || $token == 'OR') - { - $token = strtok($break); - continue; - } - elseif ($token == strtoupper(lang('NOT')) || $token == 'NOT') - { - $token = '-'.strtok($break); - } - if ($token[0]=='"') - { - $token = substr($token, 1,strlen($token)); - if(substr($token, -1) != '"') - { - $token .= ' '.strtok('"'); - } - else - { - $token = substr($token, 0, -1); - } - } - - // prepend and append extra wildcard %, if pattern does NOT already contain wildcards - if (strpos($token,'*') === false && strpos($token,'?') === false) - { - $wildcard = '%'; // if pattern contains no wildcards, add them before AND after the pattern - } - else - { - $wildcard = ''; // no extra wildcard, if pattern already contains some - } - - switch($token[0]) - { - case '+': - $op = 'AND'; - $token = substr($token, 1, strlen($token)); - break; - case '-': - case '!': - $op = 'NOT'; - $token = substr($token, 1, strlen($token)); - break; - default: - $op = 'OR'; - break; - } - $token_filter = ' '.call_user_func_array(array($GLOBALS['egw']->db,'concat'),$columns).' '. - $this->db->capabilities['case_insensitive_like'] . ' ' . - $GLOBALS['egw']->db->quote($wildcard.str_replace(array('%','_','*','?'),array('\\%','\\_','%','_'),$token).$wildcard); - - // Compare numeric token as equality for numeric columns - // skip user-wildcards (*,?) in is_numeric test, but not SQL wildcards, which get escaped and give sql-error - if (is_numeric(str_replace(array('*','?'), '', $token))) - { - $numeric_filter = array(); - foreach($numeric_columns as $col) - { - if($wildcard == '') - { - // Token has a wildcard from user, use LIKE - $numeric_filter[] = "($col IS NOT NULL AND CAST($col AS CHAR) " . - $this->db->capabilities['case_insensitive_like'] . ' ' . - $GLOBALS['egw']->db->quote(str_replace(array('*','?'), array('%','_'), $token)) . ')'; - } - else - { - $numeric_filter[] = "($col IS NOT NULL AND $col = $token)"; - } - } - if(count($numeric_filter) > 0) - { - $token_filter = '(' . $token_filter . ' OR ' . implode(' OR ', $numeric_filter) . ')'; - } - } - $criteria[$op][] = $token_filter; - - $token = strtok($break); - } - - if($criteria['NOT']) - { - $filter[] = 'NOT (' . implode(' OR ', $criteria['NOT']) . ') '; - } - if($criteria['AND']) - { - $filter[] = implode(' AND ', $criteria['AND']) . ' '; - } - if($criteria['OR']) - { - $filter[] = '(' . implode(' OR ', $criteria['OR']) . ') '; - } - - if(count($filter)) - { - $result = '(' . implode(' AND ', $filter) . ')'; - } - - // OR extra column on the end so a null or blank won't block a hit in the main columns - if ($extra_col) - { - $result .= (strlen($result) ? ' OR ' : ' ') . "$extra_col = " . $GLOBALS['egw']->db->quote($pattern); - } - - $op = 'OR'; - return array('(' . $result . ')'); - } - - /** - * Get a default list of columns to search - * This is to be used as a fallback, for when the extending class does not define - * $this->columns_to_search. All the columns are considered, and any with $skip_columns_with in - * their name are discarded because these columns are expected to be foreign keys or other numeric - * values with no meaning to the user. - * - * @return array of column names - */ - protected function get_default_search_columns() - { - $skip_columns_with = array('_id', 'modified', 'modifier', 'status', 'cat_id', 'owner'); - $search_cols = is_null($this->columns_to_search) ? $this->db_cols : $this->columns_to_search; - $numeric_types = array('auto', 'int', 'float', 'double'); - - // Skip some numeric columns that don't make sense to search if we have to default to all columns - if(is_null($this->columns_to_search)) - { - foreach($search_cols as $key => &$col) - { - // If the name as given isn't a real column name, and adding the prefix doesn't help, skip it - if(!$this->table_def['fd'][$col] && !($col = $this->prefix.array_search($col, $search_cols))) { - // Can't search this column - unset($search_cols[$key]); - continue; - } - if(in_array($this->table_def['fd'][$col]['type'], $numeric_types)) - { - foreach($skip_columns_with as $bad) - { - if(strpos($col, $bad) !== false) - { - unset($search_cols[$key]); - continue 2; - } - } - } - // Prefix with table name to avoid ambiguity - $col = $this->table_name.'.'.$col; - } - } - return $search_cols; - } - - /** - * extract the requested columns from $only_keys and $extra_cols param of a search - * - * @internal - * @param boolean|string $only_keys =true True returns only keys, False returns all cols. comma seperated list of keys to return - * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" - * @return array with columns as db-name => internal-name pairs - */ - function _get_columns($only_keys,$extra_cols) - { - //echo "_get_columns() only_keys="; _debug_array($only_keys); echo "extra_cols="; _debug_array($extra_cols); - if ($only_keys === true) // only primary key - { - $cols = $this->db_key_cols; - } - else - { - $cols = array(); - $distinct_checked = false; - foreach(is_array($only_keys) ? $only_keys : explode(',', $only_keys) as $col) - { - if (!$distinct_checked) - { - if (stripos($col, 'DISTINCT ') === 0) $col = substr($col, 9); - $distinct_checked = true; - } - if (!$col || $col == '*' || $col == $this->table_name.'.*') // all columns - { - $cols = array_merge($cols,$this->db_cols); - } - else // only the specified columns - { - if (stripos($col,'as')) // if there's already an explicit naming of the column, just use it - { - $col = preg_replace('/^.*as +([a-z0-9_]+) *$/i','\\1',$col); - $cols[$col] = $col; - continue; - } - if (($db_col = array_search($col,$this->db_cols)) !== false) - { - $cols[$db_col] = $col; - } - else - { - $cols[$col] = isset($this->db_cols[$col]) ? $this->db_cols[$col] : $col; - } - } - } - } - if ($extra_cols) // extra columns to report - { - foreach(is_array($extra_cols) ? $extra_cols : explode(',',$extra_cols) as $col) - { - if (stripos($col,'as ')!==false) $col = preg_replace('/^.*as +([a-z0-9_]+) *$/i','\\1',$col); - if (($db_col = array_search($col,$this->db_cols)) !== false) - { - $cols[$db_col] = $col; - } - else - { - $cols[$col] = isset($this->db_cols[$col]) ? $this->db_cols[$col] : $col; - } - } - } - return $cols; - } - - /** - * query rows for the nextmatch widget - * - * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter' - * For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class. - * @param array &$rows returned rows/competitions - * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class - * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or - * "LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join! - * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false - * @param mixed $only_keys =false, see search - * @param string|array $extra_cols =array() - * @return int total number of rows - */ - function get_rows($query,&$rows,&$readonlys,$join='',$need_full_no_count=false,$only_keys=false,$extra_cols=array()) - { - unset($readonlys); // required by function signature, but not used in this default implementation - if ((int) $this->debug >= 4) - { - echo "

so_sql::get_rows(".print_r($query,true).",,)

\n"; - } - $criteria = array(); - $op = 'AND'; - if ($query['search']) - { - $criteria = $query['search']; - } - $rows = $this->search($criteria,$only_keys,$query['order']?$query['order'].' '.$query['sort']:'',$extra_cols, - '',false,$op,$query['num_rows']?array((int)$query['start'],$query['num_rows']):(int)$query['start'], - $query['col_filter'],$join,$need_full_no_count); - - if (!$rows) $rows = array(); // otherwise false returned from search would be returned as array(false) - - return $this->total; - } - - /** - * Check if values for unique keys and the primary keys are unique are unique - * - * @param array $data =null data-set to check, defaults to $this->data - * @return int 0: all keys are unique, 1: first key not unique, 2: ... - */ - function not_unique($data=null) - { - if (!is_array($data)) - { - $data = $this->data; - } - $n = 1; - $uni_keys = $this->db_uni_cols; - // add the primary key, only if it's NOT an auto id - if (!$this->autoinc_id) - { - $uni_keys[] = $this->db_key_cols; - } - foreach($uni_keys as $db_col => $col) - { - if (is_array($col)) - { - $query = array(); - foreach($col as $db_c => $c) - { - $query[$db_c] = $data[$c]; - } - } - else - { - $query = array($db_col => $data[$col]); - } - foreach($this->db->select($this->table_name,$this->db_key_cols,$query,__LINE__,__FILE__,false,'',$this->app) as $other) - { - foreach($this->db_key_cols as $key_col) - { - if ($data[$key_col] != $other[$key_col]) - { - if ((int) $this->debug >= 4) - { - echo "

not_unique in ".array2string($col)." as for '$key_col': '${data[$key_col]}' != '${other[$key_col]}'

\n"; - } - return $n; // different entry => $n not unique - } - } - } - ++$n; - } - return 0; - } - - /** - * Query DB for a list / array with one colum as key and an other one(s) as value, eg. id => title pairs - * - * We do some caching as these kind of function is usualy called multiple times, eg. for option-lists. - * - * @param string $value_col array of column-names for the values of the array, can also be an expression aliased with AS, - * if more then one column given, an array with keys identical to the given ones is returned and not just the value of the column - * @param string $key_col ='' column-name for the keys, default '' = same as (first) $value_col: returns a distinct list - * @param array $filter =array() to filter the entries - * @param string $order ='' order, default '' = same as (first) $value_col - * @return array with key_col => value_col pairs or array if more then one value_col given (keys as in value_col) - */ - function query_list($value_col,$key_col='',$filter=array(),$order='') - { - static $cache = array(); - - $cache_key = serialize($value_col).'-'.$key_col.'-'.serialize($filter).'-'.$order; - - if (isset($cache[$cache_key])) - { - return $cache[$cache_key]; - } - if (!is_array($value_col)) $value_col = array($value_col); - - $cols = $ret = array(); - foreach($value_col as $key => $col) - { - $matches = null; - $cols[$key] = preg_match('/AS ([a-z_0-9]+)$/i',$col,$matches) ? $matches[1] : $col; - } - if (!$order) $order = current($cols); - - if (($search =& $this->search(array(),($key_col ? $key_col.',' : 'DISTINCT ').implode(',',$value_col),$order,'','',false,'AND',false,$filter))) - { - if (preg_match('/AS ([a-z_0-9]+)$/i',$key_col,$matches)) - { - $key_col = $matches[1]; - } - elseif (!$key_col) - { - $key_col = current($cols); - } - foreach($search as $row) - { - if (count($cols) > 1) - { - $data = array(); - foreach($cols as $key => $col) - { - $data[$key] = $row[$col]; - } - } - else - { - $data = $row[current($cols)]; - } - if ($data) $ret[$row[$key_col]] = $data; - } - } - return $cache[$cache_key] =& $ret; - } - - /** - * Get comments for all columns or a specific one - * - * @param string $column =null name of column or null for all (default) - * @return array|string array with internal-name => comment pairs, or string with comment, if $column given - */ - public function get_comments($column=null) - { - static $comments=null; - - if (is_null($comments)) - { - foreach($this->db_cols as $db_col => $col) - { - $comments[$col] = $this->table_def['fd'][$db_col]['comment']; - } - } - return is_null($column) ? $comments : $comments[$column]; - } -} +class so_sql extends Api\Storage\Base {} /** * Iterator applying a so_sql's db2data method on each element retrived * + * @deprecated use Api\Storage\Db2DataIterator */ -class so_sql_db2data_iterator implements Iterator -{ - /** - * Reference of so_sql class to use it's db2data method - * - * @var so_sql - */ - private $so_sql; - - /** - * Instance of ADOdb record set to iterate - * - * @var Iterator - */ - private $rs; - - /** - * Total count of entries - * - * @var int - */ - public $total; - - /** - * Constructor - * - * @param so_sql $so_sql - * @param Traversable $rs - */ - public function __construct(so_sql $so_sql,Traversable $rs=null) - { - $this->so_sql = $so_sql; - - $this->total = $so_sql->total; - - if (is_a($rs,'IteratorAggregate')) - { - $this->rs = $rs->getIterator(); - } - else - { - $this->rs = $rs; - } - } - - /** - * Return the current element - * - * @return array - */ - public function current() - { - if (is_a($this->rs,'iterator')) - { - $data = $this->rs->current(); - - return $this->so_sql->data2db($data); - } - return null; - } - - /** - * Return the key of the current element - * - * @return int - */ - public function key() - { - if (is_a($this->rs,'iterator')) - { - return $this->rs->key(); - } - return 0; - } - - /** - * Move forward to next element (called after each foreach loop) - */ - public function next() - { - if (is_a($this->rs,'iterator')) - { - return $this->rs->next(); - } - } - - /** - * Rewind the Iterator to the first element (called at beginning of foreach loop) - */ - public function rewind() - { - if (is_a($this->rs,'iterator')) - { - return $this->rs->rewind(); - } - } - - /** - * Checks if current position is valid - * - * @return boolean - */ - public function valid () - { - if (is_a($this->rs,'iterator')) - { - return $this->rs->valid(); - } - return false; - } -} +class so_sql_db2data_iterator extends Api\Storage\Db2DataIterator {} diff --git a/etemplate/inc/class.so_sql2.inc.php b/etemplate/inc/class.so_sql2.inc.php index fd1c88d320..985e460117 100644 --- a/etemplate/inc/class.so_sql2.inc.php +++ b/etemplate/inc/class.so_sql2.inc.php @@ -1,15 +1,17 @@ - * @copyright 2007/8 by RalfBecker@outdoor-training.de + * @copyright 2002-16 by RalfBecker@outdoor-training.de * @version $Id$ */ +use EGroupware\Api; + /** * generalized SQL Storage Object * @@ -23,102 +25,6 @@ * You have to explicitly declare other object-properties of derived classes, which should NOT * be handled by that mechanism! * - * @package etemplate - * @subpackage api - * @author RalfBecker-AT-outdoor-training.de - * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @deprecated use Api\Storage\Base */ -class so_sql2 extends so_sql -{ - /** - * Private array containing all the object-data - * - * Colides with the original definition in so_sql and I dont want to change it there at the moment. - * - * @var array - */ - //private $data; - - /** - * constructor of the class - * - * NEED to be called from the constructor of the derived class !!! - * - * @param string $app should be set if table-defs to be read from /setup/tables_current.inc.php - * @param string $table should be set if table-defs to be read from /setup/tables_current.inc.php - * @param object/db $db database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database - * @param string $colum_prefix='' column prefix to automatic remove from the column-name, if the column name starts with it - * @param boolean $no_clone=false can we avoid to clone the db-object, default no - * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object - * - * @return so_sql2 - */ - function __construct($app='',$table='',$db=null,$column_prefix='',$no_clone=false) - { - parent::__construct($app,$table,$db,$column_prefix,$no_clone); - } - - /** - * php4 constructor - * - * @deprecated use __construct - */ - function so_sql2($app='',$table='',$db=null,$column_prefix='',$no_clone=false) - { - self::__construct($app,$table,$db,$column_prefix,$no_clone); - } - - /** - * magic method to read a property from $this->data - * - * The special property 'id' always refers to the auto-increment id of the object, independent of its name. - * - * @param string $property - * @return mixed - */ - function __get($property) - { - switch($property) - { - case 'id': - $property = $this->autoinc_id; - break; - } - if (in_array($property,$this->db_cols) || in_array($property,$this->non_db_cols)) - { - return $this->data[$property]; - } - } - - /** - * magic method to set a property in $this->data - * - * The special property 'id' always refers to the auto-increment id of the object, independent of its name. - * - * @param string $property - * @param mixed $value - */ - function __set($property,$value) - { - switch($property) - { - case 'id': - $property = $this->autoinc_id; - break; - } - if (in_array($property,$this->db_cols) || in_array($property,$this->non_db_cols)) - { - $this->data[$property] = $value; - } - } - - /** - * Return the whole object-data as array, it's a cast of the object to an array - * - * @return array - */ - function as_array() - { - return $this->data; - } -} +class so_sql2 extends Api\Storage\Base2 {} diff --git a/etemplate/inc/class.so_sql_cf.inc.php b/etemplate/inc/class.so_sql_cf.inc.php index e7cef19b0e..5d3c7784ab 100644 --- a/etemplate/inc/class.so_sql_cf.inc.php +++ b/etemplate/inc/class.so_sql_cf.inc.php @@ -6,10 +6,12 @@ * @package etemplate * @link http://www.egroupware.org * @author Ralf Becker - * @copyright 2009-14 by RalfBecker@outdoor-training.de + * @copyright 2009-16 by RalfBecker@outdoor-training.de * @version $Id$ */ +use EGroupware\Api; + /** * Generalized SQL Storage Object with build in custom field support * @@ -32,710 +34,6 @@ * 'uc' => array() * ) * - * @package etemplate - * @subpackage api - * @author RalfBecker-AT-outdoor-training.de - * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @deprecated use Api\Storage */ -class so_sql_cf extends so_sql -{ - /** - * Prefix used by the class - */ - const CF_PREFIX = '#'; - - /** - * name of customefields table - * - * @var string - */ - var $extra_table; - - /** - * name of id column, defaults to the regular tables auto id - * - * @var string - */ - var $extra_id = '_id'; - - /** - * Name of key (cf name) column or just a postfix added to the table prefix - * - * @var string - */ - var $extra_key = '_name'; - - /** - * Name of value column or just a postfix added to the table prefix - * - * @var string - */ - var $extra_value = '_value'; - - var $extra_join; - var $extra_join_order; - var $extra_join_filter; - - /** - * Does extra table has a unique index (over id and name) - * - * @var boolean - */ - var $extra_has_unique_index; - - /** - * Custom fields of $app, read by the constructor - * - * @var array - */ - var $customfields; - - /** - * Do we allow AND store multiple values for a cf (1:N) relations - * - * @var boolean - */ - var $allow_multiple_values = false; - - /** - * constructor of the class - * - * Please note the different params compared to so_sql! - * - * @param string $app application name to load table schemas - * @param string $table name of the table to use - * @param string $extra_table name of the custom field table - * @param string $column_prefix ='' column prefix to automatic remove from the column-name, if the column name starts with it - * @param string $extra_key ='_name' column name for cf name column (will be prefixed with colum prefix, if starting with _) - * @param string $extra_value ='_value' column name for cf value column (will be prefixed with colum prefix, if starting with _) - * @param string $extra_id ='_id' column name for cf id column (will be prefixed with colum prefix, if starting with _) - * @param egw_db $db =null database object, if not the one in $GLOBALS['egw']->db should be used, eg. for an other database - * @param boolean $no_clone =true can we avoid to clone the db-object, default yes (different from so_sql!) - * new code using appnames and foreach(select(...,$app) can set it to avoid an extra instance of the db object - * @param boolean $allow_multiple_values =false should we allow AND store multiple values (1:N relations) - * @param string $timestamp_type =null default null=leave them as is, 'ts'|'integer' use integer unix timestamps, 'object' use egw_time objects - */ - function __construct($app,$table,$extra_table,$column_prefix='', - $extra_key='_name',$extra_value='_value',$extra_id='_id', - $db=null,$no_clone=true,$allow_multiple_values=false,$timestamp_type=null) - { - // calling the so_sql constructor - parent::__construct($app,$table,$db,$column_prefix,$no_clone,$timestamp_type); - - $this->allow_multiple_values = $allow_multiple_values; - $this->extra_table = $extra_table; - if (!$this->extra_id) $this->extra_id = $this->autoinc_id; // default to auto id of regular table - - // if names from columns of extra table are only postfixes (starting with _), prepend column prefix - if (!($prefix=$column_prefix)) - { - list($prefix) = explode('_',$this->autoinc_id); - } - elseif(substr($prefix,-1) == '_') - { - $prefix = substr($prefix,0,-1); // remove trailing underscore from column prefix parameter - } - foreach(array( - 'extra_id' => $extra_id, - 'extra_key' => $extra_key, - 'extra_value' => $extra_value - ) as $col => $val) - { - $this->$col = $col_name = $val; - if ($col_name[0] == '_') $this->$col = $prefix . $val; - } - // some sanity checks, maybe they should be active only for development - if (!($extra_defs = $this->db->get_table_definitions($app,$extra_table))) - { - throw new egw_exception_wrong_parameter("extra table $extra_table is NOT defined!"); - } - foreach(array('extra_id','extra_key','extra_value') as $col) - { - if (!$this->$col || !isset($extra_defs['fd'][$this->$col])) - { - throw new egw_exception_wrong_parameter("$col column $extra_table.{$this->$col} is NOT defined!"); - } - } - // check if our extra table has a unique index (if not we have to delete the old values, as replacing does not work!) - $this->extra_has_unique_index = $extra_defs['pk'] || $extra_defs['uc']; - - // setting up our extra joins, now we know table and column names - $this->extra_join = " LEFT JOIN $extra_table ON $table.$this->autoinc_id=$extra_table.$this->extra_id"; - $this->extra_join_order = " LEFT JOIN $extra_table extra_order ON $table.$this->autoinc_id=extra_order.$this->extra_id"; - $this->extra_join_filter = " JOIN $extra_table extra_filter ON $table.$this->autoinc_id=extra_filter.$this->extra_id"; - - $this->customfields = egw_customfields::get($app, false, null, $db); - } - - /** - * Read all customfields of the given id's - * - * @param int|array $ids one ore more id's - * @param array $field_names =null custom fields to read, default all - * @return array id => $this->cf_field(name) => value - */ - function read_customfields($ids,$field_names=null) - { - if (is_null($field_names)) $field_names = array_keys($this->customfields); - - foreach((array)$ids as $key => $id) - { - if (!(int)$id && is_array($ids)) unset($ids[$key]); - } - if (!$ids || !$field_names) return array(); // nothing to do - - $entries = array(); - foreach($this->db->select($this->extra_table,'*',array( - $this->extra_id => $ids, - $this->extra_key => $field_names, - ),__LINE__,__FILE__,false,'',$this->app) as $row) - { - $entry =& $entries[$row[$this->extra_id]]; - if (!is_array($entry)) $entry = array(); - $field = $this->get_cf_field($row[$this->extra_key]); - - if ($this->allow_multiple_values && $this->is_multiple($row[$this->extra_key])) - { - $entry[$field][] = $row[$this->extra_value]; - } - else - { - $entry[$field] = $row[$this->extra_value]; - } - } - return $entries; - } - - /** - * saves custom field data - * - * @param array $data data to save (cf's have to be prefixed with self::CF_PREFIX = #) - * @param array $extra_cols =array() extra-data to be saved - * @return bool false on success, errornumber on failure - */ - function save_customfields($data, array $extra_cols=array()) - { - foreach (array_keys((array)$this->customfields) as $name) - { - if (!isset($data[$field = $this->get_cf_field($name)])) continue; - - $where = array( - $this->extra_id => isset($data[$this->autoinc_id]) ? $data[$this->autoinc_id] : $data[$this->db_key_cols[$this->autoinc_id]], - $this->extra_key => $name, - ); - $is_multiple = $this->is_multiple($name); - - // we explicitly need to delete fields, if value is empty or field allows multiple values or we have no unique index - if(empty($data[$field]) || $is_multiple || !$this->extra_has_unique_index) - { - $this->db->delete($this->extra_table,$where,__LINE__,__FILE__,$this->app); - if (empty($data[$field])) continue; // nothing else to do for empty values - } - foreach($is_multiple && !is_array($data[$field]) ? explode(',',$data[$field]) : - // regular custom fields (!$is_multiple) eg. addressbook store multiple values comma-separated - (array)(!$is_multiple && is_array($data[$field]) ? implode(',', $data[$field]) : $data[$field]) as $value) - { - if (!$this->db->insert($this->extra_table,array($this->extra_value => $value)+$extra_cols,$where,__LINE__,__FILE__,$this->app)) - { - return $this->db->Errno; - } - } - } - return false; // no error - } - - /** - * merges in new values from the given new data-array - * - * reimplemented to also merge the customfields - * - * @param $new array in form col => new_value with values to set - */ - function data_merge($new) - { - parent::data_merge($new); - - if ($this->customfields) - { - foreach(array_keys($this->customfields) as $name) - { - if (isset($new[$field = $this->get_cf_field($name)])) - { - $this->data[$field] = $new[$field]; - } - } - } - } - - /** - * reads row matched by key and puts all cols in the data array - * - * reimplented to also read the custom fields - * - * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key - * @param string|array $extra_cols string or array of strings to be added to the SELECT, eg. "count(*) as num" - * @param string $join sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or - * @return array|boolean data if row could be retrived else False - */ - function read($keys,$extra_cols='',$join='') - { - if (!parent::read($keys,$extra_cols,$join)) - { - return false; - } - if (($id = (int)$this->data[$this->db_key_cols[$this->autoinc_id]]) && $this->customfields && - ($cfs = $this->read_customfields($id))) - { - $this->data = array_merge($this->data,$cfs[$id]); - } - return $this->data; - } - - /** - * saves the content of data to the db - * - * reimplented to also save the custom fields - * - * @param array $keys if given $keys are copied to data before saveing => allows a save as - * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows! - * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected - */ - function save($keys=null,$extra_where=null) - { - if (is_array($keys) && count($keys) && !isset($keys[0])) // allow to use an etag, eg array('etag=etag+1') - { - $this->data_merge($keys); - $keys = null; - } - $ret = parent::save($keys,$extra_where); - - if ($ret == 0 && $this->customfields) - { - $this->save_customfields($this->data); - } - return $ret; - } - - /** - * deletes row representing keys in internal data or the supplied $keys if != null - * - * reimplented to also delete the custom fields - * - * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id - * @param boolean $only_return_ids =false return $ids of delete call to db object, but not run it (can be used by extending classes!) - * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids - */ - function delete($keys=null,$only_return_ids=false) - { - if ($this->customfields || $only_return_ids) - { - $query = parent::delete($keys,true); - // check if query contains more then the id's - if (!isset($query[$this->autoinc_id]) || count($query) != 1) - { - foreach($this->db->select($this->table_name,$this->autoinc_id,$query,__LINE__,__FILE__,false,'',$this->app) as $row) - { - $ids[] = $row[$this->autoinc_id]; - } - if (!$ids) return 0; // no rows affected - } - else - { - $ids = (array)$query[$this->autoinc_id]; - } - if ($only_return_ids) return $ids; - $this->db->delete($this->extra_table,array($this->extra_id => $ids),__LINE__,__FILE__); - } - return parent::delete($keys); - } - - /** - * query rows for the nextmatch widget - * - * Reimplemented to also read the custom fields (if enabled via $query['selectcols']). - * - * Please note: the name of the nextmatch-customfields has to be 'customfields'! - * - * @param array $query with keys 'start', 'search', 'order', 'sort', 'col_filter' - * For other keys like 'filter', 'cat_id' you have to reimplement this method in a derived class. - * @param array &$rows returned rows/competitions - * @param array &$readonlys eg. to disable buttons based on acl, not use here, maybe in a derived class - * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or - * "LEFT JOIN table2 ON (x=y)", Note: there's no quoting done on $join! - * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false - * @param mixed $only_keys =false, see search - * @param string|array $extra_cols =array() - * @return int total number of rows - */ - function get_rows($query,&$rows,&$readonlys,$join='',$need_full_no_count=false,$only_keys=false,$extra_cols=array()) - { - parent::get_rows($query,$rows,$readonlys,$join,$need_full_no_count,$only_keys,$extra_cols); - - $selectcols = $query['selectcols'] ? explode(',',$query['selectcols']) : array(); - - if ($rows && $this->customfields && (!$selectcols || in_array('customfields',$selectcols))) - { - $id2keys = array(); - foreach($rows as $key => $row) - { - $id2keys[$row[$this->db_key_cols[$this->autoinc_id]]] = $key; - } - // check if only certain cf's to show - if (!in_array('customfields', $selectcols)) - { - foreach($selectcols as $col) - { - if ($this->is_cf($col)) $fields[] = $this->get_cf_name($col); - } - } - if (($cfs = $this->read_customfields(array_keys($id2keys),$fields))) - { - foreach($cfs as $id => $data) - { - $rows[$id2keys[$id]] = array_merge($rows[$id2keys[$id]],$data); - } - } - } - return $this->total; - } - - /** - * searches db for rows matching searchcriteria - * - * Reimplemented to search, order and filter by custom fields - * - * @param array|string $criteria array of key and data cols, OR string with search pattern (incl. * or ? as wildcards) - * @param boolean|string/array $only_keys =true True returns only keys, False returns all cols. or - * comma seperated list or array of columns to return - * @param string $order_by ='' fieldnames + {ASC|DESC} separated by colons ',', can also contain a GROUP BY (if it contains ORDER BY) - * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" - * @param string $wildcard ='' appended befor and after each criteria - * @param boolean $empty =false False=empty criteria are ignored in query, True=empty have to be empty in row - * @param string $op ='AND' defaults to 'AND', can be set to 'OR' too, then criteria's are OR'ed together - * @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 - * @param array $filter =null if set (!=null) col-data pairs, to be and-ed (!) into the query without wildcards - * @param string $join ='' sql to do a join, added as is after the table-name, eg. "JOIN table2 ON x=y" or - * "LEFT JOIN table2 ON (x=y AND z=o)", Note: there's no quoting done on $join, you are responsible for it!!! - * @param boolean $need_full_no_count =false If true an unlimited query is run to determine the total number of rows, default false - * @return array|NULL array of matching rows (the row is an array of the cols) or NULL - */ - 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) - { - //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())).')'); - if (!$this->customfields) - { - return parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,$start,$filter,$join,$need_full_no_count); - } - if ($only_keys === false) - { - $only_keys = $this->table_name.'.*'; - } - // if string given as criteria --> search in all (or $this->columns_to_search) columns including custom fields - if ($criteria && is_string($criteria)) - { - $criteria = $this->search2criteria($criteria,$wildcard,$op); - } - if ($criteria && is_array($criteria)) - { - // check if we search in the custom fields - if (isset($criteria[$this->extra_value])) - { - if (($negate = $criteria[$this->extra_value][0] === '!')) - { - $criteria[$this->extra_value] = substr($criteria[$this->extra_value],1); - } - $criteria[] = $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : ''). - $this->db->capabilities[egw_db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' . - $this->db->quote($wildcard.$criteria[$this->extra_value].$wildcard); - unset($criteria[$this->extra_value]); - } - // replace ambiguous auto-id with (an exact match of) table_name.autoid - if (isset($criteria[$this->autoinc_id])) - { - if ($criteria[$this->autoinc_id]) - { - $criteria[] = $this->db->expression($this->table_name,$this->table_name.'.', - array($this->autoinc_id => $criteria[$this->autoinc_id])); - } - unset($criteria[$this->autoinc_id]); - } - // replace ambiguous column with (an exact match of) table_name.column - $extra_join_added = $join && strpos($join, $this->extra_join) !== false; - foreach($criteria as $name => $val) - { - // only add extra_join, if we really need it - if (!$extra_join_added && ( - is_int($name) && strpos($val, $this->extra_value) !== false || - is_string($name) && $this->is_cf($name) - )) - { - $join .= $this->extra_join; - $extra_join_added = true; - } - $extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table); - if(is_string($name) && $extra_columns['fd'][array_search($name, $this->db_cols)]) - { - $criteria[] = $this->db->expression($this->table_name,$this->table_name.'.',array( - array_search($name, $this->db_cols) => $val, - )); - unset($criteria[$name]); - } - elseif (is_string($name) && $this->is_cf($name)) - { - if ($op != 'AND') - { - $name = substr($name, 1); - if (($negate = $criteria[$name][0] === '!')) - { - $val = substr($val,1); - } - $cfcriteria[] = '(' . $this->extra_table.'.'.$this->extra_value . ' ' .($negate ? 'NOT ' : ''). - $this->db->capabilities[egw_db::CAPABILITY_CASE_INSENSITIV_LIKE]. ' ' . - $this->db->quote($wildcard.$val.$wildcard) . ' AND ' . - $this->extra_table.'.'.$this->extra_key . ' = ' . $this->db->quote($name) . - ')'; - unset($criteria[self::CF_PREFIX.$name]); - } - else - { - // criteria operator is AND we remap the criteria to be transformed to filters - $filter[$name] = $val; - unset($criteria[$name]); - } - } - } - if ($cfcriteria && $op =='OR') $criteria[] = implode(' OR ',$cfcriteria); - } - if($only_keys === true) - { - // Expand to keys here, so table_name can be prepended below - $only_keys = array_values($this->db_key_cols); - } - // replace ambiguous column with (an exact match of) table_name.column - if(is_array($only_keys)) - { - foreach($only_keys as $key => &$col) - { - if(is_numeric($key) && in_array($col, $this->db_cols, true)) - { - $col = $this->table_name .'.'.array_search($col, $this->db_cols).' AS '.$col; - } - } - } - // check if we order by a custom field --> join cf table for given cf and order by it's value - if (strpos($order_by,self::CF_PREFIX) !== false) - { - // fields to order by, as cutomfields may have names with spaces, we examine each order by criteria - $fields2order = explode(',',$order_by); - foreach($fields2order as $v) - { - if (strpos($v,self::CF_PREFIX) !== false) - { - // we found a customfield, so we split that part by space char in order to get Sorting Direction and Fieldname - $buff = explode(' ',trim($v)); - $orderDir = array_pop($buff); - $key = substr(trim(implode(' ',$buff)), 1); - switch($this->customfields[$key]['type']) - { - case 'int': - $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'. - $this->db->to_int('extra_order.'.$this->extra_value).' '.$orderDir, $order_by); - break; - case 'float': - $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,'. - $this->db->to_double('extra_order.'.$this->extra_value).' '.$orderDir, $order_by); - break; - default: - $order_by = str_replace($v, 'extra_order.'.$this->extra_value.' IS NULL,extra_order.'. - $this->extra_value.' '.$orderDir, $order_by); - } - // postgres requires that expressions in order by appear in the columns of a distinct select - if ($this->db->Type != 'mysql') - { - if (!is_array($extra_cols)) - { - $extra_cols = $extra_cols ? explode(',', $extra_cols) : array(); - } - $extra_cols[] = 'extra_order.'.$this->extra_value; - $extra_cols[] = 'extra_order.'.$this->extra_value.' IS NULL'; - } - $join .= $this->extra_join_order.' AND extra_order.'.$this->extra_key.'='.$this->db->quote($key); - } - } - } - // check if we filter by a custom field - if (is_array($filter)) - { - $_cfnames = array_keys($this->customfields); - $extra_filter = null; - foreach($filter as $name => $val) - { - // replace ambiguous auto-id with (an exact match of) table_name.autoid - if (is_string($name) && $name == $this->autoinc_id) - { - if ((int)$filter[$this->autoinc_id]) - { - $filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array( - $this->autoinc_id => $filter[$this->autoinc_id], - )); - } - unset($filter[$this->autoinc_id]); - } - // replace ambiguous column with (an exact match of) table_name.column - elseif (is_string($name) && $val!=null && in_array($name, $this->db_cols)) - { - $extra_columns = $this->db->get_table_definitions($this->app, $this->extra_table); - if ($extra_columns['fd'][array_search($name, $this->db_cols)]) - { - $filter[] = $this->db->expression($this->table_name,$this->table_name.'.',array( - array_search($name, $this->db_cols) => $val, - )); - unset($filter[$name]); - } - } - elseif (is_string($name) && $this->is_cf($name)) - { - if (!empty($val)) // empty -> dont filter - { - if ($val[0] === '!') // negative filter - { - $sql_filter = 'extra_filter.'.$this->extra_value.'!='.$this->db->quote(substr($val,1)); - } - else // using egw_db::expression to allow to use array() with possible values or NULL - { - if($this->customfields[$this->get_cf_name($name)]['type'] == 'select' && - $this->customfields[$this->get_cf_name($name)]['rows'] > 1) - { - // Multi-select - any entry with the filter value selected matches - $sql_filter = str_replace($this->extra_value,'extra_filter.'. - $this->extra_value,$this->db->expression($this->extra_table,array( - $this->db->concat("','",$this->extra_value,"','").' '.$this->db->capabilities[egw_db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote('%,'.$val.',%') - )) - ); - } - elseif ($this->customfields[$this->get_cf_name($name)]['type'] == 'text') - { - $sql_filter = str_replace($this->extra_value,'extra_filter.'.$this->extra_value, - $this->db->expression($this->extra_table,array( - $this->extra_value.' '.$this->db->capabilities[egw_db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote($wildcard.$val.$wildcard) - )) - ); - } - else - { - $sql_filter = str_replace($this->extra_value,'extra_filter.'. - $this->extra_value,$this->db->expression($this->extra_table,array($this->extra_value => $val))); - } - } - // need to use a LEFT JOIN for negative search or to allow NULL values - $need_left_join = $val[0] === '!' || strpos($sql_filter,'IS NULL') !== false ? ' LEFT ' : ''; - $join .= str_replace('extra_filter','extra_filter'.$extra_filter,$need_left_join.$this->extra_join_filter. - ' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($this->get_cf_name($name)). - ' AND '.$sql_filter); - ++$extra_filter; - } - unset($filter[$name]); - } - elseif(is_int($name) && $this->is_cf($val)) // lettersearch: #cfname LIKE 's%' - { - $_cf = explode(' ',$val); - foreach($_cf as $cf_np) - { - // building cf_name by glueing parts together (, in case someone used whitespace in their custom field names) - $tcf_name = ($tcf_name?$tcf_name.' ':'').$cf_np; - // reacts on the first one found that matches an existing customfield, should be better then the old behavior of - // simply splitting by " " and using the first part - if ($this->is_cf($tcf_name) && ($cfn = $this->get_cf_name($tcf_name)) && array_search($cfn,(array)$_cfnames,true)!==false ) - { - $cf = $tcf_name; - break; - } - } - $join .= str_replace('extra_filter','extra_filter'.$extra_filter,$this->extra_join_filter. - ' AND extra_filter.'.$this->extra_key.'='.$this->db->quote($this->get_cf_name($cf)). - ' AND '.str_replace($cf,'extra_filter.'.$this->extra_value,$val)); - ++$extra_filter; - unset($filter[$name]); - } - } - } - // add DISTINCT as by joining custom fields for search a row can be returned multiple times - if ($join && strpos($join, $this->extra_join) !== false) - { - if (is_array($only_keys)) - { - $only_keys = array_values($only_keys); - $only_keys[0] = 'DISTINCT '.($only_keys[0] != $this->autoinc_id ? $only_keys[0] : - $this->table_name.'.'.$this->autoinc_id.' AS '.$this->autoinc_id); - } - else - { - $only_keys = 'DISTINCT '.$only_keys; - } - } - return parent::search($criteria,$only_keys,$order_by,$extra_cols,$wildcard,$empty,$op,$start,$filter,$join,$need_full_no_count); - } - - /** - * Get a default list of columns to search - * - * Reimplemented to search custom fields by default. - * - * @return array of column names - */ - protected function get_default_search_columns() - { - $cols = parent::get_default_search_columns(); - if ($this->customfields && !isset($this->columns_to_search)) - { - $cols[] = $this->extra_table.'.'.$this->extra_value; - } - //error_log(__METHOD__."() this->columns_to_search=".array2string($this->columns_to_search).' returning '.array2string($cols)); - return $cols; - } - - /** - * Function to test if $field is a custom field: check for the prefix - * - * @param string $field - * @return boolean true if $name is a custom field, false otherwise - */ - function is_cf($field) - { - return $field[0] == self::CF_PREFIX; - } - - /** - * Get name part from a custom field: remove the prefix - * - * @param string $field - * @return string name without prefix - */ - function get_cf_name($field) - { - return substr($field,1); - } - - /** - * Get the field-name from the name of a custom field: prepend the prefix - * - * @param string $name - * @return string prefix-name - */ - function get_cf_field($name) - { - return self::CF_PREFIX.$name; - } - - /** - * Check if cf is stored as 1:N relation in DB and array in memory - * - * @param string $name - * @return string - */ - function is_multiple($name) - { - return $this->allow_multiple_values && in_array($this->customfields[$name]['type'],array('select','select-account')) && - $this->customfields[$name]['rows'] > 1; - } -} +class so_sql_cf extends Api\Storage {}