lib/db/sql/schema.php
<?php

/**
 *  SQL Table Schema Builder extension for the PHP Fat-Free Framework
 *
 *  The contents of this file are subject to the terms of the GNU General
 *  Public License Version 3.0. You may not use this file except in
 *  compliance with the license. Any of the license terms and conditions
 *  can be waived if you get permission from the copyright holder.
 *
 *  crafted by   __ __     __
 *              |__|  |--.|  |--.-----.-----.
 *              |  |    < |    <|  -__|-- __|
 *              |__|__|__||__|__|_____|_____|
 *
 *  Copyright (c) 2014 by ikkez
 *  Christian Knuth <ikkez0n3@gmail.com>
 *  https://github.com/ikkez/F3-Sugar/
 *
 *  @package DB
 *  @version 2.1.1
 **/


namespace DB\SQL;

use DB\SQL;

class Schema extends DB_Utils {

    public
        $dataTypes = array(
            'BOOLEAN' =>    array('mysql|sqlite2?|pgsql' => 'BOOLEAN',
                                  'mssql|sybase|dblib|odbc|sqlsrv' => 'bit',
                                  'ibm' => 'numeric(1,0)',
            ),
            'INT1' =>       array('mysql' => 'TINYINT UNSIGNED',
                                  'sqlite2?' => 'integer',
                                  'mssql|sybase|dblib|odbc|sqlsrv' => 'tinyint',
                                  'pgsql|ibm' => 'smallint',
            ),
            'INT2' =>       array('mysql' => 'SMALLINT',
                                  'sqlite2?' => 'integer',
                                  'pgsql|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'smallint',
            ),
            'INT4' =>       array('sqlite2?|pgsql|sybase|odbc|sqlsrv|imb' => 'integer',
                                  'mysql|mssql|dblib' => 'int',
            ),
            'INT8' =>       array('sqlite2?' => 'integer',
                                  'pgsql|mysql|mssql|sybase|dblib|odbc|sqlsrv|imb' => 'bigint',
            ),
            'FLOAT' =>      array('mysql|sqlite2?' => 'FLOAT',
                                  'pgsql' => 'double precision',
                                  'mssql|sybase|dblib|odbc|sqlsrv' => 'float',
                                  'imb' => 'decfloat'
            ),
            'DOUBLE' =>     array('mysql|sqlite2?|ibm' => 'DOUBLE',
                                  'pgsql|sybase|odbc|sqlsrv' => 'double precision',
                                  'mssql|dblib' => 'decimal',
            ),
            'VARCHAR128' => array('mysql|pgsql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(128)',
            ),
            'VARCHAR256' => array('mysql|pgsql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(255)',
            ),
            'VARCHAR512' => array('mysql|pgsql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(512)',
            ),
            'TEXT' =>       array('mysql|sqlite2?|pgsql|mssql' => 'text',
                                  'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)',
                                  'ibm' => 'BLOB SUB_TYPE TEXT',
            ),
            'LONGTEXT' =>   array('mysql' => 'LONGTEXT',
                                  'sqlite2?|pgsql|mssql' => 'text',
                                  'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)',
                                  'ibm' => 'CLOB(2000000000)',
            ),
            'DATE' =>       array('mysql|sqlite2?|pgsql|mssql|sybase|dblib|odbc|sqlsrv|ibm' => 'date',
            ),
            'DATETIME' =>   array('pgsql' => 'timestamp without time zone',
                                  'mysql|sqlite2?|mssql|sybase|dblib|odbc|sqlsrv' => 'datetime',
                                  'ibm' => 'timestamp',
            ),
            'TIMESTAMP' =>  array('mysql|ibm' => 'timestamp',
                                  'pgsql|odbc' => 'timestamp without time zone',
                                  'sqlite2?|mssql|sybase|dblib|sqlsrv'=>'DATETIME',
            ),
            'BLOB' =>       array('mysql|odbc|sqlite2?|ibm' => 'blob',
                                  'pgsql' => 'bytea',
                                  'mssql|sybase|dblib' => 'image',
                                  'sqlsrv' => 'varbinary(max)',
            ),
        ),
        $defaultTypes = array(
            'CUR_STAMP' =>  array('mysql' => 'CURRENT_TIMESTAMP',
                                  'mssql|sybase|dblib|odbc|sqlsrv' => 'getdate()',
                                  'pgsql' => 'LOCALTIMESTAMP(0)',
                                  'sqlite2?' => "(datetime('now','localtime'))",
            ),
        );

    public
        $name;

    /** @var \Base */
    protected $fw;

    const
        // DataTypes and Aliases
        DT_BOOL = 'BOOLEAN',
        DT_BOOLEAN = 'BOOLEAN',
        DT_INT1 = 'INT1',
        DT_TINYINT = 'INT1',
        DT_INT2 = 'INT2',
        DT_SMALLINT = 'INT2',
        DT_INT4 = 'INT4',
        DT_INT = 'INT4',
        DT_INT8 = 'INT8',
        DT_BIGINT = 'INT8',
        DT_FLOAT = 'FLOAT',
        DT_DOUBLE = 'DOUBLE',
        DT_DECIMAL = 'DOUBLE',
        DT_VARCHAR128 = 'VARCHAR128',
        DT_VARCHAR256 = 'VARCHAR256',
        DT_VARCHAR512 = 'VARCHAR512',
        DT_TEXT = 'TEXT',
        DT_LONGTEXT = 'LONGTEXT',
        DT_DATE = 'DATE',
        DT_DATETIME = 'DATETIME',
        DT_TIMESTAMP = 'TIMESTAMP',
        DT_BLOB = 'BLOB',
        DT_BINARY = 'BLOB',

