Postgres real backend: psycopg adapter, schema, migration

Replace the PostgresDatabase skeleton with a real implementation:
psycopg (v3) first, psycopg2 second, lazy load, missing driver
still raises PostgresDriverMissingError with no silent SQLite
fallback. A sqlite3-style adapter (PgConnection/PgCursor/PgRow)
translates ? placeholders to %s outside string literals, maps
UNIQUE/FK violations, lock errors and connection failures to
explicit Postgres error types, and handles INSERT OR REPLACE,
PRAGMA table_info, AUTOINCREMENT and FTS5 refusal. POSTGRES_SCHEMA
covers all business tables with tasks result columns; server
init_db uses it under CLOUD_DB_BACKEND=postgres, SQLite path
unchanged. migrate_sqlite_to_postgres.py copies rows with
ON CONFLICT DO NOTHING and reseeds identity sequences.
Tests: cloud 239 (235 pass, 4 skipped without a real server),
host 27 pass; real-server tests skip unless CLOUD_TEST_PG_DSN
is set.
This commit is contained in:
host committed 2026-10-26 03:23:06 +08:00
1 parent a19e77ee44
commit dc3f572b2b
10 files changed
+1542 -66

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
"""Postgres 集成测试:真实服务器上的端到端路径。
全部测试在无真实 Postgres 时 skip,绝不 fail。判定条件与
test_postgres_backend.py 的 _real_pg_params() 一致:须显式
设置 CLOUD_TEST_PG_DSN(postgres://user:pass@host:port/dbname)
且主机端口 2 秒内可达,且装有 psycopg 驱动。DSN 必须指向
一次性测试库;本文件只写业务表与测试行,跑完删掉测试行,
不删库、不动其它库。DSN 只从环境变量读,测试不打印它。
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.tests.test_postgres_backend import (
_has_real_driver,
_real_pg_params,
)
from cloud.db import PostgresDatabase, init_postgres_schema
from cloud.migrations import migrate_sqlite_to_postgres
from cloud.server import init_db
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.tests.test_postgres_backend import (
_has_real_driver,
_real_pg_params,
)
from cloud.db import PostgresDatabase, init_postgres_schema
from cloud.migrations import migrate_sqlite_to_postgres
from cloud.server import init_db
def _pg_db_or_skip(testcase):
params = _real_pg_params()
if params is None:
testcase.skipTest("未设置可达的 CLOUD_TEST_PG_DSN,跳过真实 PG 测试")
if not _has_real_driver():
testcase.skipTest("未装 psycopg 驱动,跳过真实 PG 测试")
host, port, dbname, user, password = params
return PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
class PostgresIntegrationTestCase(unittest.TestCase):
def test_server_init_db_and_adapter_crud(self):
db = _pg_db_or_skip(self)
conn = db.connect()
try:
init_postgres_schema(conn)
# 适配层 CRUD:projects 插、读、改、删均走 ? 占位符。
with db.transaction(conn):
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at)"
" VALUES(?, ?, ?, ?)",
("pg-int-proj", "pg-int", "pg-int-user", 0.0))
rows = db.read_all(
conn, "SELECT * FROM projects WHERE id = ?",
("pg-int-proj",))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["name"], "pg-int")
with db.transaction(conn):
conn.execute(
"UPDATE projects SET name = ? WHERE id = ?",
("pg-int-2", "pg-int-proj"))
rows = db.read_all(
conn, "SELECT name FROM projects WHERE id = ?",
("pg-int-proj",))
self.assertEqual(rows[0]["name"], "pg-int-2")
finally:
try:
with db.transaction(conn):
conn.execute("DELETE FROM projects WHERE id = ?",
("pg-int-proj",))
except Exception:
pass
conn.close()
def test_sqlite_to_postgres_migration_is_idempotent(self):
import sqlite3
db = _pg_db_or_skip(self)
# 先建一个最小 SQLite 库:含 projects/tasks 两行。
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src.db")
sconn = sqlite3.connect(src)
sconn.execute(
"CREATE TABLE projects(id TEXT PRIMARY KEY, name TEXT,"
" owner_id TEXT, created_at REAL)")
sconn.execute(
"CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT,"
" prompt TEXT, status TEXT)")
sconn.execute(
"INSERT INTO projects VALUES(?, ?, ?, ?)",
("mig-proj", "mig", "mig-user", 1.5))
sconn.execute(
"INSERT INTO tasks VALUES(?, ?, ?, ?)",
("mig-task", "mig-proj", "hello", "queued"))
sconn.commit()
sconn.close()
conn = db.connect()
try:
first = migrate_sqlite_to_postgres(src, pg_conn=conn)
self.assertGreaterEqual(first.get("projects", 0), 1)
self.assertGreaterEqual(first.get("tasks", 0), 1)
# 第二次跑:ON CONFLICT DO NOTHING,不重复插。
second = migrate_sqlite_to_postgres(src, pg_conn=conn)
self.assertEqual(second.get("projects", -1), 0)
self.assertEqual(second.get("tasks", -1), 0)
rows = db.read_all(
conn, "SELECT * FROM projects WHERE id = ?",
("mig-proj",))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["name"], "mig")
finally:
try:
with db.transaction(conn):
conn.execute("DELETE FROM tasks WHERE id = ?",
("mig-task",))
conn.execute("DELETE FROM projects WHERE id = ?",
("mig-proj",))
except Exception:
pass
conn.close()
def test_server_init_db_on_postgres(self):
# 真走 server.init_db 的 PG 分支:环境变量切到 postgres
# 后,init_db 不应建任何 SQLite 文件,而应在 PG 里建表
# (含 tasks 的 6 个结果列);本测试不启动 HTTP server。
import os
from unittest import mock
params = _real_pg_params()
if params is None:
self.skipTest("未设置可达的 CLOUD_TEST_PG_DSN,跳过真实 PG 测试")
if not _has_real_driver():
self.skipTest("未装 psycopg 驱动,跳过真实 PG 测试")
host, port, dbname, user, password = params
env = {
"CLOUD_DB_BACKEND": "postgres",
"CLOUD_DB_HOST": host,
"CLOUD_DB_PORT": str(port),
"CLOUD_DB_NAME": dbname,
"CLOUD_DB_USER": user or "",
"CLOUD_DB_PASSWORD": password or "",
}
with mock.patch.dict(os.environ, env):
init_db("ignored-in-postgres-mode.db")
db = PostgresDatabase(host=host, port=port, dbname=dbname,
user=user, password=password)
conn = db.connect()
try:
rows = db.read_all(
conn,
"SELECT table_name FROM information_schema.tables"
" WHERE table_schema = 'public'",
())
names = {r["table_name"] for r in rows}
self.assertIn("projects", names)
self.assertIn("tasks", names)
self.assertIn("audit_log", names)
cols = db.read_all(
conn,
"SELECT column_name FROM information_schema.columns"
" WHERE table_name = 'tasks'",
())
colnames = {r["column_name"] for r in cols}
for col in ("result_text", "error_text", "actual_model",
"started_at", "finished_at", "child_pid"):
self.assertIn(col, colnames)
finally:
conn.close()