Skip to main content

从 reST 文档中提取脚本并按顺序应用它们。

项目描述

版本
2.0
作者

乐乐Gaifax <乐乐@metapensiero >

执照

GPLv3

构建和维护数据库的模式始终是一个挑战。在分布式开发环境中处理中等复杂的数据库时,它可能很快成为一场噩梦。你有新的特性,并在这里和那里修复,这些在开发分支中不断积累。您还需要不时升级几个已经部署的数据库实例。

根据我的经验,想出一个完全自动化的解决方案是非常困难的,因为以下几个原因:

  • 数据库模式的不同版本之间的比较是棘手的

  • 必须保留数据库的实际内容

  • 某些更改需要特定的配方来升级数据

  • 根据定义,任何自动化解决方案都隐藏了一些细节:我需要完全控制,例如能够创建临时表和/或过程

我尝试并自己编写了几种不同的方法来解决问题[ * ],这个包是我最新和最令人满意的努力:它建立在docutilsSphinx之上,具有一个非常好的和好的文档的附带优势整个架构的: 识字的数据库计划

<nav class="contents" id="contents" role="doc-toc">

内容

</nav>

这个怎么运作

该软件包包含两个不同的部分:Sphinx扩展和patchdb命令行工具。

该扩展实现了一个新的ReST指令,能够在文档中嵌入一个脚本:当被sphinx-build工具处理时,所有的脚本都将被收集到一个可配置的外部文件中。

patchdb工具采用该脚本集合并确定需要将哪些脚本应用到某个数据库以及正确的顺序。

它在数据库中创建并维护一个非常简单的表(不出所料地命名为 patchdb),其中记录了它成功执行的每个脚本的最后一个版本,因此它不会重新执行相同的脚本(实际上是它的特定版本)两次。

因此,在开发方面,您只需编写(并记录!)每个部分,当部署当前状态时,您只需分发脚本集合(单个文件,通常为AXONJSONYAML格式,或pickle存档,请参阅下面的存储格式 )到数据库实例所在的端点,并对每个实例执行patchdb 。

脚本

基本的构建块是脚本,用某种语言(目前是PythonSQLShell)编写的任意语句序列,并增加了一些元数据,例如scriptid、可能更长的描述、其修订等。

作为语法的完整示例,请考虑以下内容:

.. patchdb:script:: My first script
   :description: Full example of a script
   :revision: 2
   :depends: Other script@4
   :preceeds: Yet another
   :language: python
   :conditions: python_2_x

   print "Yeah!"

这将引入一个由My first script 全局标识的脚本,用Python编写:这是它的第二个版本,它的执行必须受到限制,以便它发生 Other script的第四修订版执行之后和Yet another之前

语句序列可以指定为指令的内容,可以 从外部文件加载,因此前面的脚本可以写成:

.. patchdb:script:: My first script
   :description: Full example of a script
   :revision: 2
   :depends: Other script@4
   :preceeds: Yet another
   :language: python
   :conditions: python_2_x
   :file: python_script.py

SQL脚本可以由多个语句组成,由一个独立的;;分隔。 标记,如:

.. patchdb:script:: Create and populate

   CREATE TABLE foo (id integer, value varchar(20))
   ;;
   INSERT INTO foo (id, value) VALUES (1, 'bar')

另一个特殊标记是;;INCLUDE:,可用于包含外部文件的内容,比上面的文件选项更灵活。前面的例子可以写成:

.. patchdb:script:: Create and populate

   ;;INCLUDE: create_table.sql
   ;;
   ;;INCLUDE: populate_table.sql

其中两条语句分别从create_table.sqlpopulate_table.sql加载。;;INCLUDE:标记被递归扩展,因此另一种说法是:

.. patchdb:script:: Create and populate

   ;;INCLUDE: create_and_populate.sql

create_and_populate.sql包含:

;;INCLUDE: create_table.sql
;;
;;INCLUDE: populate_table.sql

作为另一个非常有用的具体示例,请考虑需要将现有函数替换为输出参数具有不同签名的函数的情况,例如 PostgreSQL 不允许的情况。然后你可以说:

.. patchdb:script:: Some function
   :revision: 2
   :file: some_function.sql

.. patchdb:script:: Upgrade some function to revision 2
   :depends: Some function@1
   :brings: Some function@2

   DROP FUNCTION some_function(int, OUT int)
   ;;
   ;;INCLUDE: some_function.sql

条件

该示例还显示了条件的用法,允许脚本的多个变体,例如:

.. patchdb:script:: My first script (py3)
   :description: Full example of a script
   :revision: 2
   :depends: Other script@4
   :preceeds: Yet another
   :language: python
   :conditions: python_3_x

   print("Yeah!")

:conditions:选项的值可以是单个段落,包含逗号分隔的条件列表,或者是一个项目符号列表

作为此功能的另一个用例,以下代码段为两个不同的数据库声明了同一个表:

.. patchdb:script:: Simple table (PostgreSQL)
  :language: sql
  :mimetype: text/x-postgresql
  :conditions: postgres
  :file: postgresql/simple.sql

.. patchdb:script:: Simple table (MySQL)
  :language: sql
  :mimetype: text/x-mysql
  :conditions: mysql
  :file: mysql/simple.sql

如您所见,脚本的内容可以方便地存储在外部文件中,并且使用:mimetype:选项指定的特定方言,因此 Pygments 会正确突出显示它。

这样的条件也可以在命令行中任意定义,例如:

.. patchdb:script:: Configure for production
  :language: sql
  :conditions: PRODUCTION

  UPDATE configuration SET is_production = true

然后在这种情况下添加选项--assert PRODUCTION 。

一个条件可以被否定,前置一个它的名字:

.. patchdb:script:: Configure for production
  :language: sql
  :conditions: !PRODUCTION

  UPDATE configuration SET is_production = false

变量

影响脚本效果的另一种方法是使用变量:脚本可能包含一个或多个使用语法{{VARNAME}}对任意变量的引用,必须在应用程序时使用--define VARNAME=VALUE命令行定义选项。或者使用语法{{name=default}}引用可以设置变量的默认值,可以从命令行覆盖。

例如,您可以使用以下脚本:

.. patchdb:script:: Create table and give read-only rights to the web user
   :language: sql

   CREATE TABLE foo (id INTEGER)
   ;;
   GRANT SELECT ON TABLE foo TO {{WEB=www}}
   ;;
   GRANT ALL ON TABLE foo TO {{ADMIN}}

要应用它,您必须指定ADMIN变量的值,例如 --define ADMIN=$USER

变量名必须是一个标识符(即至少一个字母可能后跟字母数字或下划线),而它的值可能包含空格、字母或数字。

如果名称以ENV_ 开头,则在进程环境中查找该值。在以下示例中,用户名来自USER环境变量(必须存在),而密码来自PASSWORD环境条目,如果未设置,则来自指定的默认值:

.. patchdb:script:: Insert a default user name
   :language: sql

   INSERT INTO users (name, password) VALUES ('{{ENV_USER}}', '{{ENV_PASSWORD=password}}')

请注意,您可以使用命令行上的显式--define选项覆盖环境,例如使用--define ENV_PASSWORD=foobar

依赖项

依赖项(即选项 :brings::depends::drops:::preceeds:)可能是包含逗号分隔的脚本 ID 列表的段落,例如:

.. patchdb:script:: Create master table

   CREATE TABLE some_table (id INTEGER PRIMARY KEY, tt_id INTEGER)

.. patchdb:script:: Create target table

   CREATE TABLE target_table (id INTEGER PRIMARY KEY)