        // column default values
        DF_CURRENT_TIMESTAMP = 'CUR_STAMP';


    public function __construct(\DB\SQL $db)
    {
        $this->fw = \Base::instance();
        parent::__construct($db);
    }

    /**
     * get a list of all databases
     * @return array|bool
     */
    public function getDatabases()
    {
        $cmd = array(
            'mysql' => 'SHOW DATABASES',
            'pgsql' => 'SELECT datname FROM pg_catalog.pg_database',
            'mssql|sybase|dblib|sqlsrv|odbc' => 'EXEC SP_HELPDB',
        );
        $query = $this->findQuery($cmd);
        if (!$query) return false;
        $result = $this->db->exec($query);
        if (!is_array($result)) return false;
        foreach($result as &$db)
            if (is_array($db)) $db = array_shift($db);
        return $result;
    }

    /**
     * get all tables of current DB
     * @return bool|array list of tables, or false
     */
    public function getTables()
    {
        $cmd = array(
            'mysql' => array(
                "show tables"),
            'sqlite2?' => array(
                "SELECT name FROM sqlite_master WHERE type='table' AND name!='sqlite_sequence'"),
            'pgsql|sybase|dblib' => array(
                "select table_name from information_schema.tables where table_schema = 'public'"),
            'mssql|sqlsrv|odbc' => array(
                "select table_name from information_schema.tables"),
            'ibm' => array("select TABLE_NAME from sysibm.tables"),
        );
        $query = $this->findQuery($cmd);
        if (!$query[0]) return false;
        $tables = $this->db->exec($query[0]);
        if ($tables && is_array($tables) && count($tables) > 0)
            foreach ($tables as &$table)
                $table = array_shift($table);
        return $tables;
    }

    /**
     * returns a table object for creation
     * @param $name
     * @return bool|TableCreator
     */
    public function createTable($name)
    {
        return new TableCreator($name,$this);
    }

    /**
     * returns a table object for altering operations
     * @param $name
     * @return bool|TableModifier
     */
    public function alterTable($name)
    {
        return new TableModifier($name,$this);
    }

    /**
     * rename a table
     * @param string $name
     * @param string $new_name
     * @param bool $exec
     * @return bool
     */
    public function renameTable($name, $new_name, $exec = true)
    {
        $name = $this->db->quotekey($name);
        $new_name = $this->db->quotekey($new_name);
        if (preg_match('/odbc/', $this->db->driver())) {
            $queries = array();
            $queries[] = "SELECT * INTO $new_name FROM $name;";
            $queries[] = $this->dropTable($name, false);
            return ($exec) ? $this->db->exec($queries) : implode("\n",$queries);
        } else {
            $cmd = array(
                'sqlite2?|pgsql' =>
                    "ALTER TABLE $name RENAME TO $new_name;",
                'mysql|ibm' =>
                    "RENAME TABLE $name TO $new_name;",
                'mssql|sqlsrv|sybase|dblib|odbc' =>
                    "sp_rename {$name}, $new_name"
            );
            $query = $this->findQuery($cmd);
            if (!$exec) return $query;
            return (preg_match('/mssql|sybase|dblib|sqlsrv/', $this->db->driver()))
                ? @$this->db->exec($query) : $this->db->exec($query);
        }
    }

    /**
     * drop a table
     * @param \DB\SQL\TableBuilder|string $name
     * @param bool $exec
     * @return bool
     */
    public function dropTable($name, $exec = true)
    {
        if (is_object($name) && $name instanceof TableBuilder)
            $name = $name->name;
        $cmd = array(
            'mysql|ibm|sqlite2?|pgsql|sybase|dblib' =>
                'DROP TABLE IF EXISTS '.$this->db->quotekey($name).';',
            'mssql|sqlsrv|odbc' =>
                "IF OBJECT_ID('[$name]', 'U') IS NOT NULL DROP TABLE [$name];"
        );
        $query = $this->findQuery($cmd);
        return ($exec) ? $this->db->exec($query) : $query;
    }

}

abstract class TableBuilder extends DB_Utils {

    protected   $columns, $pkeys, $queries, $increments, $rebuild_cmd, $suppress;
    public      $name, $schema;

    const
        TEXT_NoDefaultForTEXT = "Column `%s` of type TEXT can't have a default value.",
        TEXT_ColumnExists = "Cannot add the column `%s`. It already exists.";

    /**
     * @param string $name
     * @param Schema $schema
     * @return \DB\SQL\TableBuilder
     */
    public function __construct($name, Schema $schema)
    {
        $this->name = $name;
        $this->schema = $schema;
        $this->columns = array();
        $this->queries = array();
        $this->pkeys = array('id');
        $this->increments = 'id';
        parent::__construct($schema->db);
    }

    /**
     * generate SQL query and execute it if $exec is true
     * @param bool $exec
     */
    abstract public function build($exec = TRUE);

