fix(dbtool): 导出视图并刷新 db_dump.sql(补 4 个公开视图)

This commit is contained in:
toom1996
2026-09-23 14:07:43 +08:00
parent d68a57009c
commit 762e528334
2 changed files with 254 additions and 12 deletions

View File

@ -1,6 +1,6 @@
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。
//
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
//
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
@ -23,9 +23,13 @@
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
//
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
// 数据、序列当前值。
// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
//
// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。
// 触发器 / 外键 / 注释 / 权限不导出。
//
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
// 公开查询直接报 relation does not exist。
//
// 目标库为空还是已有数据:
//
@ -57,6 +61,7 @@ import (
"fmt"
"log"
"os"
"sort"
"strings"
"time"
@ -112,7 +117,7 @@ func usage() {
用法:
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>] [-clean]
把 dump 出来的 SQL 文件灌入目标库
@ -178,13 +183,17 @@ func runDump(args []string) {
if len(tables) == 0 {
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
}
views, err := listViews(db)
if err != nil {
log.Fatalf("✗ 列举视图失败: %v", err)
}
cols := make(map[string][]column, len(tables))
// ① 结构(-clean 时先输出清理段)
fmt.Fprintln(w, "-- ==================== 结构 ====================")
if *clean {
writeClean(w, tables)
writeClean(w, tables, views)
}
for _, t := range tables {
cs, err := listColumns(db, t)
@ -194,7 +203,11 @@ func runDump(args []string) {
cols[t] = cs
writeCreateTable(w, t, cs)
}
log.Printf("→ 结构:%d 张表", len(tables))
// 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。
for _, v := range views {
writeCreateView(w, v)
}
log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views))
// ② 数据
fmt.Fprintln(w, "-- ==================== 数据 ====================")
@ -306,19 +319,169 @@ func execScript(ctx context.Context, db *sql.DB, script string) error {
})
}
// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。
// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
//
// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。
// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
func writeClean(w *bufio.Writer, tables []string) {
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失")
func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失")
for i := len(views) - 1; i >= 0; i-- {
fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name))
}
for _, t := range tables {
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
}
fmt.Fprintln(w)
}
// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。
type viewDef struct {
Name string
Def string
}
// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。
//
// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报
// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。
func listViews(db *sql.DB) ([]viewDef, error) {
rows, err := db.Query(`
SELECT c.relname, pg_get_viewdef(c.oid, true)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'v'
ORDER BY c.relname`)
if err != nil {
return nil, err
}
defer rows.Close()
defs := map[string]string{}
var names []string
for rows.Next() {
var name, def string
if err := rows.Scan(&name, &def); err != nil {
return nil, err
}
defs[name] = def
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(names) == 0 {
return nil, nil
}
deps, err := listViewDeps(db)
if err != nil {
return nil, err
}
ordered, err := topoSortViews(names, deps)
if err != nil {
return nil, err
}
out := make([]viewDef, 0, len(ordered))
for _, name := range ordered {
// pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。
body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";"))
out = append(out, viewDef{Name: name, Def: body})
}
return out, nil
}
// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。
func listViewDeps(db *sql.DB) (map[string][]string, error) {
rows, err := db.Query(`
SELECT DISTINCT v.relname, d.relname
FROM pg_depend dep
JOIN pg_rewrite rw ON rw.oid = dep.objid
JOIN pg_class v ON v.oid = rw.ev_class
JOIN pg_class d ON d.oid = dep.refobjid
JOIN pg_namespace n ON n.oid = v.relnamespace
WHERE dep.classid = 'pg_rewrite'::regclass
AND dep.refclassid = 'pg_class'::regclass
AND dep.deptype = 'n'
AND v.relkind = 'v' AND d.relkind = 'v'
AND v.oid <> d.oid
AND n.nspname = 'public'`)
if err != nil {
return nil, err
}
defer rows.Close()
deps := map[string][]string{}
for rows.Next() {
var v, d string
if err := rows.Scan(&v, &d); err != nil {
return nil, err
}
deps[v] = append(deps[v], d)
}
return deps, rows.Err()
}
// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。
func topoSortViews(names []string, deps map[string][]string) ([]string, error) {
known := make(map[string]bool, len(names))
indeg := make(map[string]int, len(names))
for _, n := range names {
known[n] = true
indeg[n] = 0
}
adj := map[string][]string{}
for v, ds := range deps {
if !known[v] {
continue
}
for _, d := range ds {
if !known[d] || d == v {
continue
}
adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v
indeg[v]++
}
}
ready := make([]string, 0, len(names))
for _, n := range names {
if indeg[n] == 0 {
ready = append(ready, n)
}
}
sort.Strings(ready)
out := make([]string, 0, len(names))
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
out = append(out, n)
next := append([]string(nil), adj[n]...)
sort.Strings(next)
for _, m := range next {
indeg[m]--
if indeg[m] == 0 {
ready = append(ready, m)
}
}
sort.Strings(ready)
}
if len(out) != len(names) {
return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names))
}
return out, nil
}
// writeCreateView 输出一个视图的 CREATE VIEW。
// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。
func writeCreateView(w *bufio.Writer, v viewDef) {
fmt.Fprintf(w, "CREATE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def)
}
// writeHeader 写文件头与几个会话设置。
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())