diff --git a/system/class/DB.class.php b/system/class/DB.class.php new file mode 100644 index 0000000..429553c --- /dev/null +++ b/system/class/DB.class.php @@ -0,0 +1,315 @@ + + */ + class DB + { + const MODE_AUTO = 'AUTO'; + const MODE_READONLY = 'READONLY'; + const MODE_READWRITE = 'READWRITE'; + + const ERR_NAME_EMPTY_STRING = 'DB Connector Name cannot be an empty string'; + const ERR_INVALID_DBCONN_MODE = 'Invalid DB connection mode: "%mode%"'; + const ERR_NO_SUCH_CONNECTION = 'No such DB connection: "%name%" (%mode%)'; + + const DEFAULT_TZ = 'US/Eastern'; + + static private $connections = array(); + static private $default_conn = NULL; + static private $tz = NULL; + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Array|NULL + */ + static public function getCol( $sql, $db=NULL ) + { + return DB::doDBcall( 'getCol', $sql, $db ); + } + + /** + * @param SQL $sql + * @param uInt $col + * @param String|NULL $db (Optional; defaults to NULL) + * @return Array|NULL + */ + static public function getColN( $sql, $col, $db=NULL ) + { +//TODO: getColN() + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Array|NULL + */ + static public function getAll( $sql, $db=NULL ) + { + return DB::doDBcall( 'getAll', $sql, $db ); + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Array|NULL + */ + static public function getRow( $sql, $db=NULL ) + { + return DB::doDBcall( 'getRow', $sql, $db ); + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Array|NULL + */ + static public function getAssoc( $sql, $db=NULL ) + { + return DB::doDBcall( 'getAssoc', $sql, $db ); + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Mixed + */ + static public function getOne( $sql, $db=NULL ) + { + return DB::doDBcall( 'getOne', $sql, $db ); + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Mixed + */ + static public function query( $sql, $db=NULL ) + { + return DB::doDBcall( 'query', $sql, $db ); + } + + /** + * @param SQL $sql + * @param String|NULL $db (Optional; defaults to NULL) + * @return Mixed + */ + static public function lowPriorityQuery( $sql, $db=NULL ) + { +//TODO: DB::lowPriorityQuery() + return DB::doDBcall( 'query', $sql, $db ); + } + + /** + * Returns the insert ID from the last query that was run. + * @params String $db + * @return Int + */ + static public function getLastInsertID( $db='default' ) + { + if( !isset(DB::$connections[$db][DB::MODE_READWRITE]) ) + throw new Exception( str_replace( '%name%', $db, + str_replace( '%mode%', $mode, + DB::ERR_NO_SUCH_CONNECTION ))); + + return DB::$connections[$db][DB::MODE_READWRITE]->getLastInsertID(); + } + + /** + * @param String $method + * @param SQL $sql + * @param String|NULL $db + * @return Mixed + */ + static private function doDBcall( $method, $sql, $db ) + { +$mode = DB::MODE_AUTO; + if( $db === NULL ) + $db = 'default'; + +// $sql->setDatabase( DB::$connections[$db] ); +// $sql->setMode( $mode ); + + if( Debug::isEnabled() ) + { + $qid = Debug::registerQuery( $sql, $db ); + $qt_start = microtime(TRUE); + } + + switch( $mode ) + { + case DB::MODE_AUTO: +//TODO: Implement MODE_AUTO choosing instead of short circuiting to R/W connector +//TODO: Report for debugging which connector was actually chosen when we're doing AUTO + case DB::MODE_READWRITE: + if( !isset(DB::$connections[$db][DB::MODE_READWRITE]) ) + throw new Exception( + str_replace( '%name%', $db, + str_replace( '%mode%', $mode, DB::ERR_NO_SUCH_CONNECTION ))); + + $conn = DB::$connections[$db][DB::MODE_READWRITE]; + break; + case DB::MODE_READONLY: + $conn_number = DB::pickRO($db); + if( !isset(DB::$connections[$db][DB::MODE_READONLY]['con'][$conn_number]) ) + throw new Exception( + str_replace( '%name%', $db, + str_replace( '%mode%', $mode, DB::ERR_NO_SUCH_CONNECTION ))); + + $conn = DB::$connections[$db][DB::MODE_READONLY]['con'][$conn_number]; + break; + default: + throw new Exception( "unsupported mode" ); + } + + $result = $conn->$method($sql); + + if( Debug::isEnabled() ) + { + $qt_end = microtime(TRUE); + $query_time = round( $qt_end - $qt_start, 5 ); + Debug::registerQueryTime( $qid, $query_time ); + } + + return $result; + } + + /** + * @param String $db (Optional; Defaults to NULL, meaning the default connector) + * @param String $mode (See DB::MODE_READWRITE, DB::MODE_READONLY) + * @return Bool + */ + static public function isConnected( $db=NULL, $mode=DB::MODE_READWRITE ) + { + if( $db === NULL ) + $db = 'default'; + + if( !isset(DB::$connections[$name][$mode]) ) + throw new Exception( str_replace( '%name%', $db, + str_replace( '%mode%', $mode, + DB::ERR_NO_SUCH_CONNECTION ))); + + return DB::$connections[$name][$mode]->isConnected(); + } + + /** + * The first connector set up becomes the default. + * The default can be changed later with DB::setDefault() + * @param String $name Connector reference ID + * @param String $mode (see DB::MODE_READONLY, DB::MODE_READWRITE) + * @param String $dsn + * @param String $user (Optional; defaults to NULL) + * @param String $pass (Optional; defaulst to NULL) + */ + static public function addConnection( $name, $mode, $dsn, $user=NULL, $pass=NULL ) + { + $name = (String) $name; + if( strlen($name) <= 0 ) + throw new Exception( DB::ERR_NAME_EMPTY_STRING ); + + switch( (String) $mode ) + { + case DB::MODE_READWRITE: + if( isset(DB::$connections[$name]) ) + DB::disconnect( $name, DB::MODE_READWRITE ); + else + DB::$connections[$name] = array(); + if( !isset(DB::$connections[$name][DB::MODE_READWRITE]) ) + DB::$connections[$name][DB::MODE_READWRITE] = array(); + + DB::$connections[$name][DB::MODE_READWRITE] = new DBConnector( $dsn, $user, $pass ); + DB::$connections[$name][DB::MODE_READWRITE]->setTZ( DB::$tz ); + break; + + case DB::MODE_READONLY: + if( !isset(DB::$connections[$name]) ) + DB::$connections[$name] = array(); + if( !isset(DB::$connections[$name][DB::MODE_READONLY]) ) + DB::$connections[$name][DB::MODE_READONLY] = array( 'con' => array(), 'last_used' => -1 ); + $id = count(DB::$connections[$name][DB::MODE_READONLY]['con']); + + $con = new DBConnector( $dsn, $user, $pass ); + $con->setTZ( DB::$tz ); + DB::$connections[$name][DB::MODE_READONLY]['con'][$id] = $con; + + break; + + default: + throw new Exception( str_replace( '%mode%', $mode, DB::ERR_INVALID_DBCONN_MODE ) ); + } + + if( count(DB::$connections) == 1 ) + DB::setDefault( $name ); + } + + /** + * Removes a connector from the pool of available connectors. + * If you remove the READONLY connection, this will remove all READONLY connectors. + * @param String $name + * @param String $mode (See DB::MODE_READWRITE, DB::MODE_READONLY) + */ + static public function removeConnection( $name, $mode ) + { +//TODO: What to do when we remove the default connector? + $name = (String) $name; + switch( $mode ) + { + case DB::MODE_READWRITE: + if( isset(DB::$connections[$name][DB::MODE_READWRITE]) ) + DB::$connections[$name][DB::MODE_READWRITE]->disconnect(); + else + throw new Exception( str_replace( '%name%', $name, + str_replace( '%mode%', DB::MODE_READWRITE, + DB::ERR_NO_SUCH_CONNECTION ))); + break; + + case DB::MODE_READONLY: + if( isset(DB::$connections[$name][DB::MODE_READONLY]) ) + { + foreach( DB::$connections[$name][DB::MODE_READONLY]['con'] as $db ) + $db->disconnect(); + } + else + throw new Exception( str_replace( '%name%', $name, + str_replace( '%mode%', DB::MODE_READONLY, + DB::ERR_NO_SUCH_CONNECTION ))); + break; + + default: + throw new Exception( str_replace( '%mode%', $mode, DB::ERR_INVALID_DBCONN_MODE ) ); + } + } + + /** + * Disconnects all connectors + */ + static public function disconnectAll() + { + foreach( DB::$connections as $conn ) + { + if( isset($conn[DB::MODE_READWRITE]) ) + $conn[DB::MODE_READWRITE]->disconnect(); + if( isset($conn[DB::MODE_READONLY]) ) + { + foreach( $conn[DB::MODE_READONLY]['con'] as $ro ) + $ro->disconnect(); + } + } + } + + /** + * Sets connector with name $name as the default to be used. + * @param String $name + */ + static public function setDefault( $name ) + { + DB::$default_conn = $name; + DB::$connections['default'] =& DB::$connections[$name]; + } + } + diff --git a/system/class/DBConnector.class.php b/system/class/DBConnector.class.php new file mode 100644 index 0000000..c9d5df9 --- /dev/null +++ b/system/class/DBConnector.class.php @@ -0,0 +1,354 @@ + + */ + class DBConnector + { + const ERR_CANNOT_CONNECT = 'Cannot connect to DB'; + const ERR_EXPECTED_SQL_OBJ = 'Expected object of type SQL'; + const ERR_UNSUPPORTED_DRIVER = 'Unsupported database driver: "%driver%"'; + + const QE_QUERY = 'query'; + const QE_EXEC = 'exec'; + + private $pdo; + private $tz; + private $dsn; + private $user; + private $pass; + private $connected = FALSE; + + /** + * @param String $dsn + * @param String $user (Optional; defaults to empty string) + * @param String $pass (Optional; defaults to empty string) + */ + public function __construct( $dsn, $user = '', $pass = '' ) + { + $this->connected = FALSE; + $this->tz = DB::DEFAULT_TZ; + $this->dsn = $dsn; + $this->user = $user; + $this->pass = $pass; + } + + /** + * Internally connects to db server when necessary + */ + private function con() + { + if( !$this->connected ) $this->connect(); + } + + /** + * Publicly accessible method to force connection to db server + */ + public function connect() + { + if( $this->connected ) return TRUE; + + try { + // Without this check, PDO seems to lose 216 bytes or so + // of memory on failure to connect to an empty string connector... -mpelmear + if( $this->dsn == '' ) + throw new Exception( DBConnector::ERR_CANNOT_CONNECT ); + $this->pdo = new PDO( $this->dsn, $this->user, $this->pass ); + } catch( PDOException $pdo ) { + throw new Exception( DBConnector::ERR_CANNOT_CONNECT, NULL, $pdo ); + } + + $this->pdo->exec( 'SET time_zone="' . $this->tz . '"' ); + $this->connected = TRUE; + + return TRUE; + } + + /** + * Publicly accessible method to force disconnection from db server + */ + public function disconnect() + { + $this->pdo = NULL; + $this->connected = FALSE; + } + + /** + * Sets connection TimeZone. + * @param String $tz MySQL-compatible timezone + */ + public function setTZ( $tz ) + { + $this->tz = $tz; + if( $this->connected ) + $this->pdo->exec( 'SET time_zone="' . $this->tz . '"' ); + } + + /** + * @param SQL $sql + * @return Array + * @throws PDOException + */ + public function getCol( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + $q = $this->_query( $sql ); + + $ret = $q->fetchAll(PDO::FETCH_COLUMN, 0); + if( $sql->willCalcFoundRows() && $q->nextRowset() ) + $sql->setFoundRowsCallback( $q->fetchColumn() ); + + return $ret; + } + + /** + * @param SQL $sql + * @param uInt $col (optional, defaults to 0) + * @return Array + * @throws PDOException + */ + public function getColN( $sql, $col=0 ) + { + self::checkSQLobject( $sql ); + $this->con(); + $q = $this->_query( $sql ); + + $ret = $q->fetchAll(PDO::FETCH_COLUMN, $col); + if( $sql->willCalcFoundRows() && $q->nextRowset() ) + $sql->setFoundRowsCallback( $q->fetchColumn() ); + + return $ret; + } + + /** + * @param SQL + * @return Array + * @throws PDOException + */ + public function getAll( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + $s = $this->_query( $sql ); + + $ret = $s->fetchAll(PDO::FETCH_ASSOC); + if( $sql->willCalcFoundRows() && $s->nextRowset() ) + $sql->setFoundRowsCallback( $s->fetchColumn() ); + + return $ret; + } + + /** + * @param SQL + * @return Array + * @throws PDOException + */ + public function getRow( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + $q = $this->_query( $sql ); + + $ret = $q->fetch(PDO::FETCH_ASSOC); + if( $sql->willCalcFoundRows() && $q->nextRowset() ) + $sql->setFoundRowsCallback( $q->fetchColumn() ); + + return $ret; + } + + /** + * If there are two columns in the resultset, return a k=>v array + * with col1 as key, col2 as value. + * If there are more than two columns in the resultset, + * return a k=>v array where col1 is the key and the + * remainder of the columns stay in an array as the value. + * @param SQL + * @return Array + * @throws PDOException + */ + public function getAssoc( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + $sth = $this->_query( $sql ); + + if( !is_object( $sth ) ) + $this->throwError(); + + $rs = array(); + + if( $sth->columnCount() == 2 ) + { + while( $row = $sth->fetch(PDO::FETCH_ASSOC) ) + $rs[array_shift($row)] = array_shift($row); + } + else + { + while( $row = $sth->fetch(PDO::FETCH_ASSOC) ) + $rs[array_shift($row)] = $row; + } + if( $sql->willCalcFoundRows() && $sth->nextRowset() ) + $sql->setFoundRowsCallback( $sth->fetchColumn() ); + + return $rs; + } + + /** + * Returns the first column from the first row of the result set. + * @param SQL + * @return Mixed + * @throws PDOException + */ + public function getOne( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + $sth = $this->_query( $sql ); + + $ret = $sth ? $sth->fetchColumn() : NULL; + if( $sql->willCalcFoundRows() && $sth->nextRowset() ) + $sql->setFoundRowsCallback( $sth->fetchColumn() ); + + return $ret; + } + + /** + * Use for buffered queries and non-SELECT statements. + * Returns DBRecordSet for SELECT statements, + * Returns uInt (number of rows affected) for other statements. + * @param SQL + * @return DBRecordSet|uInt + * @throws PDOException + */ + public function query( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + + $q = $sql->getSQL(); + if( strtoupper(substr(ltrim($q), 0, 6)) == 'SELECT' ) + { + $res = $this->_query( $sql ); + return new DBRecordSet( $res ); + } + else + return $this->_exec( $sql ); + } + + /** + * Use only for statements that only return number of rows affected. + * (INSERT, UPDATE, etc.) + * @param SQL + * @return uInt (Number of rows affected) + * @throws PDOException + */ + public function exec( $sql ) + { + self::checkSQLobject( $sql ); + $this->con(); + return $this->_exec( $sql ); + } + + /** + * Internally execute various types of query calls. + * @param SQL $sql + * @param String $query_or_exec (Optional; Defaults to QE_QUERY. Either DBConnector::QE_EXEC or DBConnector::QE_QUERY) + */ + private function _query( $sql, $query_or_exec = self::QE_QUERY ) + { + $bind_style_params = $this->prepareBindStyleParams(); + + if( $query_or_exec == DBConnector::QE_EXEC ) + $r = $this->pdo->exec( $sql->getSQL( $bind_style_params ) ); + else + $r = $this->pdo->query( $sql->getSQL( $bind_style_params ) ); + + if( $r === FALSE ) + { + // There was an error. See what to do + $error_info = $this->pdo->errorInfo(); + if( $error_info[0] == 'HY000' && $error_info[1] == '2006' ) + { + // HY000/2006 seems to be the error we get when we lost our connection. + // Try to reconnect and re-run the query. + $this->disconnect(); + $this->connect(); + + if( $query_or_exec == DBConnector::QE_EXEC ) + $r = $this->pdo->exec( $sql->getSQL( $bind_style_params ) ); + else + $r = $this->pdo->query( $sql->getSQL( $bind_style_params ) ); + + if( $r === FALSE ) + $this->throwError(); + } + else + $this->throwError(); + } + + return $r; + } + + private function _exec( $sql ) + { + return $this->_query( $sql, DBConnector::QE_EXEC ); + } + + /** + * Returns parameters suitable to be passed to SQL class + * so that binding is appropriate for the connector in use. + * @return Array + */ + private function prepareBindStyleParams() + { + $bind_style_params = array(); + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + switch( $driver ) + { + case 'mysql': + return array( SQL::PARAM_ESCAPE_STYLE => SQL::ESCAPE_STYLE_MYSQL ); + break; + case 'sqlite': + return array( SQL::PARAM_ESCAPE_STYLE => SQL::ESCAPE_STYLE_SQLITE ); + break; + default: + throw new Exception( str_replace( '%driver%', $driver, DBConnector::ERR_UNSUPPORTED_DRIVER ) ); + } + } + + /** + * Verifies that $sql is a valid SQL object. + * @param Mixed $sql + * @throws Exception( DBConnector::ERR_EXPECTED_SQL_OBJ ) + */ + public static function checkSQLobject( $sql ) + { + if( !($sql instanceof SQL) ) + throw new Exception( DBConnector::ERR_EXPECTED_SQL_OBJ ); + } + + /** + * Gathers PDO exception data an throws an exception + * @throws PDOException + */ + private function throwError() + { + $error = $this->pdo->errorInfo(); + throw new PDOException( $error[0].' ('.$error[1].'): '.$error[2] ); + } + + /** + * @return String Last insert ID for the current DB + */ + public function getLastInsertID() + { + return $this->pdo->lastInsertId(); + } + } + diff --git a/system/class/Debug.class.php b/system/class/Debug.class.php new file mode 100644 index 0000000..fbcabfe --- /dev/null +++ b/system/class/Debug.class.php @@ -0,0 +1,9 @@ + + */ + class SQL + { + const PARAM_ESCAPE_STYLE = 'param-escape-style'; + const ESCAPE_STYLE_MYSQL = 'mysql'; + const ESCAPE_STYLE_SQLITE = 'sqlite'; + + const ERR_INTERNAL = 'Internal error'; + const ERR_UNKNOWN_DATATYPE = 'Unknown datatype during bind: "%type%"'; + const ERR_INVALID_ESCAPE_STYLE = 'Invalid escape style: "%style%"'; + const ERR_INVALID_DATETIME = 'Encountered invalid datetime value with not default value specified'; + const ERR_NO_SQLITE_DRIVER = 'No compatible SQLite driver available'; + const ERR_EXPECTED_ARRAY = 'Expected an array'; + const ERR_EXPECTED_UINT = 'Expected an unsigned integer'; + const ERR_BIND_REQUIRED_INT = 'Bind required an integer, but received: "%arg%"'; + const ERR_BIND_REQUIRED_REAL = 'Bind required a real number, but received: "%arg%"'; + const ERR_INVALID_SQL_FIELD = 'Encountered an invalid SQL field: "%fieldname%"'; + const ERR_CALC_FOUND_ROWS_DISABLED = 'You must enabled the SQL_CALC_FOUND_ROWS feature before you can retrieve the results'; + const ERR_ARG_COUNT = 'Incorrect number of arguments for query bind (expected %expected% but received %received%)'; + const ERR_EXPECTED_ARG_AT_POS = 'Expected an argument to bind to position %pos%, but did not find it.'; + +//TODO: Exactly how long can a sql fieldname be? + const SQLFIELD_MAX_LEN = 45; + + /** + * The unbound query + * @var String + */ + private $sql; + /** + * Arguments to bind into the query + * @var Array + */ + private $args = array(); + /** + * Whether or not to calculate and cache SQL_CALC_FOUND_ROWS + * @var Bool + */ + private $calc_found_rows = FALSE; + /** + * Number of found rows after the query has been run with the Ember Database connector. + * ($calc_found_rows must have been set to TRUE when the query was bound and when the DB conector ran the query) + * @var NULL|uInt + */ + private $found_rows = NULL; + /** + * Amount of time the query took to run. + * (Set by the Ember database connector when in debug mode) + * @var NULL|Number + */ + private $query_execution_time = NULL; + /** + * When in debug mode, this will store the bound query after getSQL() is called. + * @var NULL|String + */ + private $bound_query = NULL; + + /** + * Creates an SQL object with $sql query. + * You probably want SQL::bind() in most cases. + * @param String $sql SQL query + */ + function __construct( $sql ) + { + $this->sql = trim($sql); + } + + /** + * + * @param String $sql + * @param Mixed N additional parameters to be bound into $sql + * @return SQL + */ + static public function bind( $sql ) + { + $q = new SQL( $sql ); + + $argc = func_num_args(); + if( $argc > 1 ) + { + $args = func_get_args(); + array_shift( $args ); // we don't want the $sql query, just the args to bind + $q->bindArray( $args ); + } + + return $q; + } + + /** + * Bind N values (accepts n arguments) + * @param Mixed N arguments + */ + public function bindVal() + { + foreach( func_get_args() as $arg ) + $this->args[] = $arg; + } + + /** + * Bind value to specific position in query + * @param uInt position + * @param Mixed value + * @throws Exception( SQL::ERR_EXPECTED_UINT ) + */ + public function bindPosition( $pos, $val ) + { + $pos = intval($pos); + if( $pos < 0 ) + throw new Exception( SQL::ERR_EXPECTED_UINT ); + $this->args[$pos] = $val; + } + + /** + * Bind an array of values, in order, to the next available bind positions. + * Keys in the array are ignored. + * @param Array $args values + * @throws Exception( SQL::ERR_EXPECTED_ARRAY ) + */ + public function bindArray( $args ) + { + if( !is_array( $args ) ) + throw new Exception( SQL::ERR_EXPECTED_ARRAY ); + + foreach( $args as $a ) + $this->args[] = $a; + } + + /** + * Clears all values set to bind into query. + * (Useful when reusing a SQL object for multiple queries to the db) + */ + public function bindClear() + { + $this->args = array(); + } + + /** + * Capture the number of rows found for this query (automatically using SQL_CALC_FOUND_ROWS) + * @param Bool + * @see SQL::willCalcFoundRows() + */ + public function setCalcFoundRows( $calc_found_rows = TRUE ) + { + $this->calc_found_rows = $calc_found_rows; + } + + /** + * Whether this object will calculate (and cash) the result of SELECT SQL_CALC_FOUND_ROWS ... + * @return Bool + * @see SQL::setCalcFoundRows() + */ + public function willCalcFoundRows() + { + return $this->calc_found_rows; + } + + /** + * Returns result from SELECT FOUND_ROWS() call + * @return uInt + */ + public function getFoundRows() + { + if( !$this->calc_found_rows ) + throw new Exception( SQL::ERR_CALC_FOUND_ROWS_DISABLED ); + + return intval( $this->found_rows ); + } + + /** + * Used by DB class as a callback to pass back the result of SQL_CALC_FOUND_ROWS. + * Do not usre. This is used internally by the Ember framework. + * @param uInt $foundrows + * @access private + */ + public function setFoundRowsCallback( $foundrows ) + { + $this->found_rows = $foundrows; + } + + /** + * Hook for DB class to record query execution time in this object. + * Do not use. It is used internally by the Ember framework. + * @param Number $t + * @access private + */ + public function recordQueryTimeHook( $t ) + { + $this->query_execution_time = $t; + } + + /** + * Returns the bound query. + * Note that every time this method is called it will perform the bind again, so + * do not call this repeatedly unless that's actually what you want. + * @return String + * @throws Exception( SQL::ERR_ARG_COUNT ) + */ + public function getSQL() + { + $sql = $this->sql;; + + if( preg_match_all('/\?([A-Za-z])([A-Za-z])?/', $sql, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE) ) + { + $arg_count = count($matches); + if( count($this->args) != $arg_count ) + throw new Exception( + str_replace( '%expected%', $arg_count, + str_replace( '%received%', count($this->args), + SQL::ERR_ARG_COUNT + ))); + + // Loop backward to preserve positions in the string + for( $i=$arg_count-1; $i >= 0; $i-- ) + { + if( !array_key_exists($i,$this->args) ) + throw new Exception( str_replace( '%pos%', $i, SQL::ERR_EXPECTED_ARG_AT_POS ) ); + + $arg = $this->args[$i]; + $type = $matches[$i][1][0]; + $modifier = isset($matches[$i][2][0]) ? $matches[$i][2][0] : NULL; + + $arg = SQL::dataBind( $type, $arg, $modifier, 0 ); + + $sql = substr_replace( $sql, $arg, $matches[$i][0][1], strlen($matches[$i][0][0]) ); + } + } + + // $sql contains the bound query at this point + + if( $this->calc_found_rows ) + { + // User requested the SQL_CALC_FOUND_ROWS result from the DB + if( FALSE === strpos( strtoupper( $sql ), 'SQL_CALC_FOUND_ROWS' ) ) + { + if( 'SELECT' == strtoupper( substr( $sql, 0, 6 ) ) ) + $sql = 'SELECT SQL_CALC_FOUND_ROWS' . substr( $sql, 6, strlen($sql)-6 ); + } + $sql = trim($sql); + if( FALSE === strpos( strtoupper( $sql ), 'SELECT FOUND_ROWS()' ) ) + { + if (substr($sql, -1) != ';') + $sql .= '; '; + $sql .= 'SELECT FOUND_ROWS();'; + } + } + + if( Debug::isEnabled() ) + $this->bound_query = $sql; + + return $sql; + } + + /** + * Magically turn this object into the bound query it represents + * @return String + */ + public function __toString() + { + return $this->getSQL(); + } + + /** + * @param String $type + * @param Mixed $arg + * @param String|NULL $modifier + * @param Mixed $default + * @return Mixed + */ + static public function dataBind( $type, $arg, $modifier=NULL, $default = 0, $params=NULL ) + { + $escape_style = SQL::ESCAPE_STYLE_MYSQL; + +//TODO: breakout params + + switch( $type{0} ) + { + case 'f': // mysql fieldname +//TODO: Can we autodetect mysql and sqlite from PDO when called from the DB class? + $fieldname_parts = explode( '.', $arg ); + if( count($fieldname_parts) <= 0 || count($fieldname_parts) > 3 ) + throw new Exception( str_replace( '%fieldname%', $arg, SQL::ERR_INVALID_SQL_FIELD ) ); + + foreach( $fieldname_parts as &$fp ) + { + // escape backticks (for mysql) + if( $escape_style == SQL::ESCAPE_STYLE_MYSQL ) + $fp = str_replace( '`', '``', $fp ); +//TODO: Are there any other valid characters for a fieldname? + if( 1 != preg_match( '/^[A-Za-z_`][A-Za-z0-9_`]*$/', $fp ) || strlen($fp) > SQL::SQLFIELD_MAX_LEN ) + throw new Exception( str_replace( '%fieldname%', $arg, SQL::ERR_INVALID_SQL_FIELD ) ); + switch( $escape_style ) + { + case SQL::ESCAPE_STYLE_MYSQL: + $fp = '`' . $fp . '`'; + break; + case SQL::ESCAPE_STYLE_SQLITE: + $fp = '"' . $fp . '"'; + break; + default: + throw new Exception( SQL::ERR_INTERNAL . ' ' . __LINE__ ); + } + } + $arg = implode( '.', $fieldname_parts ); + break; + case 'd': // mysql datetime (accepts unix timestamp or MySQL DATETIME) + if( ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ) + { + $arg = 'NULL'; + break; + } + + if( (String) intval($arg) == $arg ) + $arg = date( 'Y-m-d H:i:s', $arg ); + + // YYYY-mm-dd HH:ii:ss + if( 1 != preg_match( '/^([1-3][0-9]{3,3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|3[0-1])\s([0-1][0-9]|2[0-4]):([0-5][0-9]):([0-5][0-9])$/', $arg ) ) + { + // YYYY-mm-dd + if( 1 != preg_match( '/^([1-3][0-9]{3,3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|3[0-1])$/', $arg ) ) + throw new Exception( SQL::ERR_INVALID_DATETIME ); + $arg = SQL::stringEscape( $arg, $escape_style ); + break; // validated (YYYY-MM-DD) + } + $arg = SQL::stringEscape( $arg, $escape_style ); + break; // validated (YYYY-mm-dd HH:ii:ss) + case 'h': // htmlspecialchars + if( $arg ) + $arg = htmlspecialchars( $arg ); + case 's': // string + $arg = ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ? 'NULL' : SQL::stringEscape( $arg, $escape_style ); + break; + case 'i': // signed integer + if( ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ) + $arg = 'NULL'; + else + { + $tmp = intval($arg); + if( (string) $tmp == (string) $arg ) + $arg = $tmp; + else if( PHP_VERSION_ID >= 50100 ) // need 5.0.5, but let's assume 5.1 + { +//TODO: optimize this check by caching it as a const when this file is loaded + if( strlen($arg) > strlen(PHP_INT_MAX)-1 ) + { + if( 1 != preg_match( '/^[-]{0,1}[0-9]+$/', (String) $arg ) ) + { + if( $default === FALSE ) + throw new Exception( str_replace( '%arg%', $arg, SQL::ERR_BIND_REQUIRED_INT ) ); + else + $arg = $default; + } + } + else + { + if( $default === FALSE ) + throw new Exception( str_replace( '%arg%', $arg, SQL::ERR_BIND_REQUIRED_INT ) ); + else + $arg = $default; + } + } + else + throw new Exception( SQL::ERR_INTERNAL . ' ' . __LINE__ ); + } + break; + case 'u': // unsigned integer + if( ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ) + $arg = 'NULL'; + else + { + $tmp = intval($arg); + if( (string) $tmp == (string) $arg && $tmp > 0 ) + $arg = $tmp; + else if( PHP_VERSION_ID >= 50100 ) // need 5.0.5, but let's assume 5.1 + { + if( strlen($arg) > strlen(PHP_INT_MAX)-1 ) + { + if( 1 != preg_match( '/^[0-9]+$/', (String) $arg ) ) + { + if( $default === FALSE ) + throw new Exception( str_replace( '%arg%', $arg, SQL::ERR_BIND_REQUIRED_INT ) ); + else + $arg = $default; + } + } + else + { + if( $default === FALSE ) + throw new Exception( str_replace( '%arg%', $arg, SQL::ERR_BIND_REQUIRED_INT ) ); + else + $arg = $default; + } + } + else + throw new Exception( SQL::ERR_INTERNAL . ' ' . __LINE__ ); + } + break; + case 'a': // array (for use with IN) + if( ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ) + { + // this covers bad data, false/null data, and also empty arrays + $arg = '(NULL)'; + break; + } + if( !is_array( $arg ) ) + throw new Exception( SQL::ERR_EXPECTED_ARRAY ); + foreach( $arg as &$arg_element ) + $arg_element = SQL::stringEscape( htmlspecialchars($arg_element), $escape_style ); + $arg = '(' . implode( ',', $arg ) . ')'; + break; + case 'r': // real number + if( ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ) + $arg = 'NULL'; + else + { + if( 1 != preg_match( '/^[\-]?[0-9]*[\.]?[0-9]*([eE][\-]?[0-9]*)?$/', (String) $arg ) ) + { + if( $default === FALSE ) + throw new Exception( str_replace( '%arg%', $arg, SQL::ERR_BIND_REQUIRED_REAL ) ); + else + $arg = $default; + } + } + break; + case 'z': // serialized data + $arg = ($modifier == 'n' && $arg === NULL) || ($modifier == 'N' && !$arg) ? 'NULL' : SQL::stringEscape( @serialize($arg), $escape_style ); + break; + default: + throw new Exception( str_replace( '%type%', $type, SQL::ERR_UNKNOWN_DATATYPE ) ); + } + + return $arg; + } + + /** + * Escape a string in the appropriate format + * @param String $arg (String to escape) + * @param String $escape_style (SQL::ESCAPE_STYLE_MYSQL or SQL::ESCAPE_STYLE_SQLITE) + */ + static public function stringEscape( $arg, $escape_style ) + { + switch( $escape_style ) + { + case SQL::ESCAPE_STYLE_MYSQL: + return '"' . addslashes( (String) $arg ) . '"'; + case SQL::ESCAPE_STYLE_SQLITE: + // EMBER_SQLCLASS_SQLITE_DRIVER is defined at the end of this file + switch( EMBER_SQLCLASS_SQLITE_DRIVER ) + { + case 'sqlite3': + return '\'' . SQLite3::escapeString( (String) $arg ) . '\''; + case 'sqlite': + return '\'' . sqlite_escape_string( (String) $arg ) . '\''; + default: + throw new Exception( SQL::ERR_NO_SQLITE_DRIVER ); + } + break; + default: + throw new Exception( str_replace( '%style%', $escape_style, SQL::ERR_INVALID_ESCAPE_STYLE ) ); + } + } + } + +// define the EMBER_SQLCLASS_SQLITE_DRIVER constant for stringEscape() +if( extension_loaded('sqlite3') ) + define( 'EMBER_SQLCLASS_SQLITE_DRIVER', 'sqlite3' ); +else if( extension_loaded('sqlite') ) + define( 'EMBER_SQLCLASS_SQLITE_DRIVER', 'sqlite' ); +else + define( 'EMBER_SQLCLASS_SQLITE_DRIVER', 'none' ); + +if( !defined('PHP_VERSION_ID') ) +{ + $version = explode('.', PHP_VERSION); + define('PHP_VERSION_ID', ($version[0] * 10000 + $version[1] * 100 + $version[2])); +}