    /**
     * add a new column to this table
     * @param string|Column $key column name or object
     * @param null|array $args optional config array
     * @return \DB\SQL\Column
     */
    public function addColumn($key,$args = null)
    {
        if ($key instanceof Column) {
            $args = $key->getColumnArray();
            $key = $key->name;
        }
        if (array_key_exists($key,$this->columns))
            trigger_error(sprintf(self::TEXT_ColumnExists,$key));
        $column = new Column($key, $this);
        if ($args)
            foreach ($args as $arg => $val)
                $column->{$arg} = $val;
        // skip default pkey field
        if (count($this->pkeys) == 1 && in_array($key,$this->pkeys))
            return $column;
        return $this->columns[$key] =& $column;
    }

    /**
     * create index on one or more columns
     * @param string|array $index_cols Column(s) to be indexed
     * @param              $search_cols
     * @param bool         $unique     Unique index
     * @param int          $length     index length for text fields in mysql
     */
    protected function _addIndex($index_cols, $search_cols, $unique, $length)
    {
        if (!is_array($index_cols))
            $index_cols = array($index_cols);
        $quotedCols = array_map(array($this->db, 'quotekey'), $index_cols);
        if (preg_match('/mysql/', $this->db->driver()))
            foreach($quotedCols as $i=>&$col)
                if(strtoupper($search_cols[$index_cols[$i]]['type']) == 'TEXT')
                    $col.='('.$length.')';
        $cols = implode(',', $quotedCols);
        $name = $this->db->quotekey($this->name.'___'.implode('__', $index_cols));
        $table = $this->db->quotekey($this->name);
        $index = $unique ? 'UNIQUE INDEX' : 'INDEX';
        $cmd = array(
            'pgsql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' =>
                "CREATE $index $name ON $table ($cols);",
            'mysql' => //ALTER TABLE is used because of MySQL bug #48875
                "ALTER TABLE $table ADD $index $name ($cols);",
        );
        $query = $this->findQuery($cmd);
        $this->queries[] = $query;
    }

    /**
     * set primary / composite key to table
     * @param string|array $pkeys
     * @return bool
     */
    public function primary($pkeys) {
        if (empty($pkeys))
            return false;
        if (!is_array($pkeys))
            $pkeys = array($pkeys);
        // single pkey
        $this->increments = $pkeys[0];
        $this->pkeys = $pkeys;
        // drop duplicate pkey definition
        if (array_key_exists($this->increments,$this->columns))
            unset($this->columns[$this->increments]);
        // set flag on new fields
        foreach ($pkeys as $name)
            if(array_key_exists($name,$this->columns))
                $this->columns[$name]->pkey = true;
        // composite key
        if (count($pkeys) > 1) {
            $pkeys_quoted = array_map(array($this->db,'quotekey'), $pkeys);
            $pk_string = implode(', ', $pkeys_quoted);
            if (preg_match('/sqlite2?/', $this->db->driver())) {
                // rebuild table with new primary keys
                $this->rebuild_cmd['pkeys'] = $pkeys;
                return;
            } else {
                $table = $this->db->quotekey($this->name);
                $table_key = $this->db->quotekey($this->name.'_pkey');
                $cmd = array(
                    'odbc' =>
                        "CREATE INDEX $table_key ON $table ( $pk_string );",
                    'mysql' =>
                        "ALTER TABLE $table DROP PRIMARY KEY, ADD PRIMARY KEY ( $pk_string );",
                    'mssql|sybase|dblib|sqlsrv' => array(
                        "ALTER TABLE $table DROP CONSTRAINT PK_".$this->name."_ID;",
                        "ALTER TABLE $table ADD CONSTRAINT $table_key PRIMARY KEY ( $pk_string );",
                    ),
                    'pgsql' => array(
                        "ALTER TABLE $table DROP CONSTRAINT $table_key;",
                        "ALTER TABLE $table ADD CONSTRAINT $table_key PRIMARY KEY ( $pk_string );",
                    ),
                );
                $query = $this->findQuery($cmd);
                if (!is_array($query))
                    $query = array($query);
                foreach ($query as $q)
                    $this->queries[] = $q;
            }
        }
    }

}

class TableCreator extends TableBuilder {

    const
        TEXT_TableAlreadyExists = "Table `%s` already exists. Cannot create it.";

