如何在安装程序中自动创建(My)SQL ALTER 脚本?

4
在一个软件安装程序中,我需要自动创建一个(My)SQL ALTER脚本,只需给出运行中的数据库(例如数据结构版本x.5)和一对完整的DB (My)SQL CREATE脚本(例如版本x.1至x.9)即可。
首先,我需要找到当前运行的版本(或者如果可能的话,最接近的版本,可能有些安装程序之前更新时会出现错误,但这个功能是次要的)。然后,我想创建一个ALTER脚本来修复运行版本可能存在的错误。
之后,我想自动创建一个ALTER脚本到最新版本(x.9),并应用此脚本。再次比较两个版本,并重复,直到版本更新为止。
由于这将在安装程序中盲目运行,因此我不能使用GUI应用程序。目标平台将是Windows XP/7。安装数量将长期低于300个(高度专业化行业软件的更新软件)。所以我的问题是:
是否有任何好的(My)SQL比较/差异/脚本生成库可供C++/NSIS/其他安装程序框架使用?
感谢您的支持!

鉴于目前没有任何答案真正解决了我的问题,今天下午我将掷骰子,看看谁是幸运儿(我仍然有我的收藏夹)。截止目前,非常感谢大家的支持。 - Sebastian Lange
5个回答

3

我长期以来一直在思考同样的问题,但还没有找到一个合适的解决方案。我将分享我的做法,希望能对你有所帮助。

我的当前做法是应用一系列SQL查询,设计得适用于数据库之前的任何模式版本。如果命令已经被应用过了,那么它就会失败(例如添加字段或添加索引)。

这种方法限制了改变数据库模式的方式,也容易出现错误 - 例如,如果误将查询扩展ENUM(a,b) 字段为ENUM(a,b,c),然后再扩展为ENUM(a,b,c,d),那么运行脚本时具有值d的现有记录将会损坏。如果只有一个最新格式的查询,则很容易解决这个问题。

我也在后来添加了模式版本控制,并且目前使用一个简单但易于管理的更新文件格式 - 每行一个查询,以 ; 结尾,额外的行用于划分模式版本:

-- version 105

通过这个升级,可以大大简化代码,并在一个单一的函数中统一处理所有版本转换。该函数只需要处理在--version <current version>行之后的查询。在到达-- version行后,该函数会更新数据库中的模式版本。
此外,这种格式允许使用mysql -f mydb < myfile命令进行手动处理。在这种情况下,版本行将被作为注释忽略,并尝试对当前模式执行所有更改的所有命令 - 这可以用于修复错误(假设错误是指预期之前的旧模式)。还有一个类似的技巧可以用于更新存储过程的代码:
drop procedure if exists procname;
delimiter //
create procedure procname ...
//
delimiter ;

在您的问题中,您询问有关DB模式差异/补丁的问题 - 只有在添加新字段/索引等情况下才能概括,但无法自动处理重命名字段或删除字段。没有办法让自动化过程知道现有模式和新模式中的表1中的字段a应该被重命名为b,并保留现有数据(我假设现有数据必须保持完好无损)。因此,总结一下 - 在一般情况下,没有自动生成DB模式更新脚本的方式。

谢谢你的帮助。虽然这还没有解决问题,但“--version”注释是个好主意,所以我至少可以点赞。 - Sebastian Lange

2
这个问题有两种方法可以采取。
  1. 更改脚本旨在影响数据库的模式,而不关心数据。

  2. 更改脚本旨在影响模式,同时保留数据。

第一种方法中,只需删除当前数据库并生成一个新的数据库即可轻松完成。但我确定这不是您想要的,因为数据是方程式的重要组成部分。
第二种方法中,在任何操作之前,您需要知道无论您将要处理什么DBMS,都无法完成此操作,因为SQL并不像听起来那么标准。如果有特定的DBMS,请创建一个最新版本的模式,并将其与当前版本进行比较。这里是您可能会发现有用的MySQL工具列表
在这种方法中,您可以做以下事情:
  • 检查并查看表是否被删除。
  • 检查并查看表是否为新的。
  • 检查并查看字段是否被删除。
  • 检查并查看字段是否为新的。
  • 检查并查看表的属性是否被修改。
  • 检查并查看字段的属性是否被修改。

在这种方法中,你无法做到以下事情:

  • 检查并查看表是否被重命名。
  • 检查并查看字段是否被重命名。