.. patchdb:script:: Add foreign key to some_table
   :depends: Create master table, Create target table

   ALTER TABLE some_table
         ADD CONSTRAINT fk_master_target
             FOREIGN KEY (tt_id) REFERENCES target_table (id)

或者,它们可以作为项目符号列表输入,因此上面的最后一个脚本也可以写成:

.. patchdb:script:: Add foreign key to some_table
   :depends:
      - Create master table
      - Create target table

   ALTER TABLE some_table
         ADD CONSTRAINT fk_master_target
             FOREIGN KEY (tt_id) REFERENCES target_table (id)

使用此语法,您可以引用包含逗号的scriptid 。

与这些脚本在文档中出现的顺序无关,第三个脚本只有在前两个成功应用于数据库后才会执行。如您所见,大多数选项都是可选的:默认情况下,:language:sql:revision:1:description:取自标题(即脚本 ID),而 :depends::preceeds:是空的。

仅出于说明目的,可以通过以下方式实现相同的效果:

.. patchdb:script:: Create master table
   :preceeds: Add foreign key to some_table

   CREATE TABLE some_table (id INTEGER PRIMARY KEY, tt_id INTEGER)

.. patchdb:script:: Create target table

   CREATE TABLE target_table (id INTEGER PRIMARY KEY)

.. patchdb:script:: Add foreign key to some_table
   :depends: Create target table

   ALTER TABLE some_table
         ADD CONSTRAINT fk_master_target
             FOREIGN KEY (tt_id) REFERENCES target_table (id)

错误处理

默认情况下, patchdb在无法应用一个脚本时停止。有时您可能希望放宽该规则,例如,在使用其他方法创建的数据库上进行操作时,您无法依靠特定脚本的存在来做出决定。在这种情况下,可以使用选项:onerror: :

.. patchdb:script:: Remove obsoleted tables and functions
   :onerror: ignore

   DROP TABLE foo
   ;;
   DROP FUNCTION initialize_record_foo()

:onerror:设置为ignore时,脚本中的每个语句都会被执行,如果发生错误,它将被忽略,patchdb会继续执行下一个语句。在像 PostgreSQL 和 SQLite 这样的优秀数据库上,即使 DDL 语句也是事务性的,每个语句都在嵌套的子事务中执行,因此后续错误不会破坏正确应用先前语句的效果。

此选项的另一个可能设置是skip:在这种情况下,每当发生错误时,整个脚本的效果都会被撤消并被视为已应用。例如,假设旧版本的SomeProcedure接受一个参数,而新版本需要两个参数,您可以执行以下操作:

.. patchdb:script:: Fix stored procedure signature
   :onerror: skip

   SELECT somecol FROM SomeProcedure(NULL, NULL)
   ;;
   ALTER PROCEDURE SomeProcedure(p_first INTEGER, p_second INTEGER)
   RETURNS (somecol INTEGER) AS
   BEGIN
     somecol = p_first * p_second;
     SUSPEND;
   END

补丁

补丁是一种特殊风格的脚本,它指定了带来删除 依赖项列表。假设上面的示例是数据库的第一个版本,当前版本如下所示:

.. patchdb:script:: Create master table
   :revision: 2

   CREATE TABLE some_table (
     id INTEGER PRIMARY KEY,
     description VARCHAR(80),
     tt_id INTEGER
   )

也就是说,some_table现在又包含一个字段description

我们需要从表的第一个修订版到第二个修订版的升级路径:

.. patchdb:script:: Add a description to the master table
   :depends: Create master table@1
   :brings: Create master table@2

   ALTER TABLE some_table ADD COLUMN description VARCHAR(80)

patchdb检查数据库状态时,它将执行一个另一个。如果脚本Create master table尚未执行(例如在操作新数据库时),它将采用前一个脚本(从头开始创建表的脚本)。否则,如果数据库“包含”脚本的修订版 1(并且不高于 1),它将执行后者,从而提高修订版号。

过时的补丁