    /**
     * generate SQL query for creating a basic table, containing an ID serial field
     * and execute it if $exec is true, otherwise just return the generated query string
     * @param bool $exec
     * @return bool|TableModifier|string
     */
    public function build($exec = TRUE)
    {
        // check if already existing
        if ($exec && in_array($this->name, $this->schema->getTables())) {
            trigger_error(sprintf(self::TEXT_TableAlreadyExists,$this->name));
            return false;
        }
        $cols = '';
        if (!empty($this->columns))
            foreach ($this->columns as $cname => $column) {
                // no defaults for TEXT type
                if ($column->default !== false && is_int(strpos(strtoupper($column->type),'TEXT'))) {
                    trigger_error(sprintf(self::TEXT_NoDefaultForTEXT, $column->name));
                    return false;
                }
                $cols .= ', '.$column->getColumnQuery();
            }
        $table = $this->db->quotekey($this->name);
        $id = $this->db->quotekey($this->increments);
        $cmd = array(
            'sqlite2?|sybase|dblib' =>
                "CREATE TABLE $table ($id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT".$cols.");",
            'mysql' =>
                "CREATE TABLE $table ($id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT".$cols.") DEFAULT CHARSET=utf8 COLLATE utf8_unicode_ci;",
            'pgsql' =>
                "CREATE TABLE $table ($id SERIAL PRIMARY KEY".$cols.");",
            'mssql|odbc|sqlsrv' =>
                "CREATE TABLE $table ($id INT IDENTITY CONSTRAINT PK_".$this->name."_ID PRIMARY KEY".$cols.");",
            'ibm' =>
                "CREATE TABLE $table ($id INTEGER AS IDENTITY NOT NULL $cols, PRIMARY KEY($id));",
        );
        $query = $this->findQuery($cmd);
        // composite key for sqlite
        if (count($this->pkeys) > 1 && preg_match('/sqlite2?/', $this->db->driver())) {
            $pk_string = implode(', ', $this->pkeys);
            $query = "CREATE TABLE $table ($id INTEGER NULL".$cols.", PRIMARY KEY ($pk_string) );";
            $newTable = new TableModifier($this->name, $this->schema);
            // auto-incrementation in composite primary keys
            $pk_queries = $newTable->_sqlite_increment_trigger($this->increments);
            $this->queries = array_merge($this->queries, $pk_queries);
        }
        array_unshift($this->queries, $query);
        // indexes
        foreach ($this->columns as $cname => $column)
            if ($column->index)
                $this->addIndex($cname, $column->unique);
        if (!$exec)
            return $this->queries;
        $this->db->exec($this->queries);
        return isset($newTable) ? $newTable : new TableModifier($this->name,$this->schema);
    }

    /**
     * create index on one or more columns
     * @param string|array $columns Column(s) to be indexed
     * @param bool         $unique  Unique index
     * @param int          $length  index length for text fields in mysql
     */
    public function addIndex($columns, $unique = FALSE, $length = 20)
    {
        if (!is_array($columns))
            $columns = array($columns);
        $cols = $this->columns;
        foreach ($cols as &$col)
            $col = $col->getColumnArray();
        parent::_addIndex($columns,$cols,$unique,$length);
    }

}


class TableModifier extends TableBuilder {

    protected
        $colTypes, $rebuild_cmd;

    const
        // error messages
        TEXT_TableNotExisting = "Unable to alter table `%s`. It does not exist.",
        TEXT_NotNullFieldNeedsDefault = 'You cannot add the not nullable column `%s` without specifying a default value',
        TEXT_ENGINE_NOT_SUPPORTED = 'DB Engine `%s` is not supported for this action.';

    /**
     * generate SQL queries for altering the table and execute it if $exec is true,
     * otherwise return the generated query string
     */
    public function build($exec = TRUE)
    {
        // check if table exists
        if (!in_array($this->name, $this->schema->getTables()))
            trigger_error(sprintf(self::TEXT_TableNotExisting, $this->name));

        if ($sqlite = preg_match('/sqlite2?/', $this->db->driver())) {
            $sqlite_queries = array();
        }
        $rebuild = false;
        $additional_queries = $this->queries;
        $this->queries = array();
        // add new columns
        foreach ($this->columns as $cname => $column) {
            // not nullable fields should have a default value, when altering a table
            if ($column->default === false && $column->nullable === false) {
                trigger_error(sprintf(self::TEXT_NotNullFieldNeedsDefault, $column->name));
                return false;
            }
            // no defaults for TEXT type
            if($column->default !== false && is_int(strpos(strtoupper($column->type),'TEXT'))) {
                trigger_error(sprintf(self::TEXT_NoDefaultForTEXT, $column->name));
                return false;
            }
            $table = $this->db->quotekey($this->name);
            $col_query = $column->getColumnQuery();
            if ($sqlite) {
                // sqlite: dynamic column default only works when rebuilding the table
                if($column->default === Schema::DF_CURRENT_TIMESTAMP) {
                    $rebuild = true;
                    break;
                } else
                    $sqlite_queries[] = "ALTER TABLE $table ADD $col_query;";
            } else {
                $cmd = array(
                    'mysql|pgsql|mssql|sybase|dblib|odbc|sqlsrv' =>
                        "ALTER TABLE $table ADD $col_query;",
                    'ibm' =>
                        "ALTER TABLE $table ADD COLUMN $col_query;",
                );
                $this->queries[] = $this->findQuery($cmd);
            }
        }
        if ($sqlite)
            if ($rebuild || !empty($this->rebuild_cmd)) $this->_sqlite_rebuild($exec);
            else $this->queries += $sqlite_queries;
        $this->queries = array_merge($this->queries,$additional_queries);
        // add new indexes
        foreach ($this->columns as $cname => $column)
            if ($column->index)
                $this->addIndex($cname, $column->unique);
        if (empty($this->queries))
            return false;
        if (is_array($this->queries) && count($this->queries) == 1)
            $this->queries = $this->queries[0];
        if (!$exec) return $this->queries;
        $result = ($this->suppress)
            ? @$this->db->exec($this->queries) : $this->db->exec($this->queries);
        $this->queries = $this->columns = $this->rebuild_cmd = array();
        return $result;
    }

