<?php
namespace XF\Install;
use XF\Db\Schema\Alter;
use function count, intval, is_array, strlen;
/**
* Class InstallHelperTrait
*
* The methods here are designed to be called by upgrade steps for various uses. This is a trait so it can be shared
* with both the core software installer and add-on setup classes.
*
* @package XF\Install
*/
trait InstallHelperTrait
{
/**
* @var \XF\App
*/
protected $app;
/**
* Convert all SERIALIZED data controlled by the entity named in $entityName to JSON.
* Serialized columns are identified in $columns.
*
* @param $entityName
* @param array $columns
* @param $position
* @param array $stepData
* @param bool $singlePass
*
* @return array|bool
*/
public function entityColumnsToJson($entityName, array $columns, $position, array $stepData, $singlePass = false, $perPage = 1000)
{
$class = \XF::stringToClass($entityName, '%s\Entity\%s');
$structure = $class::getStructure(new \XF\Mvc\Entity\Structure());
$primaryKeys = is_array($structure->primaryKey) ? $structure->primaryKey : [$structure->primaryKey];
$regex = '^([abCdioOsS]:|N;$)';
return $this->tableColumnsToJson($structure->table, $primaryKeys, $columns, $position, $stepData, $singlePass, $regex, $perPage);
}
/**
* Convert all SERIALIZED data in $tableName to JSON.
* Serialized fields are identified in $serializedFields.
*
* @param $tableName
* @param array $primaryKeys
* @param array $serializedFields
* @param $position
* @param array $stepData
* @param bool $singlePass
* @param string $regex
*
* @return array|bool
*/
public function tableColumnsToJson(
$tableName, array $primaryKeys, array $serializedFields, $position, array $stepData, $singlePass = false, $regex = '^([abCdioOsS]:|N;$)', $perPage = 1000
)
{
if (!strlen($regex))
{
throw new \LogicException("Must provide a limiting regex");
}
$db = \XF::db();
$db->beginTransaction();
if (count($primaryKeys) == 1 && isset($stepData['max']))
{
// for simple cases, we can do a bit more
$perPage *= 2;
}
if (!isset($stepData['max']))
{
$stepData['max'] = $db->fetchOne("SELECT MAX({$primaryKeys[0]}) FROM {$tableName}");
// basic updates for empty array columns
foreach ($serializedFields AS $column)
{
$db->update($tableName, [$column => '[]'], "{$column} = ?", ['a:0:{}']);
}
}
$whereSql = [];
foreach ($serializedFields AS $column)
{
$whereSql[] = "({$column} <> '' AND {$column} <> '[]' AND {$column} REGEXP '{$regex}')";
}
if ($singlePass)
{
$perPage = 999999999;
}
$results = $db->fetchAll("
SELECT
" . implode(", ", $primaryKeys) . ",
" . implode(", ", $serializedFields) . "
FROM {$tableName}
WHERE {$primaryKeys[0]} > ?
AND (" . implode(' OR ', $whereSql) . ")
ORDER BY {$primaryKeys[0]}
LIMIT {$perPage}
", $position);
if (!$results)
{
$db->commit();
return true;
}
$next = $this->updateResultsToJson($results, $tableName, $primaryKeys, $serializedFields);
if ($next && count($primaryKeys) > 1)
{
// With a multi-column key, we assume the first column is an integer and use that for ordering.
// But we might not finish all rows with that first column value, so fetch the rest of the last
// one that we saw and convert them all here. Otherwise, they won't be converted on the next go around.
$results = $db->fetchAll("
SELECT
" . implode(", ", $primaryKeys) . ",
" . implode(", ", $serializedFields) . "
FROM {$tableName}
WHERE {$primaryKeys[0]} = ?
AND (" . implode(' OR ', $whereSql) . ")
", $next);
$this->updateResultsToJson($results, $tableName, $primaryKeys, $serializedFields);
}
$db->commit();
return [
$next,
"{$next} / {$stepData['max']}",
$stepData
];
}
protected function updateResultsToJson(array $results, $tableName, array $primaryKeys, array $serializedFields)
{
$db = \XF::db();
$next = 0;
foreach ($results AS $result)
{
$next = $result[$primaryKeys[0]];
$newValues = [];
foreach ($serializedFields AS $column)
{
$oldValue = $result[$column];
if ($oldValue !== '')
{
$newValue = \XF\Util\Json::decodeJsonOrSerialized($oldValue);
if ($newValue !== false || $oldValue === 'b:0;')
{
$encodedValue = json_encode($newValue);
if ($encodedValue === false && $newValue !== false)
{
$pk = $primaryKeys[0];
$pkValue = $result[$pk];
$errorMessage = "Error doing JSON conversion $tableName.$column ($pk=$pkValue): "
. json_last_error_msg() . '. Forcing partial conversion.';
if (intval($pkValue))
{
try
{
$db->insert('xf_json_convert_error', [
'table_name' => substr($tableName, 0, 100),
'column_name' => substr($column, 0, 100),
'pk_id' => intval($pkValue),
'original_value' => $oldValue
], false, 'original_value = VALUES(original_value)');
$errorMessage .= ' (Original value logged into xf_json_convert_error table)';
}
catch (\XF\Db\Exception $e) {}
}
\XF::logError($errorMessage, true);
$encodedValue = json_encode($newValue, JSON_PARTIAL_OUTPUT_ON_ERROR);
}
$newValues[$column] = $encodedValue;
}
}
}
if (!empty($newValues))
{
$pkConditions = [];
$pkValues = [];
foreach ($primaryKeys AS $pk)
{
$pkConditions[] = "$pk = ?";
$pkValues[] = $result[$pk];
}
$db->update($tableName, $newValues, implode(' AND ', $pkConditions), $pkValues);
}
}
return $next;
}
/**
* @param $sql
* @param string|array $bind
* @param bool $suppressAll
*
* @return bool|\XF\Db\AbstractStatement
* @throws \XF\Db\Exception|\Exception
*/
public function executeUpgradeQuery($sql, $bind = [], $suppressAll = false)
{
try
{
return $this->db()->query($sql, $bind);
}
catch (\XF\Db\Exception $e)
{
if ($suppressAll)
{
return false;
}
$message = $e->getMessage();
if (preg_match('/(have an error in your SQL syntax|table \'.*\' doesn\'t exist|Unknown column|doesn\'t have a default value|Data truncated)/i', $message))
{
// we don't want to suppress errors in the query that should generally be corrected
throw $e;
}
return false;
}
}
public function migrateTableToReactions($tableName, $likesColumn = 'likes', $likeUsersColumn = 'like_users')
{
if (!$this->schemaManager()->columnExists($tableName, $likesColumn))
{
// likes column is gone, so assume this has been run
return;
}
$this->alterTable($tableName, function (Alter $table) use ($likesColumn, $likeUsersColumn)
{
$table->changeColumn($likesColumn)->type('int')->unsigned(false)->renameTo('reaction_score');
$table->addColumn('reactions', 'blob')->nullable()->after('reaction_score');
$table->renameColumn($likeUsersColumn, 'reaction_users');
});
$this->executeUpgradeQuery('UPDATE `' . $tableName . '` SET reactions = CONCAT(\'{"1":\', reaction_score, \'}\') WHERE reaction_score > 0');
}
public function renameLikeAlertOptionsToReactions($contentTypes, $oldAction = 'like')
{
foreach ((array)$contentTypes AS $contentType)
{
$old = "{$contentType}_{$oldAction}";
$new = "{$contentType}_reaction";
$this->executeUpgradeQuery('UPDATE xf_user_alert_optout SET alert = ? WHERE alert = ?', [$new, $old]);
$this->executeUpgradeQuery('UPDATE xf_user_push_optout SET push = ? WHERE push = ?', [$new, $old]);
$this->executeUpgradeQuery('UPDATE xf_user_option SET alert_optout = REPLACE(alert_optout, ?, ?) WHERE alert_optout LIKE ?', [$old, $new, $this->db()->escapeLike($old, '%?%')]);
$this->executeUpgradeQuery('UPDATE xf_user_option SET push_optout = REPLACE(push_optout, ?, ?) WHERE push_optout LIKE ?', [$old, $new, $this->db()->escapeLike($old, '%?%')]);
}
}
public function renameLikeAlertsToReactions($contentTypes, $renameNewsFeed = true, $oldAction = 'like')
{
$db = $this->db();
$contentTypesQuoted = $db->quote((array)$contentTypes);
$this->executeUpgradeQuery("
UPDATE xf_user_alert
SET action = 'reaction',
extra_data = '{\"reaction_id\":1}'
WHERE content_type IN({$contentTypesQuoted})
AND action = ?
", $oldAction);
if ($renameNewsFeed)
{
$this->executeUpgradeQuery("
UPDATE xf_news_feed
SET action = 'reaction',
extra_data = '{\"reaction_id\":1}'
WHERE content_type IN({$contentTypesQuoted})
AND action = ?
", $oldAction);
}
}
public function renameLikeCriteriaToReactions($criteriaTable, $primaryKey, $criteriaField = 'user_criteria')
{
$db = $this->db();
$items = $this->db()->fetchPairs("
SELECT `$primaryKey`, `$criteriaField`
FROM `$criteriaTable`
WHERE `$criteriaField` LIKE '%\"like_count\"%'
OR `$criteriaField` LIKE '%\"like_ratio\"%'
ORDER BY `$primaryKey`
");
if (!$items)
{
return;
}
foreach ($items AS $key => $criteria)
{
$encodeF = 'json_encode';
$rules = @json_decode($criteria, true);
if (!$rules)
{
$encodeF = 'serialize';
$rules = @unserialize($criteria);
}
if (!$rules)
{
continue;
}
foreach ($rules AS &$rule)
{
if ($rule['rule'] == 'like_count')
{
$rule['rule'] = 'reaction_score';
$rule['data']['reactions'] = $rule['data']['likes'];
unset($rule['data']['likes']);
}
else if ($rule['rule'] == 'like_ratio')
{
$rule['rule'] = 'reaction_ratio';
}
}
$db->update($criteriaTable, [
$criteriaField => $encodeF($rules)
], "$primaryKey = ?", $key, 'IGNORE');
}
}
public function renameLikePermissionsToReactions(array $permissionGroupIds, $likePermissionId = 'like', $reactPermissionId = 'react')
{
$globalPermissions = [];
$contentPermissions = [];
foreach ($permissionGroupIds AS $permissionGroupId => $contentPermission)
{
$globalPermissions[] = $permissionGroupId;
if ($contentPermission)
{
$contentPermissions[] = $permissionGroupId;
}
}
$db = $this->db();
$globalPermissionsQuoted = $db->quote($globalPermissions);
$this->executeUpgradeQuery("
UPDATE xf_permission_entry
SET permission_id = '{$reactPermissionId}'
WHERE permission_id = ?
AND permission_group_id IN({$globalPermissionsQuoted})
", $likePermissionId);
if ($contentPermissions)
{
$contentPermissionsQuoted = $db->quote($contentPermissions);
$this->executeUpgradeQuery("
UPDATE xf_permission_entry_content
SET permission_id = 'react'
WHERE permission_id = ?
AND permission_group_id IN({$contentPermissionsQuoted})
", $likePermissionId);
}
}
public function renameLikeStatsToReactions($contentTypes)
{
if (!is_array($contentTypes))
{
$contentTypes = [$contentTypes];
}
foreach ($contentTypes AS $contentType)
{
$this->executeUpgradeQuery("
UPDATE xf_stats_daily
SET stats_type = ?
WHERE stats_type = ?
", ["{$contentType}_reaction", "{$contentType}_like"]);
}
}
public function addPrefixDescHelpPhrases(array $types)
{
foreach ($types AS $contentType => $table)
{
$prefixIds = $this->db()->fetchAllColumn("SELECT prefix_id FROM `$table`");
foreach ($prefixIds AS $prefixId)
{
$this->insertCustomMasterPhrase(
sprintf('%s_prefix_desc.%d', $contentType, $prefixId),
''
);
$this->insertCustomMasterPhrase(
sprintf('%s_prefix_help.%d', $contentType, $prefixId),
''
);
}
}
}
public function insertCustomMasterPhrase(string $title, string $text)
{
/** @var \XF\Entity\Phrase $phrase */
$phrase = $this->app->em()->create('XF:Phrase');
$phrase->title = $title;
$phrase->phrase_text = $text;
$phrase->language_id = 0;
$phrase->addon_id = '';
$phrase->save(false);
}
public function createWidget($widgetKey, $definitionId, array $config, $title = '')
{
/** @var \XF\Entity\Widget $widget */
$widget = $this->app->em()->create('XF:Widget');
$widget->widget_key = $widgetKey;
$widget->definition_id = $definitionId;
$widget->bulkSet($config);
$success = $widget->save(false);
if ($success)
{
$masterTitle = $widget->getMasterPhrase();
$masterTitle->phrase_text = $title;
$masterTitle->save(false);
}
}
public function createActivitySummarySection($definitionId, array $config, $title = '')
{
/** @var \XF\Entity\ActivitySummarySection $section */
$section = $this->app->em()->create('XF:ActivitySummarySection');
$section->definition_id = $definitionId;
$section->bulkSet($config);
$success = $section->save(false);
if ($success)
{
$masterTitle = $section->getMasterPhrase();
$masterTitle->phrase_text = $title;
$masterTitle->save(false);
}
}
public function deleteWidget($widgetKey)
{
$widget = $this->app->finder('XF:Widget')->where('widget_key', $widgetKey)->fetchOne();
if (!$widget)
{
return;
}
$widget->delete(false);
}
public function applyGlobalPermission($applyGroupId, $applyPermissionId, $dependGroupId = null, $dependPermissionId = null)
{
$db = $this->db();
if ($dependGroupId && $dependPermissionId)
{
$db->query("
REPLACE INTO xf_permission_entry
(user_group_id, user_id, permission_group_id, permission_id, permission_value, permission_value_int)
SELECT user_group_id, user_id, ?, ?, 'allow', 0
FROM xf_permission_entry
WHERE permission_group_id = ?
AND permission_id = ?
AND permission_value = 'allow'
", [$applyGroupId, $applyPermissionId, $dependGroupId, $dependPermissionId]);
}
else
{
$db->query("
REPLACE INTO xf_permission_entry
(user_group_id, user_id, permission_group_id, permission_id, permission_value, permission_value_int)
SELECT DISTINCT user_group_id, user_id, ?, ?, 'allow', 0
FROM xf_permission_entry
", [$applyGroupId, $applyPermissionId]);
}
}
public function applyGlobalPermissionInt($applyGroupId, $applyPermissionId, $applyValue, $dependGroupId = null, $dependPermissionId = null)
{
$db = $this->db();
if ($dependGroupId && $dependPermissionId)
{
$db->query("
REPLACE INTO xf_permission_entry
(user_group_id, user_id, permission_group_id, permission_id, permission_value, permission_value_int)
SELECT user_group_id, user_id, ?, ?, 'use_int', ?
FROM xf_permission_entry
WHERE permission_group_id = ?
AND permission_id = ?
AND permission_value = 'allow'
", [$applyGroupId, $applyPermissionId, $applyValue, $dependGroupId, $dependPermissionId]);
}
else
{
$db->query("
REPLACE INTO xf_permission_entry
(user_group_id, user_id, permission_group_id, permission_id, permission_value, permission_value_int)
SELECT DISTINCT user_group_id, user_id, ?, ?, 'use_int', ?
FROM xf_permission_entry
", [$applyGroupId, $applyPermissionId, $applyValue]);
}
}
public function applyContentPermission($applyGroupId, $applyPermissionId, $dependGroupId, $dependPermissionId)
{
$db = $this->db();
$db->query("
REPLACE INTO xf_permission_entry_content
(content_type, content_id, user_group_id, user_id,
permission_group_id, permission_id, permission_value, permission_value_int)
SELECT content_type, content_id, user_group_id, user_id, ?, ?, 'content_allow', 0
FROM xf_permission_entry_content
WHERE permission_group_id = ?
AND permission_id = ?
AND permission_value = 'content_allow'
", [$applyGroupId, $applyPermissionId, $dependGroupId, $dependPermissionId]);
}
public function applyAdminPermission($applyPermissionId, $dependPermissionId)
{
// note: this doesn't rebuild the admin permission cache -- that should happen when the permission
// is inserted, so this is only safe to use on its own when inserting the permission
$this->db()->query("
INSERT IGNORE INTO xf_admin_permission_entry
(user_id, admin_permission_id)
SELECT user_id, ?
FROM xf_admin_permission_entry
WHERE admin_permission_id = ?
", [$applyPermissionId, $dependPermissionId]);
}
public function uninstallContentTypeData($contentTypes)
{
if (!is_array($contentTypes))
{
$contentTypes = [$contentTypes];
}
$db = $this->db();
$contentTypesQuoted = $db->quote($contentTypes);
$db->beginTransaction();
$contentTypeTables = [
'xf_approval_queue',
'xf_bookmark_item',
'xf_change_log',
'xf_content_spam_cache',
'xf_deletion_log',
'xf_edit_history',
'xf_moderator_content',
'xf_moderator_log',
'xf_news_feed',
'xf_poll',
'xf_reaction_content',
'xf_report',
'xf_spam_trigger_log',
'xf_tag_content',
'xf_user_alert'
];
foreach ($contentTypeTables AS $table)
{
$db->delete($table, 'content_type IN (' . $contentTypesQuoted . ')');
}
// TODO: should try to remove report comments
// let these be cleaned up over time
$db->update('xf_attachment', ['unassociated' => 1, 'content_id' => 0], 'content_type IN (' . $contentTypesQuoted . ')');
$db->commit();
}
public function insertForumType($type, $handlerClass, $addOnId, $rebuildCache = true)
{
$this->db()->insert('xf_forum_type', [
'forum_type_id' => $type,
'handler_class' => $handlerClass,
'addon_id' => $addOnId
], false, 'handler_class = VALUES(handler_class), addon_id = VALUES(addon_id)');
if ($rebuildCache)
{
\XF::runOnce('rebuildForumTypeCache', function()
{
$this->app->repository('XF:ForumType')->rebuildForumTypeCache();
});
}
}
public function insertThreadType($type, $handlerClass, $addOnId, $rebuildCache = true)
{
$this->db()->insert('xf_thread_type', [
'thread_type_id' => $type,
'handler_class' => $handlerClass,
'addon_id' => $addOnId
], false, 'handler_class = VALUES(handler_class), addon_id = VALUES(addon_id)');
if ($rebuildCache)
{
\XF::runOnce('rebuildThreadTypeCache', function()
{
$this->app->repository('XF:ThreadType')->rebuildThreadTypeCache();
});
}
}
/**
* @return \XF\App
*/
protected function app()
{
return $this->app;
}
/**
* @return \XF\Db\AbstractAdapter
*/
protected function db()
{
return $this->app->db();
}
/**
* @return \XF\Db\SchemaManager
*/
protected function schemaManager()
{
return $this->db()->getSchemaManager();
}
protected function alterTable($tableName, \Closure $toApply)
{
$this->schemaManager()->alterTable($tableName, $toApply);
}
protected function renameTable($oldTableName, $newTableName)
{
$this->schemaManager()->renameTable($oldTableName, $newTableName);
}
protected function createTable($tableName, \Closure $toApply)
{
$this->schemaManager()->createTable($tableName, $toApply);
}
protected function dropTable($tableName, \Closure $toApply = null)
{
$this->schemaManager()->dropTable($tableName, $toApply);
}
public function tableExists($tableName)
{
return $this->schemaManager()->tableExists($tableName);
}
public function columnExists($tableName, $column, &$definition = null)
{
return $this->schemaManager()->columnExists($tableName, $column, $definition);
}
}