This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 验证 posts 表的约束与索引是否按预期生效。
|
||||
*
|
||||
* 这里直接对数据库做"坏写入"测试,确认约束真的能拦住非法数据——
|
||||
* 只检查迁移文件容易漏掉约束静默失效的情况。
|
||||
*
|
||||
* 用法:node scripts/db-verify-schema.mjs
|
||||
*/
|
||||
|
||||
import pg from "pg";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
const client = new pg.Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: /localhost|127\.0\.0\.1/.test(process.env.DATABASE_URL ?? "")
|
||||
? undefined
|
||||
: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
function check(name, condition, detail = "") {
|
||||
if (condition) {
|
||||
pass++;
|
||||
console.log(` [PASS] ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` [FAIL] ${name} ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 期望插入失败(被约束拦截),返回错误信息 */
|
||||
async function expectReject(label, sql, params) {
|
||||
try {
|
||||
await client.query(sql, params);
|
||||
check(label, false, "← 竟然插入成功了,约束未生效");
|
||||
// 清理掉误插入的数据
|
||||
await client.query("delete from posts where slug like 'zz-test-%'");
|
||||
} catch (error) {
|
||||
check(label, true);
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
|
||||
console.log("── 列结构 ────────────────────────────");
|
||||
const cols = await client.query(
|
||||
`select column_name, data_type, is_nullable, column_default
|
||||
from information_schema.columns where table_name='posts' order by ordinal_position`,
|
||||
);
|
||||
const colNames = cols.rows.map((r) => r.column_name);
|
||||
console.log(` 列 (${colNames.length}): ${colNames.join(", ")}`);
|
||||
|
||||
check("包含 slug 列", colNames.includes("slug"));
|
||||
check("包含 status 列", colNames.includes("status"));
|
||||
check("包含 tags 列", colNames.includes("tags"));
|
||||
check("包含 published_at 列", colNames.includes("published_at"));
|
||||
check("包含 reading_minutes 列", colNames.includes("reading_minutes"));
|
||||
|
||||
const tagsCol = cols.rows.find((r) => r.column_name === "tags");
|
||||
check("tags 为数组类型", tagsCol?.data_type === "ARRAY", `got ${tagsCol?.data_type}`);
|
||||
|
||||
console.log("\n── 索引 ──────────────────────────────");
|
||||
const idx = await client.query(
|
||||
`select indexname, indexdef from pg_indexes where tablename='posts' order by indexname`,
|
||||
);
|
||||
for (const row of idx.rows) {
|
||||
console.log(` - ${row.indexname}`);
|
||||
}
|
||||
const idxNames = idx.rows.map((r) => r.indexname);
|
||||
check("slug 唯一索引存在", idxNames.includes("posts_slug_unique_idx"));
|
||||
check("状态+发布时间索引存在", idxNames.includes("posts_status_published_at_idx"));
|
||||
check("标签 GIN 索引存在", idxNames.includes("posts_tags_gin_idx"));
|
||||
|
||||
const ginDef = idx.rows.find((r) => r.indexname === "posts_tags_gin_idx")?.indexdef ?? "";
|
||||
check("标签索引使用 gin", /using gin/i.test(ginDef), ginDef);
|
||||
|
||||
console.log("\n── 约束 ──────────────────────────────");
|
||||
const cons = await client.query(
|
||||
`select conname, pg_get_constraintdef(oid) as def
|
||||
from pg_constraint where conrelid = 'posts'::regclass order by conname`,
|
||||
);
|
||||
for (const row of cons.rows) {
|
||||
console.log(` - ${row.conname}: ${row.def}`);
|
||||
}
|
||||
const conNames = cons.rows.map((r) => r.conname);
|
||||
check("status CHECK 约束存在", conNames.includes("posts_status_check"));
|
||||
check("slug 格式 CHECK 约束存在", conNames.includes("posts_slug_format_check"));
|
||||
|
||||
console.log("\n── 约束实际拦截能力(坏写入测试)──");
|
||||
await expectReject(
|
||||
"拒绝非法 status",
|
||||
`insert into posts (slug,title,summary,content,status) values ($1,$2,$3,$4,$5)`,
|
||||
["zz-test-badstatus", "t", "s", "c", "archived"],
|
||||
);
|
||||
await expectReject(
|
||||
"拒绝非法 slug(大写/下划线)",
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["ZZ_Test_Slug", "t", "s", "c"],
|
||||
);
|
||||
|
||||
// 唯一约束需要先插入一行,再插入同样的 slug 才能验证
|
||||
await client.query(
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["zz-test-dup", "t", "s", "c"],
|
||||
);
|
||||
await expectReject(
|
||||
"拒绝重复 slug",
|
||||
`insert into posts (slug,title,summary,content) values ($1,$2,$3,$4)`,
|
||||
["zz-test-dup", "t", "s", "c"],
|
||||
);
|
||||
|
||||
console.log("\n── 正常写入与数组读写 ───────────────");
|
||||
try {
|
||||
const inserted = await client.query(
|
||||
`insert into posts (slug,title,summary,content,tags,status,published_at,reading_minutes)
|
||||
values ($1,$2,$3,$4,$5,$6,now(),$7) returning id, tags, status`,
|
||||
[
|
||||
"zz-test-ok",
|
||||
"正常文章",
|
||||
"摘要",
|
||||
"正文",
|
||||
["Next.js", "数据库"],
|
||||
"published",
|
||||
3,
|
||||
],
|
||||
);
|
||||
check("合法数据可写入", inserted.rowCount === 1);
|
||||
check(
|
||||
"tags 数组正确回读",
|
||||
Array.isArray(inserted.rows[0].tags) && inserted.rows[0].tags.length === 2,
|
||||
JSON.stringify(inserted.rows[0].tags),
|
||||
);
|
||||
|
||||
// 验证 GIN 索引可用的标签查询语法
|
||||
const byTag = await client.query(
|
||||
`select count(*)::int as n from posts where tags @> ARRAY[$1]::text[]`,
|
||||
["Next.js"],
|
||||
);
|
||||
check("按标签包含查询可用", byTag.rows[0].n >= 1);
|
||||
|
||||
const anyTag = await client.query(
|
||||
`select count(*)::int as n from posts where $1 = any(tags)`,
|
||||
["数据库"],
|
||||
);
|
||||
check("按标签 ANY 查询可用", anyTag.rows[0].n >= 1);
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
await client.query("delete from posts where slug like 'zz-test-%'");
|
||||
}
|
||||
|
||||
const remaining = await client.query("select count(*)::int as n from posts");
|
||||
console.log(`\n清理后剩余行数: ${remaining.rows[0].n}`);
|
||||
|
||||
console.log("\n================================");
|
||||
console.log(` 通过 ${pass} / ${pass + fail}`);
|
||||
console.log("================================");
|
||||
|
||||
await client.end();
|
||||
process.exitCode = fail > 0 ? 1 : 0;
|
||||
Reference in new issue
Block a user