换句话说,重命名实体将导致一个DROP语句和一个CREATE语句,这将导致数据丢失。这是这种方法的逻辑问题,不可能克服它。唯一的方法是查看更改命令列表并寻找适当的命令(如果你有更改语句列表而不仅仅是最终架构)。实现这个也是很麻烦的。

这种方法还有另一个重要问题; 因为我们正在采取最接近目标模式的路径,所以在过程中可能会错过一些重要的步骤。 比如,想象一下你可能已经执行过的脚本,它们影响了数据库的数据而不是其模式。 由于您没有数据的参考(除非您确实有,但我认为这不是您的情况),因此无法使用任何差异工具提取这些语句。 在这种情况下,您唯一的选择是按应该应用的相同顺序逐个应用脚本列表。 只有当您拥有版本控制机制或通过分析来制定列表时,才可能拥有这样的列表。 我几乎想不出有什么工具可以帮助您解决这个问题(如果您的数据库没有版本)。 至少我不知道有任何!

非常感谢您的努力。您的描述就是我在提问时卡住的地方。但是您的解释非常清晰明了。我给您点赞。 - Sebastian Lange
不客气,我的荣幸。我也会迫不及待地关注这个讨论,看看是否有人能提出解决方案。 - Mehran

1
我在我的应用程序中做的是在数据库中保留一个数据库版本值。
我的应用程序需要一个必需的数据库版本。
这里是我的Pascal-Oracle代码的一部分。希望它能给你一个好的想法。
const
  ApplicationsDBVersion = 26 ;
.....
.....
if CurrentDBVersion = ApplicationsDBVersion then
  Exit ;
if CurrentDBVersion < 0 then // just in a case that the database is out of sync.
  Exit;
updtScript := tStringList.Create ;
if CurrentDBVersion < 1 then
  Upgrade2Version1 ;
if CurrentDBVersion < 2 then
  Upgrade2Version2 ;
if CurrentDBVersion < 3 then
  upgrade2Version3 ;
.....
.....
.....
procedure Upgrade2Version3 ;
begin
  UpdateParameter(-3) ; // set that database is in inconsitent state
  AddField('tableX','ColX','CHAR(1)') ; // I've plenty of such routines (AddRef, AlterField, DropField,AddTable etc...
  AddField('tableX','ColY','char(1) constraint CKC_checkConstraint check (ColY is null or (Coly in (''E'',''H'')))') ;
  AddField('TableY','Colz','NUMBER(3)') ;
  UpdateParameter(3); // set that database is in consistent state ( no fail in scripts )
  runScript(3) ; // actually do the job...
end;
...
procedure UpdateParameter (_dbVersion : Integer) ;
begin
  if CurrentDBVersion = 0 then
    updtScript.Add('Insert into parametre (parametre,sira_no,deger) values ('+QuotedStr(cPRM_VeriTabaniSurumu)+',1,''1'')')
  else
    updtScript.Add('update parametre set deger = '+IntToStr(_dbVersion) + ' where parametre = '+QuotedStr(cPRM_VeriTabaniSurumu));
end ;

我们的数据库确实有版本控制,从某个版本开始就有了。但是我们也在版本控制之前开始使用数据库,不能假设数据库已经更新到给定的版本或没有错误。这就是为什么我们需要为数据库生成“差异”补丁的原因。 - Sebastian Lange

1
我能想到的最好的办法是与您分享我的脚本,它可以接受列定义列表并相应地更改数据库表。它可以添加、删除、修改(甚至重命名)列和更改主键。不幸的是,它是PHP编写的,所以需要重新编码,但也许您可以找到通用的思路有用。
我已经成功使用这个脚本几个月来升级我的CMS的各种安装。
该函数接受一个数组的数组作为第二个参数,其中每个后者都包含在位置处:
0 - Column name
1 - MySql column type (ex. "int" or "varchar(30)").
2 - whether columns is nullable (true for allow null, false for forbid)
3 - The default value for column (ie. "0").
4 - true, when column is part of primary key
5 - old name of a column (thus column of name in 5., if exists, is going to be renamed to column of name in 0.)

第一个参数是表名,第三个参数是函数是否应该删除在数据库表中存在但在提供的数组中被忽略的列。

对于这个恶心的协议我很抱歉,但是这个函数从来没有被设计成公共接口的一部分。:-)

下面是 CreateOrUpdateTable 函数体(稍后会解释引用):

