fix(dbtool): 导出视图并刷新 db_dump.sql(补 4 个公开视图)
This commit is contained in:
@ -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())
|
||||
|
||||
81
db_dump.sql
81
db_dump.sql
@ -1,5 +1,5 @@
|
||||
-- dbtool 导出:127.0.0.1:5432/fashion
|
||||
-- 生成时间:2026-09-23T13:49:34+08:00
|
||||
-- 生成时间:2026-09-23T14:06:13+08:00
|
||||
--
|
||||
-- 导入(二选一):
|
||||
-- dbtool import -in <本文件>
|
||||
@ -171,6 +171,85 @@ CREATE TABLE IF NOT EXISTS public."users" (
|
||||
"tier" character varying(16)
|
||||
);
|
||||
|
||||
CREATE VIEW public."public_brand_runway_images" AS
|
||||
SELECT i.id,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
i.is_deleted,
|
||||
i.image,
|
||||
i.runway_id,
|
||||
i.brand_id,
|
||||
i.name,
|
||||
i.sort_order,
|
||||
i.look_index,
|
||||
i.is_detail,
|
||||
i.phash,
|
||||
i.is_duplicate,
|
||||
i.dup_of
|
||||
FROM brand_runway_images i
|
||||
JOIN brand_runways r ON r.id = i.runway_id
|
||||
WHERE i.is_deleted = 0 AND r.status::text = 'published'::text AND r.is_deleted = 0;
|
||||
|
||||
CREATE VIEW public."public_brand_runways" AS
|
||||
SELECT brand_runways.id,
|
||||
brand_runways.title_en,
|
||||
brand_runways.title_cn,
|
||||
brand_runways.description_en,
|
||||
brand_runways.description_cn,
|
||||
brand_runways.created_at,
|
||||
brand_runways.updated_at,
|
||||
brand_runways.is_deleted,
|
||||
brand_runways.image_count,
|
||||
brand_runways.brand_id,
|
||||
brand_runways.year,
|
||||
brand_runways.cover,
|
||||
brand_runways.collection_type,
|
||||
brand_runways.season,
|
||||
brand_runways.season_code,
|
||||
brand_runways.status,
|
||||
brand_runways.job_id,
|
||||
brand_runways.reviewer,
|
||||
brand_runways.reject_reason
|
||||
FROM brand_runways
|
||||
WHERE brand_runways.status::text = 'published'::text AND brand_runways.is_deleted = 0;
|
||||
|
||||
CREATE VIEW public."public_street_snap_images" AS
|
||||
SELECT i.id,
|
||||
i.snap_id,
|
||||
i.image,
|
||||
i.name,
|
||||
i.sort_order,
|
||||
i.is_deleted,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
i.phash,
|
||||
i.is_duplicate,
|
||||
i.dup_of,
|
||||
i.is_detail,
|
||||
i.parent_image_id
|
||||
FROM street_snap_images i
|
||||
JOIN street_snaps s ON s.id = i.snap_id
|
||||
WHERE i.is_deleted = 0 AND s.status::text = 'published'::text AND s.is_deleted = 0;
|
||||
|
||||
CREATE VIEW public."public_street_snaps" AS
|
||||
SELECT street_snaps.id,
|
||||
street_snaps.title,
|
||||
street_snaps.year,
|
||||
street_snaps.city,
|
||||
street_snaps.cover,
|
||||
street_snaps.image_count,
|
||||
street_snaps.is_deleted,
|
||||
street_snaps.created_at,
|
||||
street_snaps.updated_at,
|
||||
street_snaps.source,
|
||||
street_snaps.source_url,
|
||||
street_snaps.status,
|
||||
street_snaps.job_id,
|
||||
street_snaps.reviewer,
|
||||
street_snaps.reject_reason
|
||||
FROM street_snaps
|
||||
WHERE street_snaps.status::text = 'published'::text AND street_snaps.is_deleted = 0;
|
||||
|
||||
-- ==================== 数据 ====================
|
||||
INSERT INTO public."brand_runway_images" ("id", "created_at", "updated_at", "is_deleted", "image", "runway_id", "brand_id", "name", "sort_order", "look_index", "is_detail", "phash", "is_duplicate", "dup_of") VALUES
|
||||
(E'989', E'1789137485', E'1789137485', E'0', E'runway/3b9b7e07916b454ba93c56b4d2edc81614101e3c.jpg', E'9', E'7', E'Look 1', E'1', E'1', E'0', NULL, E'0', E''),
|
||||
|
||||
Reference in New Issue
Block a user