    /**
     * rebuild a sqlite table with additional schema changes
     */
    protected function _sqlite_rebuild($exec=true)
    {
        $new_columns = $this->columns;
        $existing_columns = $this->getCols(true);
        // find after sorts
        $after = array();
        foreach ($new_columns as $cname => $column)
            if(!empty($column->after))
                $after[$column->after][] = $cname;
        // find rename commands
        $rename = (!empty($this->rebuild_cmd) && array_key_exists('rename',$this->rebuild_cmd))
                  ? $this->rebuild_cmd['rename'] : array();
        // get primary-key fields
        foreach ($existing_columns as $key => $col)
            if ($col['pkey'])
                $pkeys[array_key_exists($key,$rename) ? $rename[$key] : $key] = $col;
        foreach ($new_columns as $key => $col)
            if ($col->pkey)
                $pkeys[$key] = $col;
        // indexes
        $indexes = $this->listIndex();
        // drop fields
        if (!empty($this->rebuild_cmd) && array_key_exists('drop', $this->rebuild_cmd))
            foreach ($this->rebuild_cmd['drop'] as $name)
                if (array_key_exists($name, $existing_columns)) {
                    if (array_key_exists($name, $pkeys)) {
                        unset($pkeys[$name]);
                        // drop composite key
                        if(count($pkeys) == 1) {
                            $incrementTrigger = $this->db->quotekey($this->name.'_insert');
                            $this->queries[] = 'DROP TRIGGER IF EXISTS '.$incrementTrigger;
                        }
                    }
                    unset($existing_columns[$name]);
                    // drop index
                    foreach (array_keys($indexes) as $col) {
                        // for backward compatibility
                        if ($col == $name)
                            unset($indexes[$name]);
                        // new index names
                        if ($col == $this->name.'___'.$name)
                            unset($indexes[$this->name.'___'.$name]);
                        // check if column is part of an existing combined index
                        if (is_int(strpos($col, '__'))) {
                            if (is_int(strpos($col, '___'))) {
                                $col = explode('___', $col);
                                $ci = explode('__', $col[1]);
                                $col = implode('___',$col);
                            } else // for backward compatibility
                                $ci = explode('__', $col);
                            // drop combined index
                            if (in_array($name, $ci))
                                unset($indexes[$col]);
                        }
                    }
                }       
        // create new table
        $oname = $this->name;
        $this->queries[] = $this->rename($oname.'_temp', false);
        $newTable = $this->schema->createTable($oname);
        // add existing fields
        foreach ($existing_columns as $name => $col) {
            $colName = array_key_exists($name, $rename) ? $rename[$name] : $name;
            // update column datatype
            if (array_key_exists('update',$this->rebuild_cmd)
                && in_array($name,array_keys($this->rebuild_cmd['update'])))
                $col['type']=$this->rebuild_cmd['update'][$name];
            $newTable->addColumn($colName, $col)->passThrough();
            // add new fields with after flag
            if (array_key_exists($name,$after))
                foreach (array_reverse($after[$name]) as $acol) {
                    $newTable->addColumn($new_columns[$acol]);
                    unset($new_columns[$acol]);
                }
        }
        // add remaining new fields
        foreach ($new_columns as $ncol)
            $newTable->addColumn($ncol);
        $newTable->primary(array_keys($pkeys));
        // add existing indexes
        foreach (array_reverse($indexes) as $name=>$conf) {
            if (is_int(strpos($name, '___')))
               list($tname,$name) = explode('___', $name);
            if (is_int(strpos($name, '__')))
                $name = explode('__', $name);
            if ($exec) {
                $t = $this->schema->alterTable($oname);
                $t->dropIndex($name);
                $t->build();
            }
            $newTable->addIndex($name,$conf['unique']);
        }
        // build new table
        $newTableQueries = $newTable->build(false);
        $this->queries = array_merge($this->queries,$newTableQueries);
        // copy data
        if (!empty($existing_columns)) {
            foreach (array_keys($existing_columns) as $name) {
                $fields_from[] = $this->db->quotekey($name);
                $toName = array_key_exists($name, $rename) ? $rename[$name] : $name;
                $fields_to[] = $this->db->quotekey($toName);
            }
            $this->queries[] =
                'INSERT INTO '.$this->db->quotekey($newTable->name).' ('.implode(', ', $fields_to).') '.
                'SELECT '.implode(', ', $fields_from).' FROM '.$this->db->quotekey($this->name).';';
        }
        $this->queries[] = $this->drop(false);
        $this->name = $oname;
    }

    /**
     * create an insert trigger to work-a-round auto-incrementation in composite primary keys
     * @param $pkey
     * @return array
     */
    public function _sqlite_increment_trigger($pkey) {
        $table = $this->db->quotekey($this->name);
        $pkey = $this->db->quotekey($pkey);
        $triggerName = $this->db->quotekey($this->name.'_insert');
        $queries[] = "DROP TRIGGER IF EXISTS $triggerName;";
        $queries[] = 'CREATE TRIGGER '.$triggerName.' AFTER INSERT ON '.$table.
                     ' WHEN (NEW.'.$pkey.' IS NULL) BEGIN'.
                     ' UPDATE '.$table.' SET '.$pkey.' = ('.
                     ' select coalesce( max( '.$pkey.' ), 0 ) + 1 from '.$table.
                     ') WHERE ROWID = NEW.ROWID;'.
                     ' END;';
        return $queries;
    }