这种脚本的另一个特点是它们可以引用不存在的脚本 而不会产生警告或错误。

基本原理是,在数据库演进中,一个给定的脚本可能会被删除,可能会被一些后续补丁替换为不同的脚本。考虑一下您曾经有一张名为customers的表的情况:

.. patchdb:script:: Create table customers
   :revision: 2

   CREATE TABLE customers (
     id SERIAL PRIMARY KEY,
     name VARCHAR(80),
     street_address VARCHAR(80),
     city VARCHAR(80),
     telephone_number VARCHAR(80)
   )

.. patchdb:script:: Add telephone number to customers table
   :depends: Create table customers@1
   :brings: Create table customers@2

   ALTER TABLE customers ADD COLUMN telephone_number VARCHAR(80)

然后需要多个地址,因此您决定将其拆分为两个不同的关系,一个people和一个person_addresses

.. patchdb:script:: Create table persons

   CREATE TABLE persons (
     id SERIAL PRIMARY KEY,
     name VARCHAR(80)
   )

.. patchdb:script:: Create table person_addresses
   :depends: Create table persons

   CREATE TABLE person_addresses (
     id SERIAL PRIMARY KEY,
     person_id INTEGER REFERENCES persons (id),
     street_address VARCHAR(80),
     city VARCHAR(80),
     telephone_number VARCHAR(80)
   )

.. patchdb:script:: Migrate from customers to persons and person_addresses
   :depends:
      - Create table customers@2
      - Create table persons
      - Create table person_addresses
   :drops:
      - Create table customers
      - Add telephone number to customers table

   INSERT INTO persons (id, name) SELECT id, name FROM customers
   ;;
   INSERT INTO person_addresses (person_id, street_address, city, telephone_number)
     SELECT id, street_address, city, telephone_number
     FROM customers
   ;;
   DROP TABLE customers

那时,引入原始客户表的脚本从文档中消失了,但您很可能希望保留迁移补丁一段时间,至少在您确定所有生产数据库都已升级之前。

始终运行脚本

每次执行 patchdb时,都会应用另一种脚本变体。这种类型可用于在patchdb会话开始或结束时执行任意操作:

.. patchdb:script:: Say hello
   :language: python
   :always: first

   print("Hello!")

.. patchdb:script:: Say goodbye
   :language: python
   :always: last

   print("Goodbye!")

假数据域

作为使用这种脚本的一个特例,以下示例说明了 MySQL数据域的近似值,但缺少它们:

.. patchdb:script:: Define data domains (MySQL)
   :language: sql
   :mimetype: text/x-mysql
   :conditions: mysql
   :always: first

   CREATE DOMAIN bigint_t bigint
   ;;
   CREATE DOMAIN `Boolean_t` char(1)

.. patchdb:script:: Create some table (MySQL)
   :language: sql
   :mimetype: text/x-mysql
   :conditions: mysql
   :always: first

   CREATE TABLE `some_table` (
       `ID` bigint_t NOT NULL,
     , `FLAG` `Boolean_t`

     , PRIMARY KEY (`ID`)
   )

占位符

另一个特点是数据库的定义,即实际定义其模式的脚本的集合,可以在多个 Sphinx 环境中拆分:用例是当您有一个由多个模块组成的复杂应用程序时,每个模块需要自己的一组数据库对象。

当一个脚本有一个空的主体时,它被认为是一个占位符:它永远不会被应用,而是它在数据库中的存在将被断言。这样,一个 Sphinx 环境可以包含以下脚本:

.. patchdb:script:: Create table a

   CREATE TABLE a (
       id INTEGER NOT NULL PRIMARY KEY
     , value INTEGER
   )

另一个文档集可以扩展它:

.. patchdb:script:: Create table a
   :description: Place holder

.. patchdb:script:: Create unique index on value
   :depends: Create table a

   CREATE UNIQUE INDEX on_value ON a (value)

