* * @license http://opensource.org/licenses/bsd-license.php BSD * * @version $Id$ * */ /** * * Class for representing an SQL table. * * @category Abovo * * @package Abovo_Sql_Table * */ class Abovo_Sql_Table extends Solar_Base { /** * * User-provided configuration. * * Keys are ... * * `sql` * : (dependency) A Solar_Sql dependency object. * * `paging` * : (int) Number of rows per page. * * `create` * : (bool) Attempt to auto-create the table? * * `autoconnect` * : (bool) Whether to attempt to create the table at construction * * @var array * */ protected $_Abovo_Sql_Table = array( 'sql' => 'sql', 'paging' => 10, 'create' => true, 'autoconnect' => true, ); /** * * The table name. * * @var string * */ protected $_name = null; /** * * The default order when fetching rows. * * @var array * */ protected $_order = array('id'); /** * * The numer of rows per page when selecting. * * @var int * */ protected $_paging = 10; /** * * Have we connected to the database? * * @var bool * */ protected $_connected = false; /** * * The column specification array for all columns in this table. * * Each element in this array looks like this... * * {{code: php * $col = array( * 'col_name' => array( * 'name' => (string) the col_name, same as the key * 'type' => (string) char, varchar, date, etc * 'size' => (int) column size * 'scope' => (int) decimal places * 'valid' => (array) Solar_Valid methods and args * 'require' => (bool) is this a required (non-null) column? * 'autoinc' => (bool) auto-increment * 'default' => (string|array) default value * 'primary' => (bool) is this part of the primary key? * ), * ); * }} * * @var array * */ protected $_col = array(); /** * * The index specification array for all indexes on this table. * * The array should be in this format ... * * {{code: php * // the index type: 'normal' or 'unique' * $type = 'normal'; * * // index on a single column: * // CREATE INDEX idx_name ON table_name (col_name) * $this->_idx['idx_name'] = array($type, 'col_name'); * * // index on multiple columns: * // CREATE INDEX idx_name ON table_name (col1, col2, ... colN) * $this->_idx['idx_name'] = array( * $type, * array('col1', 'col2', ..., 'colN') * ); * * // easy shorthand for an index on a single column, * // giving the index the same name as the column: * // CREATE INDEX col_name ON table_name (col_name) * $this->_idx['col_name'] = $type; * }} * * The $type may be 'normal' or 'unique'. * * @var array * * @see addIndex() * */ protected $_idx = array(); /** * * A Solar_Sql dependency object. * * @var Solar_Sql * */ protected $_sql = null; /** * * Column name which will be used for create timestamp * * @var string * */ protected $_col_created = 'created'; /** * * Column name which will be used for update timestamp * * @var string * */ protected $_col_updated = 'updated'; /** * * The object class returned by fetch(), fetchNew(), and fetchRow(). * * @var string * */ protected $_row_class = 'Solar_Sql_Row'; /** * * The object class returned by fetchAll(). * * @var string * */ protected $_all_class = 'Solar_Sql_Rowset'; /** * * DataFilter class to use * * @var string * */ protected $_filter_class = 'Abovo_DataFilter'; /** * * Constructor. * * @param array $config User-provided configuration values. * * @return void * */ public function __construct($config = null) { // main construction parent::__construct($config); $this->setPaging($this->_config['paging']); // perform column and index setup, then fix everything. $this->_setup(); $this->_autoSetup(); // instantiate datafilter class $this->_obj_filter = Solar::factory($this->_filter_class); // autoconnect and create tables if ($this->_config['autoconnect'] !== false) { $this->_connect(); } } /** * * Connects to the database and auto-creates the table if requested. * * @return void * */ protected function _connect() { if (! $this->_connected) { $this->_sql = Solar::dependency('Solar_Sql', $this->_config['sql']); if ($this->_config['create']) { $this->_autoCreate(); } $this->_connected = true; } } /** * * Allows reading of protected properties. * * @param string $key The property name. * * @return mixed The property value. * */ public function __get($key = null) { $prop = array('col', 'idx', 'name', 'paging', 'all_class', 'row_class', 'order', ); if (in_array($key, $prop)) { $key = "_$key"; return $this->$key; } else { return null; } } /** * * Sets the number of rows per page. * * @param int $val The number of rows per page. * * @return void * */ public function setPaging($val) { $this->_paging = (int) $val; } /** * * Sets the class returned by fetchRow() calls. * * @param string $class The class name. If empty, uses 'Solar_Sql_Row'. * * @return void * */ public function setFetchRowClass($class) { if (! $class) { $class = 'Solar_Sql_Row'; } $this->_row_class = $class; } /** * * Sets the class returned by fetchAll() calls. * * @param string $class The class name. If empty, uses 'Solar_Sql_Rowset'. * * @return void * */ public function setFetchAllClass($class) { if (! $class) { $class = 'Solar_Sql_Rowset'; } $this->_all_class = $class; } /** * * Returns a fully-qualified column name ("tablename.colname"). * * If the column does not exist in the table, returns null. * * @param string $key The column name itself. * * @return string The column name prefixed with the table name. * */ public function getColName($key) { if (! empty($this->_col[$key])) { return $this->_name . '.' . $key; } } /** * * Inserts or updates a single row based on its ID. * * @param array $data An associative array of data to be saved, in * the format (field => value). * * @return array The data as inserted or updated. * */ public function save($data) { $this->_connect(); if (empty($data['id'])) { return $this->insert($data); } else { $where = $this->_sql->quoteInto('id = ?', $data['id']); return $this->update($data, $where); } } /** * * Validates and inserts data into the table. * * @param array $data An associative array of data to be inserted, in * the format (field => value). * * @return array The data as inserted. * */ public function insert($data) { // set defaults $data = array_merge($this->fetchNew()->toArray(), (array) $data); // add created/updated timestamps $now = date('Y-m-d\TH:i:s'); if (array_key_exists($this->_col_created, $this->_col) && empty($data[$this->_col_created])) { // add created time $data[$this->_col_created] = $now; } if (array_key_exists($this->_col_updated, $this->_col) && empty($data[$this->_col_updated])) { // add created time $data[$this->_col_updated] = $now; } // validate and recast the data. $this->_applyFilters($data); // attempt the insert. $this->_connect(); $result = $this->_sql->insert($this->_name, $data); // auto-add sequential values foreach ($this->_col as $colname => $colinfo) { // does this column autoincrement, and is no data provided? if ($colinfo['autoinc']) { $data[$colname] = $this->_sql->lastInsertId(); } } // return return $data; } /** * * Validates and updates data in the table based on a WHERE clause. * * @param array $data An associative array of data to be updated, in * the format (field => value). * * @param string $where An SQL WHERE clause limiting the updated * rows. * * @return array The data as updated. * */ public function update($data, $where) { // retain primary key data in this array for return values $retain = array(); // disallow the changing of primary key data foreach (array_keys($data) as $field) { if (! empty($this->_col[$field]['primary'])) { $retain[$field] = $data[$field]; unset($data[$field]); } } // should we add updated time? if (array_key_exists($this->_col_updated, $this->_col)) { // forcibly set the "updated" timestamp $data[$this->_col_updated] = date('Y-m-d\TH:i:s'); } // filter and validate data $this->_applyFilters($data); // attempt the update $this->_connect(); $result = $this->_sql->update($this->_name, $data, $where); // restore retained primary key data return array_merge($data, $retain); } /** * * Deletes rows in the table based on a WHERE clause. * * @param string $where An SQL WHERE clause limiting the deleted rows. * * @return void * */ public function delete($where) { $this->_connect(); $result = $this->_sql->delete($this->_name, $where); return $result; } /** * * Convenience method to select rows from this table. * * @param string $type The type of select to execute: 'all', 'one', * 'value', etc. Default is 'pdo'. * * @param string|array $where A Solar_Sql_Select::multiWhere() parameter. * * @param string|array $order A Solar_Sql_Select::order() parameter. * * @param int $page The page number of rows to fetch. * * @return mixed * */ public function select($type = 'pdo', $where = null, $order = null, $page = null) { if ($type == 'rowset') { $class = $this->_all_class; } elseif ($type == 'row') { $class = $this->_row_class; } else { $class = null; } $select = $this->_newSelect(); $result = $select->from($this->_name, array_keys($this->_col)) ->multiWhere($where) ->order($order) ->setPaging($this->_paging) ->limitPage($page) ->fetch($type, $class); if ($result instanceof Solar_Sql_Row) { $result->setSave($this); } return $result; } /** * * Fetches one row from the table by its primary key ID. * * @param int $id The primary key ID value. * * @return Solar_Sql_Row * */ public function fetch($id) { $where = array('id = ?' => $id); return $this->select('row', $where); } /** * * Fetches all rows by arbitrary criteria. * * @param string|array $where A Solar_Sql_Select::multiWhere() parameter. * * @param string|array $order A Solar_Sql_Select::order() parameter. * * @param int $page The page number of rows to fetch. * * @return Solar_Sql_Rowset * */ public function fetchAll($where = null, $order = null, $page = null) { return $this->select('rowset', $where, $order, $page); } /** * * Fetches one row by arbitrary criteria. * * @param string|array $where A Solar_Sql_Select::multiWhere() parameter. * * @param string|array $order A Solar_Sql_Select::order() parameter. * * @return Solar_Sql_Row * */ public function fetchRow($where = null, $order = null) { return $this->select('row', $where, $order); } /** * * Returns a new row of column keys and default values. * * @return Solar_Sql_Row * */ public function fetchNew() { // the array of default data $data = array(); // loop through each specified column and collect default data $spec = array_keys($this->_col); foreach ($spec as $name) { // skip columns that don't exist if (empty($this->_col[$name])) { continue; } // get the column info $info = $this->_col[$name]; // is there a default set? if (empty($info['default'])) { // no default, so it's null. $data[$name] = null; continue; } // yes, so get it based on the kind of default. // we shift off the front of the array as we go. // element 0 is the type (literal or callback), // element 1 is the literal (or callback name), // elements 2+ are any arguments for a callback. $type = array_shift($info['default']); switch ($type) { case 'callback': $func = array_shift($info['default']); $data[$name] = call_user_func_array($func, $info['default']); break; case 'literal': $data[$name] = array_shift($info['default']); break; default: $data[$name] = null; } } // done! $row = Solar::factory($this->_row_class, array('data' => $data)); $row->setSave($this); return $row; } /** * * Fetches one row by arbitrary criteria. * * @param string|array $where A Solar_Sql_Select::multiWhere() parameter. * * @param string|array $order A Solar_Sql_Select::order() parameter. * * @return Solar_Sql_Row * * @deprecated Use Solar_Sql_Table::fetchRow() instead. * */ public function fetchWhere($where = null, $order = null) { return $this->select('row', $where, $order); } // ----------------------------------------------------------------- // // Support and management methods. // // ----------------------------------------------------------------- /** * * Gets a new Solar_Sql_Select tool, with the proper SQL object injected * automatically. * * Note: this forces a database connection so that we have the SQL object * properly constructed. * * @return Solar_Sql_Select * */ protected function _newSelect() { $this->_connect(); return Solar::factory( 'Solar_Sql_Select', array('sql' => $this->_sql) ); } /** * * Use this to set up extended table objects. * * @return void * */ protected function _setup() { } /** * * Fixes the $col and $idx properties after user setup. * * @return void * */ protected function _autoSetup() { // make sure there's a table name. defaults to the // part after the last underscore, then converts camelCaps // to underscore_words. if (empty($this->_name)) { // get the class name $tmp = get_class($this); // get the part after the last underscore $pos = strrpos($tmp, '_'); if ($pos !== false) { $tmp = substr($tmp, $pos + 1); } // camels to unders $this->_name = preg_replace('/([a-z])([A-Z])/', "$1_$2", $tmp); } // make sure table name is lower case regardless $this->_name = strtolower($this->_name); // a baseline column definition $basecol = array( 'name' => null, 'type' => null, 'size' => null, 'scope' => null, 'primary' => false, 'require' => false, 'autoinc' => false, 'default' => null, 'filter' => array(), ); // baseline index definition $baseidx = array( 'name' => null, 'type' => 'normal', 'cols' => null, ); // auto-added columns and indexes $autocol = array(); $autoidx = array(); // auto-add an ID column and index for unique identification if (! array_key_exists('id', $this->_col)) { $autocol['id'] = array( 'type' => 'int', 'primary' => true, 'require' => true, 'autoinc' => true, ); $autoidx['id'] = array( 'type' => 'unique', 'cols' => 'id', ); } // merge the auto-added items on top of the rest $this->_col = array_merge($autocol, $this->_col); $this->_idx = array_merge($autoidx, $this->_idx); // fix up each column to have a full set of info foreach ($this->_col as $name => $info) { // fill in missing elements $info = array_merge($basecol, $info); // make sure there's a name $info['name'] = $name; // if 'filter' is a string, make the validation a simple // Solar_DataFilter method call. if (is_string($info['filter'])) { $info['filter'] = array($info['filter']); } // if 'default' is not already an array, make it // one as a literal. this lets you avoid the array // when setting up simple literals. if (! is_array($info['default'])) { $info['default'] = array('literal', $info['default']); } // save back into the column info $this->_col[$name] = $info; } // fix filters and add final fallbacks $this->_fixFilters(); // fix up each index to have a full set of info foreach ($this->_idx as $key => $val) { if (is_int($key) && is_string($val)) { // array('col') $info = array( 'name' => $val, 'type' => 'normal', 'cols' => array($val), ); } elseif (is_string($key) && is_string($val)) { // array('col' => 'unique') $info = array( 'name' => $key, 'type' => $val, 'cols' => array($key), ); } else { // array('alt' => array('type' => 'normal', 'cols' => array(...))) $info = array_merge($baseidx, (array) $val); $info['name'] = (string) $key; settype($info['cols'], 'array'); } $this->_idx[$key] = $info; } } /** * * Creates the table in the database if it does not already exist. * * @return bool False if the table already existed and didn't * need to be created, or true if the table did not exist and was * successfully created. * */ protected function _autoCreate() { // is a table with the same name already there? $tmp = $this->_sql->fetchTableList(); $here = strtolower($this->_name); foreach ($tmp as $there) { if ($here == strtolower($there)) { // table already exists return false; } } // create the table itself $this->_sql->createTable( $this->_name, $this->_col ); // create each of the indexes foreach ($this->_idx as $name => $info) { try { // create this index $this->_sql->createIndex( $this->_name, $info['name'], $info['type'] == 'unique', $info['cols'] ); } catch (Exception $e) { /** @todo Does this throw a TableNotCreated exception too? */ // cancel the whole deal. $this->_sql->dropTable($this->_name); throw $e; } } // post-creation try { $this->_postCreate(); } catch (Exception $e) { /** @todo Does this throw a TableNotCreated exception too? */ // cancel the whole deal. $this->_sql->dropTable($this->_name); throw $e; } // creation of the table and its indexes is complete return true; } /** * * Additional table-creation tasks, such as inserting rows. * * @return void * */ protected function _postCreate() { } /** * * Fixes all filters * * @return void * */ protected function _fixFilters() { // make sure that strings are converted // to arrays so that _applyFilters() works properly. foreach ($this->_col as $col => $info) { foreach ($info['filter'] as $key => $val) { if (is_string($val)) { $this->_col[$col]['filter'][$key] = array($val); } } } // low and high range values for integer filters $range = array( 'smallint' => array(pow(-2, 15), pow(+2, 15) - 1), 'int' => array(pow(-2, 31), pow(+2, 31) - 1), 'bigint' => array(pow(-2, 63), pow(+2, 63) - 1) ); // columns names $cols = array_keys($this->_col); // add final fallback filters based on data type foreach ($cols as $field) { // column type $type = $this->_col[$field]['type']; $info = $this->_col[$field]; switch ($type) { case 'bool': $this->_col[$field]['filter'][] = array('validateBool'); $this->_col[$field]['filter'][] = array('sanitizeBool'); break; case 'char': case 'varchar': $this->_col[$field]['filter'][] = array('validateString'); $this->_col[$field]['filter'][] = array('validateMaxLength', $info['size']); $this->_col[$field]['filter'][] = array('sanitizeString'); break; case 'smallint': case 'int': case 'bigint': $this->_col[$field]['filter'][] = array('validateInt'); $this->_col[$field]['filter'][] = array('validateRange', $range[$type][0], $range[$type][1]); $this->_col[$field]['filter'][] = array('sanitizeInt'); break; case 'numeric': $this->_col[$field]['filter'][] = array('validateNumeric'); $this->_col[$field]['filter'][] = array('validateSizeScope', $info['size'], $info['scope']); $this->_col[$field]['filter'][] = array('sanitizeNumeric'); break; case 'float': $this->_col[$field]['filter'][] = array('validateFloat'); $this->_col[$field]['filter'][] = array('sanitizeFloat'); break; case 'clob': $this->_col[$field]['filter'][] = array('validateString'); $this->_col[$field]['filter'][] = array('sanitizeString'); break; case 'date': $this->_col[$field]['filter'][] = array('validateIsoDate'); $this->_col[$field]['filter'][] = array('sanitizeIsoDate'); break; case 'time': $this->_col[$field]['filter'][] = array('validateIsoTime'); $this->_col[$field]['filter'][] = array('sanitizeIsoTime'); break; case 'timestamp': $this->_col[$field]['filter'][] = array('validateIsoTimestamp'); $this->_col[$field]['filter'][] = array('sanitizeIsoTimestamp'); break; } } } /** * * Applies sanitize/validate filters for all data * * @param array &data Data as col => val passed by reference * * @return void * * @throws Solar_Exception if something invalidated * */ protected function _applyFilters(&$data) { $invalid = array(); foreach ($data as $field => &$val) { // does this col exist? if (! array_key_exists($field, $this->_col)) { // ...no, remove it! unset($data[$field]); continue; } foreach ($this->_col[$field]['filter'] as $params) { // take the method name off the top of the params ... $method = array_shift($params); // ... and put the value in its place. array_unshift($params, $data[$field]); if ($this->_col[$field]['autoinc']) { $this->_obj_filter->setRequire(false); } else { $this->_obj_filter->setRequire($this->_col[$field]['require']); } // call the filtering method $result = call_user_func_array( array($this->_obj_filter, $method), $params ); // make sure everything is required by default $this->_obj_filter->setRequire(true); // did the filter sanitize, or did it validate? $type = strtolower(substr($method, 0, 8)); // what to do with the result? if ($type == 'sanitize') { // retain the sanitized value $val = $result; } elseif ($type == 'validate' && $result === false) { // a validation method failed; use the method name as // the locale translation key, converting from camelCase // to camel_Case, then to CAMEL_CASE. $tmp = preg_replace('/([a-z])([A-Z])/', '$1_$2', $method); $tmp = strtoupper($tmp); $invalid[$field] = $this->locale($tmp); // no more validations on this key break; } } } // were there any errors? if (! empty($invalid)) { throw $this->_exception( 'ERR_INVALID_DATA', array('info' => $invalid) ); } } protected function _autoAddValid() { /** $autovalids = array( `bool` => array() `char` => array() `varchar` => `smallint` => size `int` => size `bigint` => size `numeric` => `float` => `clob` => array('') `date` => array('isoDate') `time` => array('isoTime') `timestamp` => array('isoDateTime') ); */ // low and high range values for integers $int_range = array( 'smallint' => array(pow(-2, 15), pow(+2, 15) - 1), 'int' => array(pow(-2, 31), pow(+2, 31) - 1), 'bigint' => array(pow(-2, 63), pow(+2, 63) - 1) ); foreach ($this->_col as $name => &$col) { switch ($col['type']) { case 'bool': break; case 'char': case 'varchar': // are there any validations? if (empty($col['valid'])) { if($col['require']) { $blank = Solar_Valid::NOT_BLANK; } else { $blank = SOlar_Valid::OR_BLANK; } // add maxLength rule $col['valid'] = array( 'maxLength', 'VALIDATE_MAXLENGTH', $col['size'], $blank ); } break; case 'int': case 'bigint': case 'smallint': if (empty($col['valid'])) { $min = $int_range[$col['type']][0]; $max = $int_range[$col['type']][1]; $col['valid'] = array('range', 'VALIDATE_RANGE', $min, $max); } break; case 'float': break; case 'numeric': case 'date': case 'time': case 'timestamp': break; } } } /* // are there any validations? if (! empty($col['valid'])) { // is first a 'multiple' rule? if ($col['valid'][0][0] == 'multiple') { // yes, so go through them and search // for maxLenght validation $found = false; foreach ($col['valid'][0][2] as $valid) { // is this it? if ($valid[0] == 'maxLenght') { $found = true; break; } } if (! $found) { if ($col['require']) { // add a rangeLenght rule because the col // is required $col['valid'][0][2][] = array('maxLenght', 1, $col['size']); } else { // add maxLenght rule only $col['valid'][0][2][] = array('maxLenght', $col['size']); } } } else { // check if this is a rangeLenght rule if ($col['valid'][0][0]) { # code... } } } else { // it's empty so add rangeLenght rule } */ }