    /**
     * get columns of a table
     * @param bool $types
     * @return array
     */
    public function getCols($types = false)
    {
        $schema = $this->db->schema($this->name, null, 0);
        if (!$types)
            return array_keys($schema);
        else
            foreach ($schema as $name => &$cols) {
                $default = ($cols['default'] === '') ? null : $cols['default'];
                if (!is_null($default) && (
                    (is_int(strpos($curdef=$this->findQuery($this->schema->defaultTypes['CUR_STAMP']),
                    $default)) || is_int(strpos($default,$curdef)))
                    || $default == "('now'::text)::timestamp(0) without time zone"))
                {
                    $default = 'CUR_STAMP';
                } elseif (!is_null($default)) {
                    // remove single-qoutes
                    if (preg_match('/sqlite2?/', $this->db->driver()))
                        $default=preg_replace('/^\s*([\'"])(.*)\1\s*$/','\2',$default);
                    elseif (preg_match('/mssql|sybase|dblib|odbc|sqlsrv/', $this->db->driver()))
                        $default=preg_replace('/^\s*(\(\')(.*)(\'\))\s*$/','\2',$default);
                    // extract value from character_data in postgre
                    elseif (preg_match('/pgsql/', $this->db->driver()))
                        if (is_int(strpos($default, 'nextval')))
                            $default = null; // drop autoincrement default
                        elseif (preg_match("/^\'*(.*)\'*::(\s*\w)+/", $default, $match))
                            $default = $match[1];
                } else
                    $default=false;
                $cols['default'] = $default;
            }
        return $schema;
    }

    /**
     * removes a column from a table
     * @param string $name
     * @return bool
     */
    public function dropColumn($name)
    {
        $colTypes = $this->getCols(true);
        // check if column exists
        if (!in_array($name, array_keys($colTypes))) return true;
        if (preg_match('/sqlite2?/', $this->db->driver())) {
            // SQlite does not support drop column directly
            $this->rebuild_cmd['drop'][] = $name;
        } else {
            $quotedTable = $this->db->quotekey($this->name);
            $quotedColumn = $this->db->quotekey($name);
            $cmd = array(
                'mysql' =>
                    "ALTER TABLE $quotedTable DROP $quotedColumn;",
                'pgsql|odbc|ibm|mssql|sybase|dblib|sqlsrv' =>
                    "ALTER TABLE $quotedTable DROP COLUMN $quotedColumn;",
            );
            if (preg_match('/mssql|sybase|dblib|sqlsrv/', $this->db->driver()))
                $this->suppress=true;
            $this->queries[] = $this->findQuery($cmd);
        }
    }

    /**
     * rename a column
     * @param $name
     * @param $new_name
     * @return void
     */
    public function renameColumn($name, $new_name)
    {
        $existing_columns = $this->getCols(true);
        // check if column is already existing
        if (!in_array($name, array_keys($existing_columns)))
            trigger_error('cannot rename column. it does not exist.');
        if (in_array($new_name, array_keys($existing_columns)))
            trigger_error('cannot rename column. new column already exist.');
        
        if (preg_match('/sqlite2?/', $this->db->driver()))
            // SQlite does not support drop or rename column directly
            $this->rebuild_cmd['rename'][$name] = $new_name;
        elseif (preg_match('/odbc/', $this->db->driver())) {
            // no rename column for odbc, create temp column
            $this->addColumn($new_name, $existing_columns[$name])->passThrough();
            $this->queries[] = "UPDATE $this->name SET $new_name = $name";
            $this->dropColumn($name);
        } else {
            $existing_columns = $this->getCols(true);
            $quotedTable = $this->db->quotekey($this->name);
            $quotedColumn = $this->db->quotekey($name);
            $quotedColumnNew = $this->db->quotekey($new_name);
            $cmd = array(
                'mysql' =>
                    "ALTER TABLE $quotedTable CHANGE $quotedColumn $quotedColumnNew ".$existing_columns[$name]['type'].";",
                'pgsql|ibm' =>
                    "ALTER TABLE $quotedTable RENAME COLUMN $quotedColumn TO $quotedColumnNew;",
                'mssql|sybase|dblib|sqlsrv' =>
                    "sp_rename [$this->name.$name], '$new_name', 'Column'",
            );
            if (preg_match('/mssql|sybase|dblib|sqlsrv/', $this->db->driver()))
                $this->suppress = true;
            $this->queries[] = $this->findQuery($cmd);
        }
    }

    /**
     * modifies column datatype
     * @param      $name
     * @param      $datatype
     * @param bool $force
     * @return bool
     */
    public function updateColumn($name, $datatype, $force = false)
    {
        if(!$force)
            $datatype = $this->findQuery($this->schema->dataTypes[strtoupper($datatype)]);
        $table = $this->db->quotekey($this->name);
        $column = $this->db->quotekey($name);
        if (preg_match('/sqlite2?/', $this->db->driver())){
            $this->rebuild_cmd['update'][$name] = $datatype;
        } else {
            $cmd = array(
            'mysql' =>
                "ALTER TABLE $table MODIFY COLUMN $column $datatype;",
            'pgsql' =>
                "ALTER TABLE $table ALTER COLUMN $column TYPE $datatype;",
            'sqlsrv|mssql|sybase|dblib|ibm' =>
                "ALTER TABLE $table ALTER COLUMN $column $datatype;",
            );
            $this->queries[] = $this->findQuery($cmd);
        }
    }

