pg-schema-dump
@pureprofile/pg-schema-dump 将PostgreSQL模式转储为许多小的、单独命名的模式 .sql 文件,因此模式变成 文本可比较和可变.
每个对象都被写入一个具有确定名称和规范化SQL的文件,而不是单个转储。然后可以将两个数据库与一个数据库进行比较 diff 或者提交git来跟踪模式漂移。转储也可以是 恢复 到空数据库中,这使得已提交的转储可用作测试fixture。
写入属于单个表的对象——它的索引、触发器、主键、唯一和检查约束,以及它的序列的所有权 到那个表的文件中,因为它们都不能从其他地方引用。外键在每个表中获取一个文件,因为它们必须等到每个表都存在。
为什么
pg_dump 输出的排序和格式化方式使得区分两个模式非常痛苦。这个工具产生 相同模式的相同字节输出:收集器在SQL中对行排序,列排序是确定性的,SQL是规范化的。结果是一个目录,您可以 diff -r,签入git,或者反馈到一个空的数据库。
安装
全球CLI:
npm install -g @pureprofile/pg-schema-dump
作为图书馆:
npm install @pureprofile/pg-schema-dump
# or: pnpm add @pureprofile/pg-schema-dump
CLI使用
pg-schema-dump --url <connection-string> [--out <dir>] [scope options]
| 选项 | 要求 | 描述 |
|---|---|---|
--url | 是 | PostgreSQL要转储的数据库连接字符串。 |
--out | 否 | 要将转储写入的目录。请参阅下面的默认值。 |
--scope-file | 否 | JSON清单的路径,其中列出了要包含的内容。看到 范围. |
--include-schema | 否 | 包括一个完整的模式。可重复的。 |
--include-table | 否 | 包括一个 schema.table. 可重复的。 |
--include-function | 否 | 包括一个 schema.function 明确。可重复的。 |
当 --out ,则输出为 pg-schema-dump/<NODE_ENV>/<dbName> ( NODE_ENV 默认为 development 和 dbName 从连接中读取)。
如果没有作用域选项,整个数据库将被转储。
示例
将数据库转储到一个显式目录中:
pg-schema-dump \
--url postgres://user:[email protected]:5432/mydb \
--out ./schema/mydb
使用默认输出位置转储(./pg-schema-dump/development/mydb):
pg-schema-dump --url postgres://user:pass@localhost/mydb
比较两个数据库的模式:
pg-schema-dump --url postgres://user:pass@host-a/app --out ./a
pg-schema-dump --url postgres://user:pass@host-b/app --out ./b
diff -r ./a ./b
范围
转储整个遗留数据库可能会产生数千个文件,其中大多数都无关紧要。作用域将转储缩小到您真正关心的表。预期的工作流是 提交清单 你故意编辑:
{
"schemas": ["billing", "archive"],
"tables": ["public.orders", "public.customers"],
"functions": []
}
pg-schema-dump --url "$DB_URL" --out ./db/schema --scope-file ./db/table-scope.json
作用域是一个列表,而不是起点——它是 不 通过遵循外键展开,以便在源数据库更改时保持转储的可再现性。计算出一次需要的闭包(递归查询) pg_constraint.confrelid 它),提交结果,并在有新的东西需要一个表时扩展它。
什么作用域拉在你命名的表旁边:
- 序列 由范围内表拥有,由其列默认值之一调用,或由
nextval()在作用域内函数的函数体中。第三条路径不是可选的:仅从触发器主体到达的序列对目录的依赖关系图是不可见的,忽略它会导致触发触发器的第一次插入失败,而不是恢复失败。 - 功能 可以从范围内的表中访问——列默认值、CHECK约束、表达式索引、触发器和重写规则主体——然后递归地访问这些函数调用的函数。加上任何东西
functions. 不是模式中的每个函数,这在大型数据库上是一百个文件和几百个文件的区别。 - 索引、触发器和约束 作用域内表的。
- 的观点 只有当他们读到的所有东西都在范围关系中 和 函数,因为调用丢失的函数的视图无法创建,就像读取丢失的表一样。一个视图纯粹是由它的依赖关系来判断的,所以一个视图存在于一个你从未命名过的模式中,如果它读取的所有内容都在作用域中,它仍然会被丢弃。
- 模式 对于包含的所有内容,因此还原有地方可以放置它。
目标表超出作用域的外键为 省略而不是发出,因为指向转储不包含的表的键无法恢复。每一个遗漏都被记录下来:
scope: dropped FK public.orders.pricing_rule_id -> pricing.rule (target out of scope)
scope: excluded view public.order_summary (depends on out-of-scope public.shipment)
当你扩大视野时,阅读日志——它会告诉你你还错过了什么。
扩展所拥有的对象总是被排除在外,因为 CREATE EXTENSION 重新创建它们。
编程式的使用
这个包导出一个类, PgClient.
import { PgClient } from '@pureprofile/pg-schema-dump';
// from a connection string...
const client = new PgClient('postgres://user:pass@localhost:5432/mydb');
// ...or from a pg.ClientConfig
const client2 = new PgClient({
host: 'localhost',
port: 5432,
user: 'user',
password: 'pass',
database: 'mydb',
});
await client.dumpSchema({ out: './schema/mydb' });
连接 不 保持开放:每个 query() 连接、运行和断开连接。 dumpSchema 是异常—它打开一个连接,并行运行所有收集器,然后关闭它。
构造函数的选择
new PgClient(config, {
logger, // a console-like logger, or `null` to silence all output
skipSchemas, // extra schemas to skip (pg_catalog & information_schema are always skipped)
skipFunctions, // function names to skip
skipExtensions, // extension names to skip, e.g. one unavailable in your target image
scope, // { includeSchemas, includeTables, includeFunctions } — see Scoping
});
多么糟糕的报告
dumpSchema 解析为 DumpOmissions 描述了垃圾场留下的东西。
import { PgClient } from '@pureprofile/pg-schema-dump';
import type { DumpOmissions } from '@pureprofile/pg-schema-dump';
const client = new PgClient('postgres://user:pass@localhost/mydb', {
scope: { includeTables: ['public.orders'] },
});
const omissions: DumpOmissions = await client.dumpSchema({ out: './schema/mydb' });
for (const fk of omissions.droppedForeignKeys) {
// { schema, table, name, target } — `target` is the out-of-scope 'schema.table' it pointed at
console.warn(`dropped ${fk.schema}.${fk.table}.${fk.name} -> ${fk.target}`);
}
for (const view of omissions.excludedViews) {
// { view, cause } — `cause` is a 'schema.name' the view reads that the dump does not contain
console.warn(`excluded ${view.view} (needs ${view.cause})`);
}
阅读这些文章时需要注意两点:
cause是缺少依赖,不一定是问题的根源。 不包括一个 视图排除在它上面构建的任何东西,以这种方式丢弃的视图报告被排除的东西 视图,它读取-所以跟随一个链可能需要多个步骤。droppedForeignKeys对于未作用域转储始终为空,但excludedViews不必是: 中读取关系的视图skipSchemas基于相同的理由,模式被排除在外,范围或否 范围。还要注意的是,警告日志仅在作用域处于活动状态时才写入,因此是无作用域转储 在这里,没有其他地方报道过这样的观点。
这些信息会被返回并被记录下来。 logger: null 是否支持选项和转储
悄悄丢弃外键或视图是后来发现成本最高的失败
因此,调用者在进行自己的报告时会得到程序化的记录,而不必刮去日志。
恢复转储
restoreSchema 读取转储目录并将其重放到连接的数据库中。它打开了自己的
无论是成功还是失败,都是如此,所以它不需要周围的环境
connect() / end():
const client = new PgClient('postgres://user:pass@localhost/fresh_db');
await client.restoreSchema({ src: './schema/mydb' });
文件的应用顺序是每个依赖项都被先前的文件满足,所以转储在一次传递中重播:
| 订单 | 前缀 | 笔记 |
|---|---|---|
| 1 | extension. | |
| 2 | schema. | |
| 3 | type. | |
| 4 | sequence. | 所以一张桌子 default nextval(...) 解决了 |
| 5 | function. | 应用与 check_function_bodies 从 |
| 6 | table. | 列、约束、序列所有权、索引和触发器 |
| 7 | fk. | 在每个表存在之后 |
| 8 | view. | 可以引用任何表或函数吗 |
两个结果值得了解:
- 函数在表之前恢复下,
check_function_bodies = off,所以函数体可以引用一个还不存在的表。这就是为什么可以安全地将触发器和表达式索引合并到表文件中。 - 在添加任何外键之前创建每个表,所以是外键 周期 表间恢复无需特殊处理。
失败的文件不是致命的:它会转到队列的后面,然后再尝试一次,只要队列来了就再试一次——对于链式视图来说足够了,其中一个视图从另一个尚未创建的视图中进行选择。只有当队列的整个循环完成且没有应用任何内容时,恢复才会放弃,因为此时数据库没有改变,而另一个循环将重复相同的失败。错误名称 每一个 文件因自身原因未应用:
restoreSchema: 2 file(s) could not be applied:
- table.public.thing.sql: relation "some_seq" does not exist
- view.public.other.sql: column reference "x" is ambiguous
数据库生命周期助手
脚本测试数据库或恢复目标时有用:
await client.ensureEmptyDb('mydb_test'); // drop if exists, create, switch to it
await client.databaseExists('mydb_test');
await client.createDatabase('mydb_test');
await client.dropDatabase('mydb_test');
await client.switchDatabase('other_db');
await client.truncateTables('mydb_test'); // switch to it, then TRUNCATE ... CASCADE every non-skipped table
注意 truncateTables 将客户端切换到 db 先把它留在那里,同样的方法
ensureEmptyDb 它不是对其他数据库的只读操作。
输出结构
名称冲突通过追加来消除重复 .v2, .v3,……
| 前缀 | 对象 | 例子 |
|---|---|---|
extension. | 扩展 | extension.uuid-ossp.sql |
schema. | 模式 | schema.public.sql |
type. | 枚举类型 | type.public.mood.sql |
sequence. | 序列 | sequence.public.users_id_seq.sql |
function. | 功能 | function.public.my_func.sql |
table. | 一张桌子和它自己的部件 | table.public.users.sql |
fk. | 一个表的外键 | fk.public.orders.sql |
view. | 的观点 | view.public.active_users.sql |
一、 table.*.sql 文件保存了 CREATE TABLE 列加上命名主键、唯一、检查和排除约束 ALTER SEQUENCE ... OWNED BY 对于它拥有的任何序列,然后是它的索引,然后是它的触发器。它作为一个多语句脚本重播。
约束定义来自 pg_get_constraintdef,因此组合主键、多列外键和检查约束都是精确复制的。
nextval(...) 列默认值逐字发出,而不是折叠为 serial/bigserial. 这种简写使得Postgres自动创建与转储的序列相冲突的第二个序列,并且它只匹配搜索路径中的表,因为序列名称在其他地方是模式限定的。
限制
在采用该工具之前值得阅读—使用以下任何一种方式的模式都将转储或恢复 不完全。
当前未收集:物化视图、分区表(relkind = 'p'), GENERATED ... AS IDENTITY 列、域/组合/范围类型(仅限枚举)和行数据—转储仅限模式,因此fixture需要自己的种子脚本。
触发器仅针对普通表收集,因为触发器被写入其表的文件中,而其他表没有。所以一个 INSTEAD OF 视图上的触发器是 不 扔掉了。它是被故意跳过的,而不是被收集起来然后扔到地板上,但是依赖于它的模式将在没有它的情况下恢复。
不处理包含文字点的标识符。 匹配一个限定名,并以键值为 schema || '.' || name -在作用域谓词中,在清单项中,以及在将索引,触发器和约束附加到其表的内部分组中。只有当标识符本身不包含点时,这是明确的,这是Postgres允许的。CREATE SCHEMA "a.b"). 模式 a.b 表格 c 和模式 a 表格 b.c 对于所有表来说都是不可区分的,因此这样的模式可能会混淆作用域或将对象附加到错误的表。清单验证拒绝包含多个点的条目,这样可以避免常见的错别字,但这不是一个修复方法。发出的DDL不受影响——每个标识符在发出时都被独立引用。
一个恢复顺序形状不能通过桶的等级来解决,它将失败而不是无声地出错 签名 接受表的复合行类型(CREATE FUNCTION f(public.t)),其中同一表还携带CHECK约束或表达式索引调用 f. 函数首先恢复,和 check_function_bodies = off 只放松体验证—签名仍然需要行类型,所以 f 之前无法创建 t. 但 t的文件携带依赖索引,该索引以前不能创建 f表文件是单个的多语句查询,它会回滚整个表文件。谁也不能先走。打破它意味着将依赖对象从表文件中分离出来,这牺牲了合并所带来的文件计数减少,因此在真正的模式需要它之前,它一直没有得到解决。恢复停止并命名这两个文件。
开发
本项目使用 规划 并要求 节点24 (见 .nvmrc)。
pnpm install
pnpm build # rimraf ./dist && tsc
pnpm check # ultracite (oxlint + oxfmt) — lint/format check
pnpm fix # ultracite — apply lint/format fixes
pnpm test:unit # unit project only (no database required)
pnpm test:e2e # e2e project only (requires Docker)
pnpm test # both projects
pnpm test:coverage # both projects, with coverage over ./src
测试
- 单位 测试(
tests/unit/)是纯的,不需要数据库。 - E2E 测试(
tests/e2e/)使用 Testcontainers 旋转一个短暂的postgres:16-alpine容器,所以 Docker必须正在运行 本地。在CI,ubuntu-latest船舶Docker,所以不需要额外的设置。要对现有数据库而不是容器运行e2e,请设置TEST_DB_HOST(可选TEST_DB_PORT/TEST_DB_USER/TEST_DB_PASSWORD)。
为整个e2e项目启动一个容器,并且项目运行单分叉和不分叉 文件并行性,所以e2e测试共享一个PostgreSQL实例,而不是每个测试获得一个。
覆盖范围是封闭的。 vitest.config.ts 对行设置90%的阈值,
语句和函数,以及CI运行 pnpm test:coverage,因此,如果低于它,则构建失败。
阈值是基于聚合,而不是每个文件(perFile: false, src/bin.ts 排除和
没有分支阈值),所以一个小的未经测试的加法仍然可以传递总数——将90%作为
它是地板,不作为标准的变化。在推送之前在本地运行它。
Git钩子
lefthook 是通过 prepare 脚本并运行:
- 未雨绸缪 - - - - - -
pnpm fix(ultracite)在分级文件上,重新分级修复。 - pre-push - - - - - -
pnpm build.
CI运行install, lint (pnpm check),构建,以及覆盖的完整测试套件,按此顺序,对每个推送和拉取请求 main.
释放
发布是自动化的 release-please:合并到 main 他写道:“打开一个发布PR,并将该PR合并到版本中 CHANGELOG.md、标签 vX.Y.Z,创建GitHub Release并发布到npm。
因为版本是从提交消息派生的, 每次提交都必须是 传统的提交 -不符合要求的信息永远不会触发释放。合并是唯一的合并方式,合并的主体是PR标题,所以在实践中 公关标题 重要的是;一个 PR Title Check在每个pull请求上验证它。
看到 文档/ release-please.md 对于整个过程,类型到碰撞表,以及故障排除。