function CreateOrUpdateTable($tablename, array $columns, $allowdropcolumn = false)
{       
    foreach($columns as &$column)
    {
        if ((!isset($column[0])) || (!preg_match('/^[a-zA-Z0-9_\-]+$/', $column[0])))
            $column[0] = 'TableColumn' . array_search($column, $columns);
        if ((!isset($column[1])) || (!preg_match('/^(int|date|datetime|decimal\([0-9]+,[0-9]+\)|varchar\([0-9]+\)|char\([0-9]+\)|text|tinyint)$/', $column[1])))
            $column[1] = 'int';
        if ((!isset($column[2])) || (!is_bool($column[2])))
            $column[2] = ALLOW_NULL;
        if ((!isset($column[3])) || (!is_string($column[3])))
            $column[3] = (($column[2] == ALLOW_NULL || $column[1] === 'text') ? 'NULL' : ($column[1] == 'int' ? "'0'" : ($column[1] == 'tinyint' ? "'0'" : ($column[1] == 'decimal' ? "'0.00'" : ($column[1] == 'date' ? "'1900-01-01'" : ($column[1] == 'datetime' ? "'1900-01-01 00:00:00'" : "''"))))));
        else
            $column[3] = "'" . Uti::Sql($column[3]) . "'";
        if ((!isset($column[4])) || (!is_bool($column[4])))
            $column[4] = false;
    }
    unset($column);

    if (!$this->TableExists($tablename))
    {
        $statements = array();
        foreach ($columns as $column)
        {
            $statement = $this->ColumnCreationStatement($column);
            if ($statement !== '')
                $statements[] = $statement;
        }

        $this->Query("create table " . $tablename . "(" . implode(',', $statements) . ") ENGINE=InnoDB DEFAULT CHARSET=latin2");
    }
    else
    {
        $this->Select("show columns in " . $tablename);
        $existing = $this->AllRows(null, 'Field');

        $oldkeys = array(); $newkeys = array();         
        foreach ($existing as $e)
            if ($e['Key'] === 'PRI')
                $oldkeys[] = $e['Field'];

        sort($oldkeys);
        $oldkeys = implode(',', $oldkeys);

        $lastcolumn = ''; // not 'FIRST' as we can extend existing table here providing only extending columns

        foreach ($columns as $column)
        {
            if ($column[4])
                $newkeys[] = $column[0];

            $newtype = $column[1] . ($column[1] === 'int' ? '(11)' : ($column[1] === 'tinyint' ? '(4)' : ''));
            $newnull = ($column[2] === ALLOW_NULL ? 'YES' : 'NO');
            $newdefault = $column[3];                   

            if (isset($existing[$column[0]]))
            {
                $oldtype = $existing[$column[0]]['Type'];
                $oldnull = $existing[$column[0]]['Null'];
                $olddefault = isset($existing[$column[0]]['Default']) ? "'" . Uti::Sql($existing[$column[0]]['Default']) . "'" : "NULL";

                if (($oldtype != $newtype) || ($oldnull != $newnull) || ($olddefault != $newdefault))
                {
                    $this->SaveToLog("Altering table [" . $tablename . "], column [" . $column[0] . "], changing: type [" .
                        $oldtype . "] => [" . $newtype . "] nullability [" . $oldnull . "] => [" . $newnull . "] default [" . $olddefault . "] => [" . $newdefault . "]", true);
                    $statement = $this->ColumnCreationStatement($column, false);
                    if ($statement !== '')
                        $this->Query("alter table " . $tablename . " change " . $column[0] . " " . $statement);
                }

                unset($existing[$column[0]]);
            }
            else if (isset($column[5]) && (Uti::AnyExists(array_keys($existing), $column[5]) !== false))
            {
                $oldcolumn = Uti::AnyExists(array_keys($existing), $column[5]);

                $this->SaveToLog("Altering table [" . $tablename . "], column [" . $column[0] . "], renaming: name [" . $oldcolumn . "] => [" . $column[0] . "] " .
                    " type [" . $newtype . "] nullability [" . $newnull . "] default [" . $newdefault . "]", true);

                $statement = $this->ColumnCreationStatement($column, false);
                if ($statement !== '')
                    $this->Query("alter table " . $tablename . " change " . $oldcolumn . " " . $statement);

                unset($existing[$oldcolumn]);
            }
            else
            {
                $this->SaveToLog("Altering table [" . $tablename . "], column [" . $column[0] . "], adding: name [" . $column[0] . "] " .
                    " type [" . $newtype . "] nullability [" . $newnull . "] default [" . $newdefault . "]", true);

                $statement = $this->ColumnCreationStatement($column, false);
                if ($statement !== '')
                    $this->Query("alter table " . $tablename . " add " . $statement . " " . $lastcolumn);                   
            }

            $lastcolumn = 'AFTER ' . $column[0];
        }

        if ($allowdropcolumn)
        {
            foreach ($existing as $e)
            {
                $this->SaveToLog("Altering table [" . $tablename . "], column [" . $e['Field'] . "], dropping", true);

                $this->Query("alter table " . $tablename . " drop " . $e['Field']);
            }
        }

        sort($newkeys);
        $newkeys = implode(',',$newkeys);

        if ($oldkeys != $newkeys)
        {
            $this->SaveToLog("Altering table [" . $tablename . "], changing keys [" . $oldkeys . "] => [" . $newkeys . "]", true);

            if ($oldkeys !== '')
                $this->Query("alter table " . $tablename . " drop primary key");
            if ($newkeys !== '')    
                $this->Query("alter table " . $tablename . " add primary key (" . $newkeys . ")");
        }
    }
}