    /**
     * create index on one or more columns
     * @param string|array $columns Column(s) to be indexed
     * @param bool         $unique  Unique index
     * @param int          $length  index length for text fields in mysql
     */
    public function addIndex($columns, $unique = FALSE, $length = 20)
    {
        if (!is_array($columns))
            $columns = array($columns);
        $existingCol = $this->columns;
        foreach ($existingCol as &$col)
            $col = $col->getColumnArray();
        $allCols = array_merge($this->getCols(true), $existingCol);
        parent::_addIndex($columns, $allCols, $unique, $length);
    }

    /**
     * drop a column index
     * @param string|array $name
     */
    public function dropIndex($name)
    {
        if (is_array($name))
            $name = $this->name.'___'.implode('__', $name);
        elseif(!is_int(strpos($name,'___')))
            $name = $this->name.'___'.$name;
        $name = $this->db->quotekey($name);
        $table = $this->db->quotekey($this->name);
        $cmd = array(
            'pgsql|sqlite2?|ibm' =>
                "DROP INDEX $name;",
            'mssql|sybase|dblib|odbc|sqlsrv' =>
                "DROP INDEX $table.$name;",
            'mysql'=>
                "ALTER TABLE $table DROP INDEX $name;",
        );
        $query = $this->findQuery($cmd);
        $this->queries[] = $query;
    }

    /**
     * returns table indexes as assoc array
     * @return array
     */
    public function listIndex()
    {
        $table = $this->db->quotekey($this->name);
        $cmd = array(
            'sqlite2?' =>
                "PRAGMA index_list($table);",
            'mysql' =>
                "SHOW INDEX FROM $table;",
            'mssql|sybase|dblib|sqlsrv' =>
                "select * from sys.indexes ".
                "where object_id = (select object_id from sys.objects where name = '$this->name')",
            'pgsql' =>
                "select i.relname as name, ix.indisunique as unique ".
                "from pg_class t, pg_class i, pg_index ix ".
                "where t.oid = ix.indrelid and i.oid = ix.indexrelid ".
                "and t.relkind = 'r' and t.relname = '$this->name'".
                "group by t.relname, i.relname, ix.indisunique;",
        );
        $result = $this->db->exec($this->findQuery($cmd));
        $indexes = array();
        if (preg_match('/pgsql|sqlite2?/', $this->db->driver())) {
            foreach($result as $row)
                $indexes[$row['name']] = array('unique' => $row['unique']);
        } elseif (preg_match('/mssql|sybase|dblib|sqlsrv/', $this->db->driver())) {
            foreach ($result as $row)
                $indexes[$row['name']] = array('unique' => $row['is_unique']);
        } elseif (preg_match('/mysql/', $this->db->driver())) {
            foreach($result as $row)
                $indexes[$row['Key_name']] = array('unique' => !(bool)$row['Non_unique']);
        } else
            trigger_error(sprintf(self::TEXT_ENGINE_NOT_SUPPORTED, $this->db->driver()));
        return $indexes;
    }

    /**
     * rename this table
     * @param string $new_name
     * @param bool $exec
     * @return $this|bool
     */
    public function rename($new_name, $exec = true) {
        $query = $this->schema->renameTable($this->name, $new_name, $exec);
        $this->name = $new_name;
        return ($exec) ? $this : $query;
    }

    /**
     * drop this table
     * @param bool $exec
     * @return mixed
     */
    public function drop($exec = true) {
        return $this->schema->dropTable($this,$exec);
    }

}

/**
 * defines a table column configuration
 * Class Column
 * @package DB\SQL
 */
class Column extends DB_Utils {

    public      $name, $type, $nullable, $default, $after, $index, $unique, $passThrough, $pkey;
    protected   $table, $schema;

    const
        TEXT_NoDataType = 'The specified datatype %s is not defined in %s driver',
        TEXT_CurrentStampDataType = 'Current timestamp as column default is only possible for TIMESTAMP datatype';

    /**
     * @param string $name
     * @param TableBuilder $table
     */
    public function __construct($name, TableBuilder $table) {
        $this->name = $name;
        $this->nullable = true;
        $this->default = false;
        $this->after = false;
        $this->index = false;
        $this->unique = false;
        $this->passThrough = false;
        $this->pkey = false;

        $this->table = $table;
        $this->schema = $table->schema;
        parent::__construct($this->schema->db);
    }

    /**
     * @param string $datatype
     * @param bool $force don't match datatype against DT array
     * @return $this
     */
    public function type($datatype, $force = FALSE) {
        $this->type = $datatype;
        $this->passThrough = $force;
        return $this;
    }

    public function type_tinyint() {
        $this->type = Schema::DT_INT1;
        return $this;
    }

    public function type_smallint() {
        $this->type = Schema::DT_INT2;
        return $this;
    }

    public function type_int() {
        $this->type = Schema::DT_INT4;
        return $this;
    }

    public function type_bigint() {
        $this->type = Schema::DT_INT8;
        return $this;
    }