第二组只能在前一组之后应用。

用法

收集补丁

要使用它,首先您必须在 Sphinx 环境中注册扩展,将包的全名添加到文件conf.py的扩展列表中,例如:

# Add any Sphinx extension module names here, as strings.
extensions = ['metapensiero.sphinx.patchdb']

另一个需要定制的地方是磁盘脚本存储的位置,即包含每个找到的脚本信息的文件的路径:这与文档本身是分开的,因为您可能会将它部署在生产服务器上更新他们的数据库。

该位置可以设置在与上面相同的conf.py中,例如:

# Location of the external storage
patchdb_storage = '…/dbname.json'

否则,您可以使用sphinx-build命令的-D选项设置它,以便您可以轻松地与Makefile中的其他规则共享其定义。我通常将以下片段放在由sphinx-quickstart创建的Makefile的开头:

TOPDIR ?= ..
STORAGE ?= $(TOPDIR)/database.json

SPHINXOPTS = -D patchdb_storage=$(STORAGE)

此时,执行通常的make html将更新脚本存档:该文件包含更新本地或远程数据库所需的所有内容;换句话说,不需要运行 Sphinx(甚至安装它)更新数据库。

更新数据库

硬币的另一面由patchdb工具管理,它消化脚本存档并能够确定哪些脚本尚未应用,并最终以正确的顺序执行。

当您的数据库已经存在并且您刚刚开始使用patchdb时,您可能需要使用以下命令强制初始状态:

patchdb --assume-already-applied --postgresql "dbname=test" database.json

这只会更新patchdb表,注册所有缺失脚本的当前版本,而不执行它们。

您可以检查将要执行的操作,即获取尚未应用的补丁列表,使用如下命令:

patchdb --dry-run --postgresql "dbname=test" database.json

database.json存档可以发送到生产机器(在某些情况下,我将它放在存储库的 生产分支中并使用版本控制工具来更新远程机器,在其他情况下我只是使用基于scprsync的解决方案)。另一种方法是将其包含在某个包中,然后使用语法some.package:path/database.json

脚本甚至可能来自几个不同的档案(参见上面的占位符):

patchdb --postgresql "dbname=test" app.db.base:pdb.json app.db.auth:pdb.json

自动备份

特别是在开发模式下,我发现有一种简单的方法可以返回到以前的状态并重试升级,以测试不同的升级路径或修复新补丁中的愚蠢拼写错误。

由于 2.3 版patchdb有一个新选项--backups-dir,它控制自动备份工具:在每次执行时,在继续应用丢失的补丁之前, 无论是否有任何补丁,默认情况下都会备份当前数据库和保留这些快照的简单索引。

该选项默认为系统范围的临时目录(通常是POSIX 系统上的/tmp):如果您不需要自动备份(合理的生产系统应该有不同的方法来获取此类快照),请指定None作为参数选项。

使用patchdb-states工具,您可以获得可用快照的列表,或恢复任何以前的快照:

$ patchdb-states list
[lun 18 apr 2016 08:24:48 CEST] bc5c5527ece6f11da529858d5ac735a8 <create first table@1>
[lun 18 apr 2016 10:27:11 CEST] 693fd245ad9e5f4de0e79549255fbd6e <update first table@1>

$ patchdb-states restore --sqlite /tmp/quicktest.sqlite 693fd245ad9e5f4de0e79549255fbd6e
[I] Creating patchdb table
[I] Restored SQLite database /tmp/quicktest.sqlite from /tmp/693fd245ad9e5f4de0e79549255fbd6e

$ patchdb-states clean -k 1
Removed /tmp/bc5c5527ece6f11da529858d5ac735a8
Kept most recent 1 snapshot

支持的数据库