以下外部函数需要解释: ColumnCreationStatement提供了修改/创建表格部分的语句:
private function ColumnCreationStatement(array $columninfo, $includekey = true)
{
    $r = '';

    if ((count($columninfo) > 0) && (preg_match('/^[a-zA-Z0-9_\-]+$/', $columninfo[0])))
    {
        $r .= $columninfo[0];
        if ((count($columninfo) > 1) && (preg_match('/^(int|date|datetime|decimal\([0-9]+,[0-9]+\)|varchar\([0-9]+\)|char\([0-9]+\)|text|tinyint)$/', $columninfo[1])))
            $r .= ' ' . $columninfo[1];
        else
            $r .= ' int';
        if ((count($columninfo) > 2) && is_bool($columninfo[2]))
            $r .= ($columninfo[2] === NOT_NULL ? ' not null' : ' null');
        if ((count($columninfo) > 3) && is_string($columninfo[3]) && ($columninfo[3] !== '') && ($columninfo[1] !== 'text'))
            $r .= " default " . $columninfo[3];
        if ((count($columninfo) > 4) && is_bool($columninfo[4]) && $includekey)
            $r .= ($columninfo[4] === true ? ', primary key(' . $columninfo[0] . ')' : '');
    }

    return $r;
}

TableExists只是验证数据库中是否存在表(使用show tables like)。

Query执行MySql语句(是的:不返回结果;])

SelectAllRows是返回行作为哈希表集合的快捷方式。

SaveToLog很明显。 :-)

Uti :: AnyExists看起来像这样:

public static function AnyExists($haystack, $needles, $separator = ';')
{
    if (!is_array($needles))
        $needles = explode($separator, $needles);

    foreach ($needles as $needle)
    {
        if (array_search($needle, $haystack) !== false)
            return $needle;
    }

    return false;
}

我希望这些都能有所帮助。如果您有任何问题,请在评论中随时提问。:-)


浏览一下代码,这是一个不错的算法,但它并不能真正帮助我解决问题。我已经在php中实现了类似的功能,但在c++中处理这个问题需要更多的时间,并且仍然缺少“查找最接近的版本”和“更新到最新版本”的完整CREATE-Scripts。尽管如此,工作做得很好。 - Sebastian Lange
抱歉,但是需要澄清一下:它确实可以。你需要做的是将你的CREATE脚本转换为数组(函数参数)。但你不需要了解数据库表的状态。或者我们都错过了什么,你的x.9版本与x.5版本不兼容? - Kuba Wyrostek
据我所知,该脚本将表结构更新为 PHP 数组中给定的表结构。这是我的问题的一部分,它很好(这也是我投赞成票的原因),但我仍然需要深入了解我的数据库结构,包括不同的字符集、函数、外键、更新策略和表关系。该脚本扮演了表更新的角色(我认为它在这方面运行良好)。这就是为什么它不能解决完整的数据库更新问题的原因。 - Sebastian Lange

1
听起来这些脚本应该是静态的。你能否包含所有脚本(从版本x.1到x.2,以及从x.2到x.3等),并运行用户需要的特定脚本?

我们可能会有所有的ALTER脚本,但这正是我想避免的。我希望能够自动创建一个ALTER脚本,因为数据库中可能存在错误(我见过太多错误了)。 - Sebastian Lange

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接