    public function type_float() {
        $this->type = Schema::DT_FLOAT;
        return $this;
    }

    public function type_decimal() {
        $this->type = Schema::DT_DOUBLE;
        return $this;
    }

    public function type_text() {
        $this->type = Schema::DT_TEXT;
        return $this;
    }

    public function type_longtext() {
        $this->type = Schema::DT_LONGTEXT;
        return $this;
    }

    public function type_varchar($length = 255) {
        $this->type = "varchar($length)";
        $this->passThrough = true;
        return $this;
    }

    public function type_date() {
        $this->type = Schema::DT_DATE;
        return $this;
    }

    public function type_datetime() {
        $this->type = Schema::DT_DATETIME;
        return $this;
    }

    public function type_timestamp($asDefault=false) {
        $this->type = Schema::DT_TIMESTAMP;
        if ($asDefault)
            $this->default = Schema::DF_CURRENT_TIMESTAMP;
        return $this;
    }

    public function type_blob() {
        $this->type = Schema::DT_BLOB;
        return $this;
    }

    public function type_bool() {
        $this->type = Schema::DT_BOOLEAN;
        return $this;
    }

    public function passThrough($state = TRUE) {
        $this->passThrough = $state;
        return $this;
    }

    public function nullable($nullable) {
        $this->nullable = $nullable;
        return $this;
    }

    public function defaults($default) {
        $this->default = $default;
        return $this;
    }

    public function after($name) {
        $this->after = $name;
        return $this;
    }

    public function index($unique = FALSE) {
        $this->index = true;
        $this->unique = $unique;
        return $this;
    }

    /**
     * returns an array of this column configuration
     * @return array
     */
    public function getColumnArray()
    {
        $fields = array('name','type','passThrough','default','nullable',
                        'index','unique','after','pkey');
        $fields = array_flip($fields);
        foreach($fields as $key => &$val)
            $val = $this->{$key};
        unset($val);
        return $fields;
    }

    /**
     * generate SQL column definition query
     * @return bool|string
     */
    public function getColumnQuery()
    {
        if (!$this->type)
            trigger_error(sprintf('Cannot build a column query for `%s`: no column type set',$this->name));
        // prepare column types
        if ($this->passThrough)
            $type_val = $this->type;
        else {
            $type_val = $this->findQuery($this->schema->dataTypes[strtoupper($this->type)]);
            if (!$type_val) {
                trigger_error(sprintf(self::TEXT_NoDataType, strtoupper($this->type),
                    $this->db->driver()));
                return FALSE;
            }
        }
        // build query
        $query = $this->db->quotekey($this->name).' '.$type_val.' '.
            ($this->nullable ? 'NULL' : 'NOT NULL');
        // default value
        if ($this->default !== false) {
            $def_cmds = array(
                'sqlite2?|mysql|pgsql|mssql|sybase|dblib|odbc|sqlsrv' => 'DEFAULT',
                'ibm' => 'WITH DEFAULT',
            );
            $def_cmd = $this->findQuery($def_cmds).' ';
            // timestamp default
            if ($this->default === Schema::DF_CURRENT_TIMESTAMP) {
                // check for right datatpye
                $stamp_type = $this->findQuery($this->schema->dataTypes['TIMESTAMP']);
                if ($this->type != 'TIMESTAMP' && // TODO: check that condition
                    ($this->passThrough && strtoupper($this->type) != strtoupper($stamp_type))
                )
                    trigger_error(self::TEXT_CurrentStampDataType);
                $def_cmd .= $this->findQuery($this->schema->defaultTypes[strtoupper($this->default)]);
            } else {
                // static defaults
                $pdo_type = preg_match('/int|bool/i', $type_val, $parts) ?
                    constant('\PDO::PARAM_'.strtoupper($parts[0])) : \PDO::PARAM_STR;
                $def_cmd .= ($this->default === NULL ? 'NULL' :
                    $this->db->quote(htmlspecialchars($this->default, ENT_QUOTES,
                        $this->f3->get('ENCODING')), $pdo_type));
            }
            $query .= ' '.$def_cmd;
        }
        if (!empty($this->after)) {
            // `after` feature only works for mysql
            if (preg_match('/mysql/', $this->db->driver())) {
                $after_cmd = 'AFTER '.$this->db->quotekey($this->after);
                $query .= ' '.$after_cmd;
            }
        }
        return $query;
    }
}


class DB_Utils {

    /** @var \DB\SQL */
    protected $db;

    /** @var \BASE */
    protected $f3;

    const
        TEXT_ENGINE_NOT_SUPPORTED = 'DB Engine `%s` is not supported for this action.';

    /**
     * parse command array and return backend specific query
     * @param $cmd
     * @param $cmd array
     * @return bool|string
     */
    protected function findQuery($cmd)
    {
        $match = FALSE;
        foreach ($cmd as $backend => $val)
            if (preg_match('/'.$backend.'/', $this->db->driver())) {
                $match = TRUE;
                break;
            }
        if (!$match) {
            trigger_error(sprintf(self::TEXT_ENGINE_NOT_SUPPORTED, $this->db->driver()));
            return FALSE;
        }
        return $val;
    }

    public function __construct(SQL $db) {
        $this->db = $db;
        $this->f3 = \Base::instance();
    }
}