Invision Power Services, Inc. * @copyright (c) 2001 - SVN_YYYY Invision Power Services, Inc. * @license http://www.invisionpower.com/legal/standards/ * @package IPS Tools * @since 4 Sept 2013 * @version SVN_VERSION_NUMBER */ namespace IPSUtf8; /** * Conversion class */ class Convert { const VERSION_ID = '1.1.17'; /** * @brief Data Store */ protected $_data = array(); /** * @brief Instance */ protected static $instance = NULL; /** * @brief Convertable table fields */ protected static $convertCols = array( 'text', 'mediumtext', 'longtext', 'varchar', 'char', 'tinytext' ); /** * @brief Numeric table fields */ protected static $numericCols = array( 'integer' => 11, 'int' => 11, 'smallint' => 6, 'tinyint' => 4, 'mediumint' => 8, 'bigint' => 20, 'decimal' => null, 'numeric' => null, 'float' => null, 'double' => null ); /** * @brief Tables we need to convert */ protected static $nonUtf8Tables = NULL; /** * @brief Tables we need to convert */ protected static $nonUtf8Collations = NULL; /** * @brief Number of rows we need to convert */ protected static $rowsToConvert = NULL; /** * @brief Native Database object */ protected static $db = NULL; /** * @brief UTF-8 Database object */ protected static $utf = NULL; /** * @brief UTF-8 table engine */ protected static $utfTableEngine = NULL; /** * @brief Create don't populate */ protected static $createOnly = array( 'content_cache_posts', 'content_cache_sigs', 'sessions', 'topic_views', 'search_keywords' ); /** * @brief Problem Tables that can cause an error due to serialization while using "fast" mode */ protected static $problemTables = array( 'cache_store' ); /** * @brief MySQL supported character sets */ protected static $mysqlCharSets = NULL; /** * Get instance * * @return Output */ public static function i() { if ( self::$instance === NULL ) { static::$db = \IPSUtf8\Db::i(); if ( defined( 'SOURCE_DB_CHARSET' ) AND SOURCE_DB_CHARSET !== NULL ) { static::$db->set_charset( SOURCE_DB_CHARSET ); } static::$utf = \IPSUtf8\Db::i('utf8'); self::$instance = new self(); self::$instance->init(); } return self::$instance; } /** * Get value from data store * * @param mixed $key Key * @return mixed Value from the datastore */ public function __get( $key ) { if( isset( $this->_data[ $key ] ) ) { return $this->_data[ $key ]; } return NULL; } /** * Set value in data store * * @param mixed $key Key * @param mixed $value Value * @return void */ public function __set( $key, $value ) { if( array_key_exists( $key, $this->_data ) ) { $this->_data[ $key ] = $value; } } /** * Process a batch * * @param int|null Number of rows to limit per batch * @return boolean True (I converted some) False (I didn't) */ public function process( $limit=null, $fromError=false ) { /* MB4? */ $sessionData = \IPSUtf8\Session::i()->json; if ( isset( $sessionData['use_utf8mb4'] ) AND ! empty( $sessionData['use_utf8mb4'] ) ) { static::$utf->set_charset( 'utf8mb4' ); } if ( \IPSUtf8\Session::i()->current_table === NULL AND \IPSUtf8\Session::i()->is_ipb ) { /* Lock tasks until the end of time so they do not attempt to add / remove things during conversion */ if( static::$db->checkForTable('task_manager') ) { static::$db->update( "task_manager", array( 'task_locked' => 2147483647 ) ); } else { static::$db->update( "core_tasks", array( 'next_run' => 2147483647 ) ); } } $convertCount = 0; $table = $this->getTable(); $convertCols = static::getConvertableColumns( $table['name'] ); $numericCols = static::getNumericColumns( $table['name'] ); $tableCharSet = \IPSUtf8\Convert::i()->database_charset; $currentCharSet = \IPSUtf8\Session::i()->current_charset; if ( ! empty( $table['charset'] ) ) { $tableCharSet = $table['charset']; /* Latin1 is actually cp1252, not ISO-8859-1 @link http://dev.mysql.com/doc/refman/5.0/en/charset-we-sets.html */ /* But only if our document character set is ISO-8859-1 or UTF-8. Otherwise, we're using the slow method which needs to real charset despite what the table is set too */ if ( $tableCharSet === 'latin1' AND in_array( mb_strtolower( $currentCharSet ), array( 'iso-8859-1', 'utf-8' ) ) ) { $currentCharSet = 'windows-1252'; } if ( defined( 'FORCE_CONVERT_CHARSET' ) AND FORCE_CONVERT_CHARSET !== NULL ) { $currentCharSet = FORCE_CONVERT_CHARSET; } } if ( !defined( 'SOURCE_DB_CHARSET' ) OR SOURCE_DB_CHARSET === NULL ) { static::$db->set_charset( $tableCharSet ); } /* Create table only */ if ( IPB_LOCK and in_array( $table['name'], static::$createOnly ) ) { static::log( 'No content conversion of ' . $table['name'] . ' required' ); $this->getNextTable( $table['name'] ); \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->json = $sessionData; \IPSUtf8\Session::i()->save(); return true; } while( $convertCount < $limit ) { $sessionData = \IPSUtf8\Session::i()->json; if ( $table === null ) { if ( count( array_keys( \IPSUtf8\Session::i()->tables ) ) == count( array_keys( \IPSUtf8\Session::i()->completed_json ) ) ) { \IPSUtf8\Session::i()->status = 'completed'; \IPSUtf8\Session::i()->save(); return false; } return false; } /* Got any fields to convert? */ if ( count( $convertCols ) === 0 ) { /* No data to convert, lets repopulate the easy way! */ $this->preInsert( $table ); static::$utf->delete( $table['name'] ); static::$utf->insert( $table['name'], static::$db->select( '*', $table['name'] ) ); $this->postInsert( $table ); /* Update counts */ $sessionData['convertedCount'] += $table['count']; $convertCount += $table['count']; static::log( 'No columns to convert in ' . $table['name'] . ' INSERT INTO FROM SELECT used' ); $table = $this->getNextTable( $table['name'] ); $convertCols = static::getConvertableColumns( $table['name'] ); $numericCols = static::getNumericColumns( $table['name'] ); if ( $table === null or $convertCount >= $limit ) { \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->json = $sessionData; \IPSUtf8\Session::i()->save(); return true; } } /* If the previous table had no columns to convert, then the table charset may be lost - reset it if it does not match */ if ( $table !== NULL AND $tableCharSet !== $table['charset'] ) { $tableCharSet = $table['charset']; } /* Are we reeeeeeeeally sure we know the charset? Let's check one more time */ if ( ! $tableCharSet ) { $createTable = static::$db->query( "SHOW CREATE TABLE `" . static::$db->prefix . $table['name'] . "`" )->fetch_assoc(); if ( preg_match( '#\scharset=([a-z0-9]+?)(\s|$)#i', $createTable['Create Table'], $matches ) ) { $tableCharSet = mb_strtolower( $matches[1] ); } } /* Latin1 or ISO-8559-1? */ if ( ! in_array( $table['name'], static::$problemTables ) AND ( in_array( $tableCharSet, $this->getMysqlCharSets() ) ) and ( \IPSUtf8\Session::i()->current_charset == 'utf-8' or \IPSUtf8\Session::i()->current_charset == 'iso-8859-1' ) AND ( !isset( $sessionData['force_conversion'] ) OR $sessionData['force_conversion'] !== 1 ) ) { /* Convert the fast way! */ $count = static::$utf->select( 'COUNT(*)', $table['name'] )->first(); if ( $count > 0 ) { /* TRUNCATE can hang so only do it if the table isn't empty (which it will be 99% of the time) */ /* @see http://dba.stackexchange.com/questions/28055/truncate-table-statement-sometimes-hangs */ static::log("TRUNCATE TABLE `" . static::$utf->prefix . $table['name'] . "`"); static::$utf->query("TRUNCATE TABLE `" . static::$utf->prefix . $table['name'] . "`" ); } /* Update counts */ $sessionData['convertedCount'] += $table['count']; $convertCount += $table['count']; $select = array(); $convertCols = static::getConvertableColumns( $table['name'] ); $schematic = static::getTableSchematic( $table['name'] ); foreach( array_keys( $table['definition']['columns'] ) as $col ) { if ( in_array( $col, $convertCols ) ) { /* Even if the current charset is set to UTF-8, if the Table Charset is latin1, we need to use this method */ /* ... but only if the actual column isn't using utf8 */ $columnCollation = $schematic['definition']['columns'][$col]['collation']; if ( \IPSUtf8\Session::i()->current_charset != 'utf-8' OR ( $tableCharSet != 'utf8' AND ! in_array( $columnCollation, array( 'utf8_unicode_ci', 'utf8mb4_unicode_ci' ) ) ) OR ( $tableCharSet == 'utf8' AND mb_substr( $columnCollation, 0, 6 ) == 'latin1' ) ) { if ( \IPSUtf8\Session::i()->current_charset == 'iso-8859-1' AND $tableCharSet == 'latin1' ) { /* Casting as binary truncates non-latin characters, so if we are using real Latin1, just convert */ $select[] = 'CONVERT( `' . $col . '` USING utf8 )'; } else { /* Otherwise cast to binary */ $select[] = 'CONVERT( CAST(`' . $col . '` AS BINARY) USING utf8 )'; } } else { /** * @note This looks a little weird but bare with me. Semi-old databases (post-2.x but pre-4.x) can have UTF8 Tables and Columns, but contain Latin1 data. * We need to try and sniff this out and apply a different type of conversion, because in this instance you cannot just extract * as BINARY and then insert into the source. Instead, you need to convert to latin1, then to binary, then to utf8. But you can't * do that on actual UTF8 data because it will then truncate multibyte characters so we need to compare the conversion against what * is stored. */ $collation = 'utf8_unicode_ci'; if ( $tableCharSet == 'utf8mb4' OR $columnCollation == 'utf8mb4_unicode_ci' ) { $collation = 'utf8mb4_unicode_ci'; } $source_charset = 'latin1'; if ( defined( 'SOURCE_DB_CHARSET' ) AND SOURCE_DB_CHARSET !== NULL ) { $source_charset = SOURCE_DB_CHARSET; } $select[] = 'CASE WHEN STRCMP(CONVERT(CONVERT(CONVERT(`' . $col . '` USING ' . $source_charset . ') USING BINARY) USING utf8), `' . $col . '` COLLATE ' . $collation . ') = 0 THEN CONVERT(`' . $col . '` USING BINARY) ELSE CONVERT(CONVERT(CONVERT(`' . $col . '` USING ' . $source_charset . ') USING BINARY) USING utf8) END'; } } else { $select[] = '`' . $col . '`'; } } $this->preInsert( $table ); $sql = "INSERT IGNORE INTO `" . static::$utf->prefix . $table['name'] . "` SELECT " . implode( ',', $select ) . " FROM `" . static::$db->prefix . $table['name'] . "`"; static::log( $sql ); static::$utf->query( $sql ); $this->postInsert( $table ); $table = $this->getNextTable( $table['name'] ); \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->json = $sessionData; \IPSUtf8\Session::i()->save(); /* Always reset so we can test for no convertable columns above */ return true; } /* If we have a work table, do we have a PKEY and a current row? */ if ( \IPSUtf8\Session::i()->current_pkey ) { $start = static::$utf->select('MAX(`' . \IPSUtf8\Session::i()->current_pkey . '`) as max', \IPSUtf8\Session::i()->current_table )->setKeyField('max')->first(); $rows = static::$db->select( '*', $table['name'], array( \IPSUtf8\Session::i()->current_pkey . ' > ?', intval( $start ) ), \IPSUtf8\Session::i()->current_pkey . ' ASC', ( $limit ? array( 0, ( $limit - $convertCount ) ) : null ) ); } else { $start = static::$utf->select('COUNT(*) as count', \IPSUtf8\Session::i()->current_table )->setKeyField('count')->first(); /* Fetch via offset */ $rows = static::$db->select( '*', $table['name'], null, null, ( $limit ? array( $start, ( $limit - $convertCount ) ) : array( $start, 18446744073709551615 ) ) # No really, this is what MySQL recommends for offset, no limit (http://dev.mysql.com/doc/refman/5.1/en/select.html#id4651990) ); } if ( !defined( 'FORCE_CONVERT_METHOD' ) OR FORCE_CONVERT_METHOD === NULL ) { \IPSUtf8\Text\Charset::$method = 'internal'; /* Optimise for latin character sets */ if ( function_exists( 'mb_convert_encoding' ) ) { if ( in_array( strtolower( $currentCharSet ), array_map( 'strtolower', mb_list_encodings() ) ) ) { \IPSUtf8\Text\Charset::$method = 'mb'; } } } $rowCount = 0; $gotRows = count( $rows ); $batch = array(); $batchBytes = 0; $max = ( $limit ) ?: 250; /* If we have a text column then the data set gets large */ if ( $limit > 50 AND static::hasTextColumn( $table['name'] ) ) { $max = 50; } /* Throttle inserts for other reasons? */ $throttle = static::throttleInserts( $table['name'] ); if ( $throttle !== false ) { $max = ( $throttle < $limit ) ? $throttle : $limit; } /* Anything fetched? */ if ( $gotRows === 0 ) { try { static::$utf->query("ALTER TABLE `" . static::$utf->prefix . "{$table['name']}` ENABLE KEYS;"); static::log( "{$table['name']} keys enabled." ); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} enable keys exception caught." ); } $table = $this->getNextTable( $table['name'] ); $convertCols = static::getConvertableColumns( $table['name'] ); $numericCols = static::getNumericColumns( $table['name'] ); continue; } foreach( $rows as $row ) { foreach( $convertCols as $col ) { if ( isset( $row[ $col ] ) AND ! empty( $row[ $col ] ) ) { if ( static::isSerialized( $row[ $col ] ) AND \IPSUtf8\Text\Charset::i()->needsConverting( $row[ $col ], $currentCharSet, 'UTF-8' ) ) { /* Store a copy in case it doesn't work */ $original = $row[ $col ]; $work = unserialize( $row[ $col ] ); array_walk_recursive( $work, function( &$input, $key ) use ($currentCharSet) { $input = \IPSUtf8\Text\Charset::i()->convert( $input, $currentCharSet, 'UTF-8' ); } ); $row[ $col ] = serialize( $work ); /* Did it work? */ if ( ! @unserialize( $row[ $col ] ) ) { /* Something went wrong... maybe we can figure it out */ $work = unserialize( $original ); array_walk_recursive( $work, function ( &$input, $key ) use ( $currentCharSet, $tableCharSet ) { if ( function_exists( 'mb_detect_encoding' ) ) { $encoding = mb_detect_encoding( $input ); $input = \IPSUtf8\Text\Charset::i()->convert( $input, $encoding, 'UTF-8' ); } else { /* @todo expand */ if ( $tableCharSet == 'latin1' ) { $encoding = 'ISO-8859-1'; } $input = \IPSUtf8\Text\Charset::i()->convert( $input, $encoding, 'UTF-8' ); } } ); $row[ $col ] = serialize( $work ); } } else { $row[ $col ] = \IPSUtf8\Text\Charset::i()->convert( $row[ $col ], $currentCharSet, 'UTF-8' ); } } } /* Numeric columns */ foreach( $numericCols as $col ) { if ( isset( $row[ $col ] ) AND ! empty( $row[ $col ] ) ) { if ( $row[ $col ] === null OR ! is_numeric( $row[ $col ] ) ) { $row[ $col ] = 0; } } } /* Roughly add the size of this insert in bytes */ $tmp = $row; // Use a copy so the reference in array_walk doesn't overwrite the row to be inserted /* Make sure it's an associative array as topic_views only has 1 column so DB driver returns an indexed array only */ array_walk_recursive( $tmp, function( &$input, $key ) { $input = htmlentities( (string) $input, ENT_QUOTES | ENT_IGNORE, 'utf-8', false ); } ); $batchBytes += mb_strlen( @json_encode( $tmp ), '8bit'); unset( $tmp ); $batch[] = $row; $nextBatch = null; /* Give us an error margin to account for MySQL syntax */ if ( $batchBytes > ( $this->_data['max_allowed_packet'] - ( ( $this->_data['max_allowed_packet'] / 100 ) * ( count( $batch ) * 0.3 ) ) ) ) { /* Remove last row */ $nextBatch = array_pop( $batch ); static::log( "Max packet hit with " . $batchBytes . 'b with ' . count( $batch ) . ' rows' ); } /* If we have moved the only row from $batch to $nextbatch, then process that now */ if ( count( $nextBatch ) and ! count( $batch ) ) { $batch = $nextBatch; $nextBatch = array(); } /* Got a batch to write? */ if ( $nextBatch !== null OR ( count( $batch ) === $max ) OR ( $gotRows === count( $batch ) ) ) { try { /* Optimise */ $this->preInsert( $table ); $insertId = static::$utf->insert( $table['name'], $batch, false, true, $table ); if ( ! empty( static::$utf->error ) ) { throw new \RuntimeException( static::$utf->error ); } $this->postInsert( $table ); /* Update counts */ $sessionData['convertedCount'] += count( $batch ); $convertCount += count( $batch ); if ( \IPSUtf8\Session::i()->current_pkey ) { \IPSUtf8\Session::i()->current_row = $insertId; } else { \IPSUtf8\Session::i()->current_row += count( $batch ); } \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->json = $sessionData; \IPSUtf8\Session::i()->save(); static::log( count( $batch ) . " rows batch inserted using conversion method: " . \IPSUtf8\Text\Charset::$method . ', last insert ID ' . $insertId ); $batch = ( $nextBatch !== null ) ? array( $nextBatch ) : array(); $batchBytes = 0; } catch( \IPSUtf8\Db\Exception $e ) { $msg = $e->getMessage(); \IPSUtf8\Session::i()->save(); /* Trying to insert duplicate data, just let it go to the next in case there is a small overlap */ if ( mb_stristr( $msg, 'duplicate entry' ) ) { static::log( "Duplicate key failure on insert\n{$msg}\n" . var_export( $batch, true ) ); return $this->heal( $limit, $convertCount ); } } } } /* Anything fetched? */ if ( $gotRows === 0 ) { try { static::$utf->query("ALTER TABLE `" . static::$utf->prefix . "{$table['name']}` ENABLE KEYS;"); static::log( "{$table['name']} keys enabled." ); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} enable keys exception caught." ); } $table = $this->getNextTable( $table['name'] ); $convertCols = static::getConvertableColumns( $table['name'] ); } } return true; } /** * Pre insert * * @param array $table Table definition data * @return void */ public function preInsert( $table ) { $row = static::$utf->query( "SHOW TABLE STATUS LIKE '" . static::$utf->prefix . $table['name'] . "'" )->fetch_assoc(); static::$utfTableEngine = $row['Engine']; if ( empty( static::$utfTableEngine ) ) { static::$utfTableEngine = static::$utf->defaultEngine(); } if ( \strtolower( static::$utfTableEngine ) === 'myisam' ) { static::log( "Pre inserts for MyISAM table " . $table['name'] ); try { //static::$utf->query("ALTER TABLE " . static::$utf->prefix . "{$table['name']} DISABLE KEYS;"); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} disable keys exception caught." ); } } else if ( \strtolower( static::$utfTableEngine ) === 'innodb' ) { static::log( "Pre inserts for InnoDB table " . $table['name'] ); try { static::$utf->query("SET autocommit=0"); static::$utf->query("SET unique_checks=0"); static::$utf->query("SET foreign_key_checks=0"); } catch ( Exception $e ) { } } } /** * Post insert * * @param array $table Table definition data * @return void */ public function postInsert( $table ) { if ( \strtolower( static::$utfTableEngine ) === 'myisam' ) { static::log( "Post inserts for MyISAM table " . $table['name'] ); try { //static::$utf->query("ALTER TABLE " . static::$utf->prefix . "{$table['name']} ENABLE KEYS;"); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} enable keys exception caught." ); } } else if ( \strtolower( static::$utfTableEngine ) === 'innodb' ) { static::log( "Post inserts for InnoDB table " . $table['name'] ); try { static::$utf->query("SET autocommit=1"); static::$utf->query("SET unique_checks=1"); static::$utf->query("SET foreign_key_checks=1"); } catch ( Exception $e ) { } } } /** * Finish the conversion * * @return null */ public function finish() { $charset = 'utf8'; $collation = 'utf8_unicode_ci'; /* MB4? */ $sessionData = \IPSUtf8\Session::i()->json; if ( isset( $sessionData['use_utf8mb4'] ) AND ! empty( $sessionData['use_utf8mb4'] ) ) { static::$utf->set_charset( 'utf8mb4' ); $charset = 'utf8mb4'; $collation = 'utf8mb4_unicode_ci'; } /* IPB 3? */ if ( isset( \IPSUtf8\Session::i()->tables['core_sys_conf_settings'] ) AND isset( \IPSUtf8\Session::i()->tables['cache_store'] ) ) { if ( static::$utf->checkForTable('core_sys_conf_settings' ) ) { static::$utf->update( 'core_sys_conf_settings', array( 'conf_value' => '' ), array( 'conf_key=?', 'gb_char_set' ) ); $rows = static::$utf->select( '*', 'core_sys_conf_settings', array( 'conf_add_cache=?', 1 ) ); $settings = array(); foreach( $rows as $row ) #row your boat { $value = $row['conf_value'] != "" ? $row['conf_value'] : $row['conf_default']; if ( $value == '{blank}' ) { $value = ''; } $settings[ $row['conf_key'] ] = $value; } static::$utf->update( 'cache_store', array( 'cs_value' => serialize( $settings ) ), array( 'cs_key=?', 'settings' ) ); } /* Update Language Locales */ if ( static::$utf->checkForTable('core_sys_lang') ) { /* Store current */ $currentLocale = setlocale( LC_ALL, '0' ); /* Loop through languages */ $languages = static::$utf->select( '*', 'core_sys_lang' ); foreach( $languages AS $language ) { $locale = explode( '.', $language['lang_short'] ); # We want to update even if a charset is already set. foreach( array( "{$locale[0]}.UTF8", "{$locale[0]}.UTF-8", "{$locale[0]}.utf8" ) AS $test ) { $verify = setlocale( LC_ALL, $test ); if ( $verify !== FALSE ) { static::$utf->update( 'core_sys_lang', array( 'lang_short' => $test ), array( 'lang_id=?', $language['lang_id'] ) ); break; } } } foreach( explode( ";", $currentLocale ) as $locale ) { $parts = explode( "=", $locale ); if( in_array( $parts[0], array( 'LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY', 'LC_NUMERIC', 'LC_TIME' ) ) ) { setlocale( constant( $parts[0] ), $parts[1] ); } } /* Clear Cache so it regenerates */ static::$utf->update( 'cache_store', array( 'cs_value' => '' ), array( 'cs_key=?', 'lang_data' ) ); } /* Unlock Tasks */ if( static::$utf->checkForTable('task_manager') ) { static::$utf->update( "task_manager", array( 'task_locked' => 0 ) ); } else if( static::$utf->checkForTable('core_tasks') ) { static::$utf->update( "core_tasks", array( 'next_run' => time() ) ); } } /* Change database collation */ require( ROOT_PATH . '/conf_global.php' ); static::$utf->query( "ALTER DATABASE `" . $INFO['sql_database'] . "` DEFAULT CHARACTER SET " . $charset . " COLLATE " . $collation ); } /** * Restore the original tables. * * @return null */ public function restoreOriginalTables() { /* Grab all tables to convert */ $stmt = static::$db->prepare( "SHOW TABLES" ); $stmt->execute(); $stmt->bind_result( $name ); $tables = array(); while ( $stmt->fetch() === true ) { $tables[] = $name; } foreach( $tables as $name ) { if ( mb_substr( $name, 0, 5 ) === 'orig_' ) { $plainName = mb_substr( $name, 5 ); static::$db->query( "DROP TABLE IF EXISTS `" . $plainName . "`" ); $rename = "RENAME TABLE `" . $name . '` TO `' . $plainName . '`'; static::$db->query( $rename ); static::log( $rename ); } } } /** * Delete the original tables. * * @return null */ public function deleteOriginalTables() { /* Grab all tables to convert */ $stmt = static::$utf->prepare( "SHOW TABLES" ); $stmt->execute(); $stmt->bind_result( $name ); $tables = array(); while ( $stmt->fetch() === true ) { $tables[] = $name; } foreach( $tables as $name ) { if ( mb_substr( $name, 0, 5 ) === 'orig_' ) { static::$db->query( "DROP TABLE IF EXISTS `" . $name . "`" ); static::log( "DROP TABLE IF EXISTS `" . $name . "`" ); } } } /** * Rename the tables * * @return null */ public function renameTables() { /* Grab all tables to convert */ $stmt = static::$db->prepare( "SHOW TABLES" ); $stmt->execute(); $stmt->bind_result( $name ); $tables = array(); $origPrefix = static::$db->prefix; if ( mb_substr( $origPrefix, 0, 6 ) === 'x_utf_' ) { $origPrefix = mb_substr( $origPrefix, 6 ); } while ( $stmt->fetch() === true ) { if( mb_substr( $name, 0, 6 ) === 'x_utf_' ) { $tables[] = $name; } } foreach( $tables as $name ) { $tableNameNoPrefix = $name; $tableName = mb_substr( $name, mb_strlen( static::$utf->prefix ) ); if ( $tableName !== 'convert_session' AND $tableName !== 'convert_session_tables' ) { if( static::$utf->checkForTable( $tableName ) ) { static::$db->query( "DROP TABLE IF EXISTS `" . $origPrefix . $tableName . '`' ); static::log( "DROP TABLE IF EXISTS `" . $origPrefix . $tableName . '`' ); } static::$utf->query( "RENAME TABLE `" . $tableNameNoPrefix . '` TO `' . $origPrefix . $tableName . '`' ); static::log( "RENAME TABLE `" . $tableNameNoPrefix . '` TO `' . $origPrefix . $tableName . '`' ); } } /* Update table data */ \IPSUtf8\Session::i()->updateTableData(); } /** * Go through the DB and fix the collation of UTF8 tables * * @return null */ public function fixCollation() { $charset = 'utf8'; $collation = 'utf8_unicode_ci'; /* MB4? */ $sessionData = \IPSUtf8\Session::i()->json; if ( isset( $sessionData['use_utf8mb4'] ) AND ! empty( $sessionData['use_utf8mb4'] ) ) { static::$utf->set_charset( 'utf8mb4' ); $charset = 'utf8mb4'; $collation = 'utf8mb4_unicode_ci'; } /* Change database collation */ require( ROOT_PATH . '/conf_global.php' ); static::$utf->query( "ALTER DATABASE `" . $INFO['sql_database'] . "` DEFAULT CHARACTER SET " . $charset . " COLLATE " . $collation ); foreach( \IPSUtf8\Session::i()->tables as $name => $data ) { if ( in_array( $name, $this->getNonUtf8CollationTables() ) ) { /* Skip if it's an _old table */ if( mb_substr( $name, -4 ) == '_old' ) { continue; } $cols = static::getConvertableColumns( $data['name'] ); $tableData = \IPSUtf8\Session::i()->tables[ $data['name'] ]; if ( count( $cols ) ) { $dropIndexes = array(); $addIndexes = array(); $modify = array(); $hasUnique = FALSE; /* Changing collation can cause issues for duplicate enries in unique keys */ if ( isset( $tableData['definition']['indexes'] ) ) { foreach( $tableData['definition']['indexes'] as $key => $index ) { if ( $index['type'] === 'unique' ) { /* Make sure none of the columns in this index are auto_increment */ foreach( $index['columns'] AS $k => $idxcol ) { if ( $tableData['definition']['columns'][ $idxcol ]['auto_increment'] === TRUE ) { continue 2; } } if ( static::$db->checkForIndex( $name, $key ) ) { $hasUnique = TRUE; break; } } } } } /* We don't need to drop any indexes - just create a copy of the table and insert ignore */ if ( $hasUnique === TRUE ) { static::$utf->query( "RENAME TABLE `" . static::$db->prefix . $name . "` TO `" . static::$db->prefix . $name . "_temp`" ); static::$utf->query( "CREATE TABLE `" . static::$db->prefix . $name . "` LIKE `" . static::$db->prefix . $name . "_temp`" ); static::$utf->query( "INSERT IGNORE INTO `" . static::$db->prefix . $name . "` SELECT * FROM `" . static::$db->prefix . $name . "_temp`" ); static::$utf->query( "DROP TABLE `" . static::$db->prefix . $name . "_temp`" ); } static::$utf->query( "ALTER TABLE `" . static::$db->prefix . $name . "` CONVERT TO CHARACTER SET " . $charset . " COLLATE " . $collation ); if ( count( $cols ) ) { foreach( $cols as $col ) { if ( isset( $tableData['definition']['columns'][ $col ] ) ) { $colData = $tableData['definition']['columns'][ $col ]; $modify[] = " MODIFY `" . $col . "` " . $colData['type'] . ( ( is_numeric( $colData['length'] ) AND $colData['length'] > 0 ) ? "(" . $colData['length'] . ")" : '' ) . " CHARACTER SET " . $charset . " COLLATE " . $collation; } } } if ( count( $cols ) ) { if ( count( $modify ) ) { $query = "ALTER TABLE `" . static::$db->prefix . $name . "` " . implode( ',', $modify ); static::log( $query ); static::$utf->query( $query ); } if ( /*count( $modify ) and*/ count( $addIndexes ) ) { $query = "ALTER TABLE `" . static::$db->prefix . $name . "` " . implode( ',', $addIndexes ); static::log( $query ); static::$utf->query( $query ); } } } } /* Update table data */ \IPSUtf8\Session::i()->updateTableData(); } /** * Something went wrong, so try and heal to restart progress * * @param int $limit Process() method limit * @param int $cycleCount Items processed if this was in the middle of process() * @return void */ public function heal( $limit=250, $cycleCount=0 ) { if ( \IPSUtf8\Session::i()->current_table ) { $table = \IPSUtf8\Session::i()->tables[ \IPSUtf8\Session::i()->current_table ]; /* Table doesn't exist, so rewind back to the start of this table */ if ( ! static::$utf->checkForTable( $table['name'] ) ) { if ( static::$utf->createTable( $table['definition'] ) === false ) { throw new \RuntimeException( static::$utf->error . "\n" . var_export( $table['definition'], true ) ); } $completed = \IPSUtf8\Session::i()->completed_json; if ( isset( $completed[ \IPSUtf8\Session::i()->current_table ] ) ) { unset( $completed[ \IPSUtf8\Session::i()->current_table ] ); } /* Got a primary key so we can use a WHERE N > X query rather than a limit for efficiency? */ if ( isset( $table['definition']['indexes']['PRIMARY'] ) ) { $pkey = $table['definition']['indexes']['PRIMARY']['columns'][0]; /* Is it numeric? */ if ( mb_stristr( $table['definition']['columns'][ $pkey ]['type'], 'int' ) ) { \IPSUtf8\Session::i()->current_pkey = $pkey; } } \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->completed_json = $completed; \IPSUtf8\Session::i()->current_row = 0; \IPSUtf8\Session::i()->save(); /* Run again */ return $this->process( ( $limit - $cycleCount ) ); } if ( \IPSUtf8\Session::i()->current_pkey ) { /* Get the latest row */ $max = static::$utf->select('MAX(`' . \IPSUtf8\Session::i()->current_pkey . '`) as max', \IPSUtf8\Session::i()->current_table )->setKeyField('max')->first(); } else { /* Get the count */ $max = static::$utf->select('COUNT(*) as count', \IPSUtf8\Session::i()->current_table )->setKeyField('count')->first(); } \IPSUtf8\Session::i()->status = 'processing'; \IPSUtf8\Session::i()->current_row = $max; \IPSUtf8\Session::i()->save(); /* Run again */ return $this->process( ( $limit - $cycleCount ), TRUE ); } } /** * Set the current work table * * @return void */ public function getTable() { $table = null; if ( \IPSUtf8\Session::i()->current_table AND isset( \IPSUtf8\Session::i()->tables[ \IPSUtf8\Session::i()->current_table ] ) ) { /* Table selected and convertion in progress */ $table = \IPSUtf8\Session::i()->tables[ \IPSUtf8\Session::i()->current_table ]; static::log( "Continuing with " . $table['name'] . ' (PKEY: ' . \IPSUtf8\Session::i()->current_pkey . ')' ); if ( ! static::$utf->checkForTable( $table['name'] ) ) { if ( static::$utf->createTable( $this->checkTable( $table['definition'] ) ) === false ) { throw new \RuntimeException( static::$utf->error . "\n" . var_export( $table['definition'], true ) ); } try { static::$utf->query("ALTER TABLE `" . static::$utf->prefix . "{$table['name']}` DISABLE KEYS;"); static::log( "{$table['name']} keys disabled." ); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} disable keys exception caught." ); } } return $table; } else { /* need to resolve an issue where if you only need to convert a few tables, these few will be named x_utf_ while others won't, so for now, just convert all */ $nonUtf8Tables = array_keys( \IPSUtf8\Session::i()->tables );//$this->getNonUtf8Tables(); /* No table selected */ if ( is_array( $nonUtf8Tables ) and count( $nonUtf8Tables ) ) { if ( is_array( \IPSUtf8\Session::i()->completed_json ) ) { $diff = array_diff( $nonUtf8Tables, array_keys( \IPSUtf8\Session::i()->completed_json ) ); if ( count( $diff ) ) { $table = \IPSUtf8\Session::i()->tables[ array_shift( $diff ) ]; } else { return null; } } else { $table = array_shift( $nonUtf8Tables ); } if ( isset( $table['name'] ) ) { \IPSUtf8\Session::i()->current_table = $table['name']; /* Got a primary key so we can use a WHERE N > X query rather than a limit for efficiency? */ if ( isset( $table['definition']['indexes']['PRIMARY'] ) ) { $pkey = $table['definition']['indexes']['PRIMARY']['columns'][0]; /* Is it numeric? */ if ( mb_stristr( $table['definition']['columns'][ $pkey ]['type'], 'int' ) ) { \IPSUtf8\Session::i()->current_pkey = $pkey; } } \IPSUtf8\Session::i()->save(); /* Create the table for UTF8 goodness */ static::$utf->dropTable( $table['name'], true ); if ( static::$utf->createTable( $this->checkTable( $table['definition'] ) ) === false ) { throw new \RuntimeException( static::$utf->error . "\n" . var_export( $table['definition'], true ) ); } static::log( "Created UTF8 table " . $table['name'] . ' (PKEY: ' . \IPSUtf8\Session::i()->current_pkey . ')' ); try { static::$utf->query("ALTER TABLE `" . static::$utf->prefix . "{$table['name']}` DISABLE KEYS;"); static::log( "{$table['name']} keys disabled." ); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$table['name']} disable keys exception caught." ); } return $table; } } } return null; } /** * Attempt to fix issues with keys longer than 1000bytes * * @param array $definition Table definition * @return array */ public function checkTable( $definition ) { /* MB4? */ $sessionData = \IPSUtf8\Session::i()->json; $length = 0; $multiplier = ( ! empty( $sessionData['use_utf8mb4'] ) ) ? 4 : 3; $needsFixing = array(); $maxLen = 1000; if ( \mb_strtolower( $definition['engine'] ) === 'innodb' ) { $maxLen = 767; } if ( isset( $definition['indexes'] ) ) { foreach( $definition['indexes'] as $key => $index ) { foreach( $index['columns'] as $i => $column ) { if ( isset( $definition['columns'][ $column ] ) and ( ( ! empty( $definition['columns'][ $column ]['length'] ) or in_array( mb_strtolower( $definition['columns'][ $column ]['type'] ), array( 'longtext', 'mediumtext', 'text' ) ) ) ) ) { $length += (int) ( $definition['columns'][ $column ]['length'] ) ? $definition['columns'][ $column ]['length'] : 250; } } if ( $length * $multiplier > $maxLen ) { foreach( $index['columns'] as $i => $column ) { if ( isset( $definition['columns'][ $column ] ) and ( ( ! empty( $definition['columns'][ $column ]['length'] ) or in_array( mb_strtolower( $definition['columns'][ $column ]['type'] ), array( 'longtext', 'mediumtext', 'text' ) ) ) ) ) { /* Column name, column length, column type */ $needsFixing[ $key ][ $i ] = array( $column, ( (int) $definition['columns'][ $column ]['length'] ) ? $definition['columns'][ $column ]['length'] : 250, $definition['columns'][ $column ]['type'] ); } } } $length = 0; } } if ( count( $needsFixing ) ) { foreach( $needsFixing as $key => $i ) { $totalLength = 0; $maxChars = $maxLen / $multiplier; foreach( $i as $vals ) { $totalLength += $vals[1]; } if ( $totalLength > $maxChars ) { /* Check each column can be reduced by the amount we need reducing */ $debt = 0; $reduceEachBy = ( ( 100 / $totalLength ) * $maxChars ) / 100; /* Apply debt if we have any. We do not reduce integers */ foreach( $i as $x => $vals ) { if ( array_key_exists( mb_strtolower( $vals[2] ), static::$numericCols ) AND static::$numericCols[ mb_strtolower( $vals[2] ) ] !== NULL ) { /* Don't go by "display" space, go by storage space, which is static regardless of supplied value ... an INT(4) still uses 11 chars */ $debt += static::$numericCols[ mb_strtolower( $vals[2] ) ]; } } /* Recalculate value to multiply index sub lengths with (subtracting debt) */ $reduceEachBy = ( ( 100 / ( $totalLength - $debt ) ) * ( $maxChars - $debt ) ) / 100; foreach( $i as $x => $vals ) { /* No length? */ if ( empty( $vals[1] ) ) { $vals[1] = 250; } if ( array_key_exists( mb_strtolower( $vals[2] ), static::$numericCols ) AND static::$numericCols[ mb_strtolower( $vals[2] ) ] !== NULL ) { /* Preserve col len */ continue; } $vals[1] = floor( $vals[1] * $reduceEachBy ); $i[ $x ] = $vals; } } foreach( $i as $x => $vals ) { if ( $definition['columns'][ $definition['indexes'][ $key ]['columns'][ $x ] ]['length'] != $vals[1] ) { $definition['indexes'][ $key ]['length'][ $x ] = $vals[1]; } } } } return $definition; } /** * Fetches the next table to process or false if nothing left to process * * @param string $currentTable Name of table just processed * @return array Array of table data */ public function getNextTable( $currentTable ) { try { static::$utf->query("ALTER TABLE `" . static::$utf->prefix . "{$currentTable}` ENABLE KEYS;"); static::log( "{$table['name']} keys enabled." ); } catch( \IPSUtf8\Db\Exception $e ) { static::log( static::$utf->prefix . "{$currentTable} enable keys exception caught." ); } $completed = \IPSUtf8\Session::i()->completed_json; $completed[ $currentTable ] = $currentTable; \IPSUtf8\Session::i()->completed_json = $completed; \IPSUtf8\Session::i()->current_pkey = null; \IPSUtf8\Session::i()->current_table = null; \IPSUtf8\Session::i()->current_row = 0; \IPSUtf8\Session::i()->save(); return $this->getTable(); } /** * Init this class */ public function init() { /* Grab all tables to convert */ $stmt = static::$db->query( "show variables" ); $tables = array(); while ( $row = $stmt->fetch_array( MYSQLI_ASSOC ) ) { $key = $row['Variable_name']; $value = $row['Value']; if ( $key == 'character_set_database' ) { $this->_data['database_charset'] = strtolower( $value ); } if ( $key == 'bulk_insert_buffer_size' ) { $this->_data['bulk_insert_buffer_size'] = $value; } if ( $key == 'max_allowed_packet' ) { $this->_data['max_allowed_packet'] = $value; } } if ( ! empty( $this->_data['max_allowed_packet'] ) AND ! empty( $this->_data['bulk_insert_buffer_size'] ) AND ( $this->_data['bulk_insert_buffer_size'] < $this->_data['max_allowed_packet'] ) ) { $this->_data['max_allowed_packet'] = $this->_data['bulk_insert_buffer_size']; } if ( empty( $this->_data['max_allowed_packet'] ) AND empty( $this->_data['bulk_insert_buffer_size'] ) ) { /* No value so use MySQL default of 1MB */ $this->_data['max_allowed_packet'] = 1048576; } } /** * Returns true if the table has a text or medium text column * * @param string $name Name of table * @return boolean */ public static function hasTextColumn( $name ) { $table = \IPSUtf8\Session::i()->tables[ $name ]; foreach( $table['definition']['columns'] as $col => $val ) { if ( in_array( mb_strtolower( $val['type'] ), array( 'mediumtext', 'text' ) ) ) { return true; } } return false; } /** * Tables with lots of dynamic columns can be slow * * @param string $name Name of table * @return boolean */ public static function throttleInserts( $name ) { $table = \IPSUtf8\Session::i()->tables[ $name ]; $total = 0; $var = 0; foreach( $table['definition']['columns'] as $col => $val ) { $total++; if ( in_array( mb_strtolower( $val['type'] ), array( 'varchar' ) ) ) { $var++; } } if ( $total > 70 ) { return 50; } else if ( $total > 30 AND $var > 0 ) { if ( ( $var / $total ) * 100 > 25 ) { return 100; } } return false; } /** * Returns whether the DB is UTF8 already * * @return bool */ public function databaseIsUtf8() { $yeahProbably = false; if ( \IPSUtf8\Convert::i()->database_charset == 'utf8mb4' OR \IPSUtf8\Convert::i()->database_charset == 'utf8' OR \IPSUtf8\Session::i()->current_charset == 'utf-8' ) { $yeahProbably = true; } /* Another check */ if ( $yeahProbably === true ) { /* Best check the tables, then */ $json = \IPSUtf8\Session::i()->json; if ( count( $this->getNonUtf8Tables() ) ) { $yeahProbably = false; } } return $yeahProbably; } /** * Grab the character sets from MySQL * * @return array */ public function getMysqlCharSets() { if ( static::$mysqlCharSets === NULL ) { static::$mysqlCharSets = array( 'latin1', 'utf8' ); $stmt = static::$db->query( "show character set" ); while ( $row = $stmt->fetch_array( MYSQLI_ASSOC ) ) { static::$mysqlCharSets[] = $row['Charset']; } } return static::$mysqlCharSets; } /** * Return tables that need converting * * @param boolean $force Force a recount * @return array */ public function getNonUtf8Tables( $force=false ) { if ( static::$nonUtf8Tables === NULL or $force === TRUE ) { static::$nonUtf8Tables = array(); /* Best check the tables, then */ $json = \IPSUtf8\Session::i()->json; foreach( $json['charSets'] as $charSet => $tablesArray ) { if ( ! empty( $json['force_conversion'] ) or ( $charSet != 'utf8' and $charSet != 'utf8mb4' ) ) { static::$nonUtf8Tables = array_merge( static::$nonUtf8Tables, $tablesArray ); } } } return static::$nonUtf8Tables; } /** * Returns total rows to convert * * @return array */ public function getTotalRowsToConvert() { if ( static::$rowsToConvert === NULL ) { static::$rowsToConvert = 0; foreach( array_keys( \IPSUtf8\Session::i()->tables ) as $table ) { static::$rowsToConvert += intval( \IPSUtf8\Session::i()->tables[ $table ]['count'] ); } } return static::$rowsToConvert; } /** * Returns whether the DB has the correct collation * * @return bool */ public function getNonUtf8CollationTables() { if ( static::$nonUtf8Collations === null ) { static::$nonUtf8Collations = array(); /* Best check the tables, then */ foreach( \IPSUtf8\Session::i()->tables as $table => $definition ) { /* If the table itself is not utf8_unicode_ci, don't bother check columns */ if ( $definition['collation'] AND !in_array( $definition['collation'], array( 'utf8_unicode_ci', 'utf8mb4_unicode_ci' ) ) ) { static::$nonUtf8Collations[] = $table; continue; } foreach( $definition['definition']['columns'] as $name => $column ) { if ( $column['collation'] and ! in_array( $column['collation'], array( 'utf8_unicode_ci', 'utf8mb4_unicode_ci' ) ) ) { static::$nonUtf8Collations[] = $table; break; } } } } return static::$nonUtf8Collations; } /** * Fetch debug information * * @return array */ public function getDebugString() { $data = array(); $tableCharSet = \IPSUtf8\Convert::i()->database_charset; $currentCharSet = \IPSUtf8\Session::i()->current_charset; $json = \IPSUtf8\Session::i()->json; $data[] = "IP.Board Character Set: " . $currentCharSet; $data[] = "Database Character Set: " . $tableCharSet; $data[] = "Original table prefix: " . static::$db->prefix; $data[] = "Converted table prefix: " . static::$utf->prefix; foreach( $json['charSets'] as $charSet => $tablesArray ) { $data[] = count( $tablesArray ) . ' tables are ' . $charSet; } $data[] = count( $this->getNonUtf8CollationTables() ) . " tables have incorrect collations"; $data[] = "Can use 'dump' method: " . var_export( $this->canDump(), true ); return $data; } /** * Detect whether this is a serialized string or not * * @param string $string The actual string * @return boolean */ public static function isSerialized( $string ) { /* If it looks nothing like a serialized string, then return it */ if ( ! preg_match( '#^a:\d+:\{#', $string ) ) { return false; } /* Now make sure it's actually a serialized string */ return ( boolean ) @unserialize( $string ); } /** * Log data */ public static function log( $message ) { $file = THIS_PATH . '/tmp/log_' . date('Y-m-d') . '.cgi'; $isNew = false; if ( ! is_file( $file ) ) { $isNew = true; } @file_put_contents( $file, "\n" . str_repeat( '-', 48 ) . "\n" . date('r') . "\n" . $message, FILE_APPEND ); if ( $isNew ) { @chmod( $file, 0777 ); } } /** * Return a list of numeric columns that need checking * * @param string $name Table * @return array Array of columns that need checking as they can contain INT */ public static function getNumericColumns( $name ) { if ( ! is_string( $name ) ) { return false; } $table = \IPSUtf8\Session::i()->tables[ $name ]; $return = array(); foreach( $table['definition']['columns'] as $col => $val ) { if ( in_array( mb_strtolower( $val['type'] ), static::$numericCols ) ) { $return[] = $val['name']; } } return $return; } /** * Return a list of columns that need converting * * @param string $name Table * @return array Array of columns that need converting as they can contain text */ public static function getConvertableColumns( $name ) { if ( ! is_string( $name ) ) { return false; } $table = \IPSUtf8\Session::i()->tables[ $name ]; $return = array(); foreach( $table['definition']['columns'] as $col => $val ) { if ( in_array( mb_strtolower( $val['type'] ), static::$convertCols ) ) { $return[] = $val['name']; } } return $return; } /** * Can we use the fast dump method? * * @return boolean */ public static function canDump() { /* Best check the tables, then */ $json = \IPSUtf8\Session::i()->json; $tablesOk = true; foreach( $json['charSets'] as $charSet => $tablesArray ) { if ( $charSet != 'utf8' and $charSet != 'utf8mb4' and mb_substr( $charSet, 0, 5) != 'latin' and $charSet != 'windows-1252' ) { $tablesOk = false; break; } } if ( ( $tablesOk ) AND ( \IPSUtf8\Session::i()->current_charset == 'utf-8' or \IPSUtf8\Session::i()->current_charset == 'iso-8859-1' ) AND ( ( is_callable( 'exec' ) AND false === stripos( ini_get( 'disable_functions' ), 'exec' ) ) ) ) { try { @exec("iconv -l", $output ); } catch( \ErrorException $e ) { return false; } if ( ! array( $output ) or ! count( $output ) or ( ! stristr( implode( "\n", $output ), 'latin' ) ) ) { return false; } return true; } else { return false; } } /** * Get Table Schematic * * @return array */ protected static function getTableSchematic( $table ) { return json_decode( \IPSUtf8\Db::i('utf8')->select( 'table_schema', 'convert_session_tables', array( "table_name=?", $table ) )->first(), TRUE ); } }