从版本 2 开始,patchdb可以对以下数据库进行操作:

  • 火鸟(需要fdb

  • MySQL(默认需要PyMySQL,请参阅选项--driver以选择其他选项)

  • PostgreSQL(需要psycopg2

  • SQLite(使用标准库sqlite3模块)

示例开发 Makefile 片段

以下是我通常放入外部Makefile的片段:

export TOPDIR := $(CURDIR)
DBHOST := localhost
DBPORT := 5432
DBNAME := dbname
DROPDB := dropdb --host=$(DBHOST) --port=$(DBPORT) --if-exists
CREATEDB := createdb --host=$(DBHOST) --port=$(DBPORT) --encoding=UTF8
STORAGE := $(TOPDIR)/$(DBNAME).json
DSN := host=$(DBHOST) port=$(DBPORT) dbname=$(DBNAME)
PUP := $(PATCHDB) --postgresql="$(DSN)" --log-file=$(DBNAME).log $(STORAGE)

# Build the Sphinx documentation
doc:
        $(MAKE) -C doc STORAGE=$(STORAGE) html

$(STORAGE): doc

# Show what is missing
missing-patches: $(STORAGE)
        $(PUP) --dry-run

# Upgrade the database to the latest revision
database: $(STORAGE)
        $(PUP)

# Remove current database and start from scratch
scratch-database:
        $(DROPDB) $(DBNAME)
        $(CREATEDB) $(DBNAME)
        $(MAKE) database

快速示例

以下 shell 会话说明了基础知识:

python3 -m venv patchdb-session
cd patchdb-session
source bin/activate
pip install metapensiero.sphinx.patchdb[dev]
yes n | sphinx-quickstart --project PatchDB-Quick-Test \
                          --author JohnDoe \
                          -v 1 --release 1 \
                          --language en \
                          --master index --suffix .rst \
                          --makefile --no-batchfile \
                          pdb-qt
cd pdb-qt
echo "extensions = ['metapensiero.sphinx.patchdb']" >> conf.py
echo "patchdb_storage = 'pdb-qt.json'" >> conf.py
echo "
.. patchdb:script:: My first script
   :depends: Yet another
   :language: python

   print('world!')

.. patchdb:script:: Yet another
   :language: python

   print('Hello')
" >> index.rst
make html
patchdb --sqlite /tmp/pdb-qt.sqlite --dry-run pdb-qt.json

最后你应该得到类似的东西:

Would apply script "yet another@1"
Would apply script "my first script@1"
100% (2 of 2) |########################################| Elapsed Time: 0:00:00 Time: 0:00:00

删除--dry-run

$ patchdb --sqlite /tmp/pdb-qt.sqlite pdb-qt.json
Hello
world!

Done, applied 2 scripts
100% (2 of 2) |########################################| Elapsed Time: 0:00:00 Time: 0:00:00

再次:

$ patchdb --sqlite /tmp/pdb-qt.sqlite pdb-qt.json
Done, applied 0 scripts

变化[ 1 ]

3.7 (2019-12-20)

  • 当补丁将脚本带到高于其当前版本的修订时,捕获依赖错误

3.6 (2019-12-19)

  • 现在 Python 脚本接收到对当前补丁管理器的引用,因此它们能够执行存储中已经存在的任意脚本

3.5 (2019-06-21)

  • 现在,当补丁带来未知脚本时,这是一个硬错误:当它出现时,它要么已过时,要么某处有错字

3.4 (2019-03-31)

  • 发布过程中没有新的小故障

3.3 (2019-03-31)

  • 解除对sqlparse版本的限制,允许使用最近发布的0.3.0。

3.2 (2018-03-03)

3.1 (2017-11-30)

  • 修复确定补丁脚本是否仍然有效的逻辑故障

  • 使用启蒙显示进度条:-- verbose选项不见了,现在是默认模式

3.0 (2017-11-06)

  • 仅限 Python 3 [ 2 ]

  • 新的执行逻辑,希望在多个非平凡待定迁移的情况下修复循环依赖错误

项目详情