From fcca19cfcf8d9de6ca814dd2f1e35c259281b42f Mon Sep 17 00:00:00 2001
From: Ralf Becker
Date: Sun, 6 Mar 2016 09:05:20 +0000
Subject: [PATCH] moving so_sql* to Api\Storage
---
api/src/Storage.php | 744 +++++++++++
api/src/Storage/Base.php | 1646 +++++++++++++++++++++++
api/src/Storage/Base2.php | 114 ++
api/src/Storage/Db2DataIterator.php | 130 ++
etemplate/inc/class.so_sql.inc.php | 1747 +------------------------
etemplate/inc/class.so_sql2.inc.php | 106 +-
etemplate/inc/class.so_sql_cf.inc.php | 712 +---------
7 files changed, 2653 insertions(+), 2546 deletions(-)
create mode 100644 api/src/Storage.php
create mode 100644 api/src/Storage/Base.php
create mode 100644 api/src/Storage/Base2.php
create mode 100644 api/src/Storage/Db2DataIterator.php
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 {}