Files
AgentLab/backend/tests/test_db_portable.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

539 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应用库在 SQLite、PostgreSQL 上行为一致(db/portable.py、db/postgres.py、db/base.py 的迁移)。
连着库的那几项在两种库上各跑一遍(scripts/test-postgres.sh 跑 PostgreSQL),断言同一套结果;只看编译结果的不连库;
PostgreSQL 老库迁移另建一个库(只在 AGENTLAB_TEST_DATABASE_URL 时跑)。全是虚构数据。
"""
from __future__ import annotations
import os
import re
import uuid
import pytest
from sqlalchemy import func, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.schema import CreateTable
from app.db import base as db_base
from app.db.base import Base, SessionLocal
from app.db.models import Run, TableRecipe
from app.db.portable import json_eq, json_flag, json_is_string, json_text, unique_violation
PG = bool(os.environ.get("AGENTLAB_TEST_DATABASE_URL", "").strip())
def _pg_dialect():
from sqlalchemy.dialects.postgresql.asyncpg import dialect
from app.db import postgres
made = dialect()
postgres.install(made)
return made
# --------------------------------------------------------------------------
# 守着的约定
# --------------------------------------------------------------------------
def test_every_migrated_column_is_in_the_models():
"""PostgreSQL 上补列的类型取模型里这一列的(db/base._column_ddl):迁移表里的每一列模型里都得有。"""
missing = [(t, c) for t, c, _ in db_base._COLUMN_MIGRATIONS
if t not in Base.metadata.tables or c not in Base.metadata.tables[t].c]
assert missing == []
def test_every_not_null_migrated_column_gets_a_value_for_old_rows():
"""迁移补的列模型里是 NOT NULL 的,存量行不能留 NULL(PostgreSQL 的表照模型带 NOT NULL,导入时整行插不进):要么补列时
带 DEFAULT 回填,要么登记在 _NOT_NULL_BACKFILLS 里每次启动按模型的缺省值填。"""
bad = []
for table, column, ddl in db_base._COLUMN_MIGRATIONS:
col = Base.metadata.tables[table].c[column]
if col.nullable:
continue
if not re.search(r"\bDEFAULT\b", ddl, re.I) and (table, column) not in db_base._NOT_NULL_BACKFILLS:
bad.append((table, column))
assert bad == []
for table, column in db_base._NOT_NULL_BACKFILLS:
assert db_base.model_default(Base.metadata.tables[table].c[column])[0], (table, column)
def test_checkpoint_tables_do_not_clash_with_app_tables():
"""断点和应用表放在同一个库、同一个 schema:两边的表名不能撞。名字从两个 saver 自己的建表语句里取。"""
import inspect as pyinspect
from langgraph.checkpoint.postgres.base import BasePostgresSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
created = set(re.findall(r"CREATE TABLE IF NOT EXISTS (\w+)", " ".join(BasePostgresSaver.MIGRATIONS)))
created |= set(re.findall(r"CREATE TABLE IF NOT EXISTS (\w+)", pyinspect.getsource(AsyncSqliteSaver.setup)))
assert {"checkpoints", "checkpoint_blobs", "checkpoint_writes", "checkpoint_migrations"} <= created
assert created & set(Base.metadata.tables) == set()
def test_the_compile_guard_catches_whole_json_values_however_they_are_written():
"""整个 JSON 值做比较、去重、分组、排序:编译时就报错(tests/jsonguard.py,conftest 挂在应用库引擎上,跑全量时一直生效)。
按表达式的类型认,逐行正则抓不到的写法也抓得到;按键筛选的 json_eq 等、IS NULL、UNION ALL 不受影响。"""
from sqlalchemy import union, union_all
from tests.jsonguard import JsonMisuse
dialect = db_base.engine.sync_engine.dialect
alias = Run.origin
caught = [
select(Run.origin).distinct(),
select(Run).distinct(),
select(Run.id).order_by(
Run.created_at,
Run.origin,
),
select(Run.id).order_by(alias.desc()),
select(Run.id).where(alias == {"a": 1}),
select(Run.id).where(Run.input.in_([{"q": 1}])),
select(Run.id).where(Run.origin["k"] == 1),
select(func.count()).select_from(Run).group_by(Run.options),
union(select(Run.origin), select(Run.input)),
]
for statement in caught:
with pytest.raises(JsonMisuse):
statement.compile(dialect=dialect)
fine = [
select(Run.origin).where(json_eq(Run.origin, "k", 1), Run.origin.is_(None),
Run.origin != None), # noqa: E711 - 写成 IS NOT NULL,json 上也能用
select(Run.id).where(json_text(Run.origin, "k") == "x").order_by(json_text(Run.origin, "k")).distinct(),
union_all(select(Run.origin), select(Run.input)),
]
for statement in fine:
statement.compile(dialect=dialect)
def test_postgres_columns_are_unbounded_varchar_bigint_and_json():
"""应用库上 String(n) 建成不限长的 VARCHAR、文本列按字节比(COLLATE "C",同 SQLite)、Integer 是 BIGINT(自增主键
BIGSERIAL)、JSON 是 json(不是 JSONB:JSONB 会重排对象里的键)、时间是不带时区的 TIMESTAMP。SQLite 上的建表语句不变。"""
from sqlalchemy.dialects.sqlite.aiosqlite import dialect as sqlite_dialect
runs = Base.metadata.tables["runs"]
pg = CreateTable(runs).compile(dialect=_pg_dialect()).string
assert 'id VARCHAR COLLATE "C" NOT NULL' in pg and "VARCHAR(" not in pg
assert 'error TEXT COLLATE "C"' in pg
assert "last_seq BIGINT" in pg and "INTEGER" not in pg
assert "input JSON NOT NULL" in pg and "JSONB" not in pg
assert "created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL" in pg
changes = CreateTable(Base.metadata.tables["source_changes"]).compile(dialect=_pg_dialect()).string
assert "seq BIGSERIAL NOT NULL" in changes
lite = CreateTable(runs).compile(dialect=sqlite_dialect()).string
assert "id VARCHAR(32) NOT NULL" in lite and "last_seq INTEGER" in lite and "input JSON NOT NULL" in lite
assert "COLLATE" not in lite
def test_postgres_casts_keep_their_length():
"""CAST(x AS VARCHAR(n)) 是截断的意思,照旧写长度;只有建表时的列不限长。"""
from sqlalchemy import String, cast
sql = str(select(cast(Run.workflow_name, String(4))).compile(dialect=_pg_dialect()))
assert "CAST(runs.workflow_name AS VARCHAR(4))" in sql
def test_postgres_orders_nulls_like_sqlite():
"""SQLite 升序 NULL 在前、降序 NULL 在后:PostgreSQL 上可空列照这个补写,不可空的列、写明了的不动。"""
from app.db.models import Task
sql = str(select(Run.id, func.row_number().over(order_by=Run.started_at).label("n"))
.order_by(Run.started_at, Run.finished_at.desc(), Run.created_at.desc(),
Task.due_at.desc().nulls_first(), Task.remind_at.asc().nulls_last())
.compile(dialect=_pg_dialect()))
assert "OVER (ORDER BY runs.started_at ASC NULLS FIRST)" in sql
order = sql.split("ORDER BY", 2)[2]
assert "runs.started_at ASC NULLS FIRST" in order
assert "runs.finished_at DESC NULLS LAST" in order
assert "runs.created_at DESC," in order # 不可空:不补
assert "tasks.due_at DESC NULLS FIRST" in order and "NULLS FIRST NULLS" not in order
assert "tasks.remind_at ASC NULLS LAST" in order
def test_postgres_integer_binds_are_bigint():
"""和 Integer 列比较、写进 Integer 列的参数按 BIGINT 转换,超过 21 亿的值不在半路溢出。"""
sql = str(select(Run.id).where(Run.last_seq > 5).compile(dialect=_pg_dialect()))
assert "runs.last_seq > $1::BIGINT" in sql
def test_database_urls_are_normalized_and_never_logged_with_the_password():
from app.db.urls import describe, libpq_conninfo, normalize_checkpoint_url, normalize_database_url
assert normalize_database_url("postgres://u:p%40w@h:5433/db") == "postgresql+asyncpg://u:p%40w@h:5433/db"
assert normalize_checkpoint_url("postgresql+asyncpg://u:pw@h/db") == "postgresql://u:pw@h/db"
# asyncpg 的 ssl= 换成 libpq 的 sslmode=,只给 asyncpg 看的参数不带给 psycopg
assert libpq_conninfo("postgresql+asyncpg://u:pw@h/db?ssl=require&prepared_statement_cache_size=0") \
== "postgresql://u:pw@h/db?sslmode=require"
# 应用库地址写 libpq 的 sslmode=:换成 asyncpg 的 ssl=(以前原样交给 asyncpg,连的时候 TypeError);断点库换回 sslmode=
app_url = normalize_database_url("postgresql://u:pw@h/db?sslmode=verify-full")
assert app_url == "postgresql+asyncpg://u:pw@h/db?ssl=verify-full"
assert libpq_conninfo(app_url) == "postgresql://u:pw@h/db?sslmode=verify-full"
for bad, says in (("postgresql://u@h/db?sslrootcert=/etc/ca.pem", "PGSSLROOTCERT"),
("postgresql://u@h/db?sslmode=strict", "sslmode=strict"),
("postgresql://u@h/db?ssl=require&sslmode=require", "只写一个"),
("postgresql://u@h/db?application_name=x", "application_name")):
with pytest.raises(ValueError, match=says):
normalize_database_url(bad)
assert "pw" not in describe("postgresql+asyncpg://u:pw@h:5433/db")
for bad in ("mysql://u@h/db", "sqlite:///x.db", "postgresql://h", "这不是地址"):
with pytest.raises(ValueError):
normalize_database_url(bad)
from app.core.config import Settings
with pytest.raises(ValueError, match="AGENTLAB_DATABASE_URL"):
Settings(database_url="mysql://u@h/db")
assert Settings(database_url=" ").db_dialect == "sqlite"
def test_a_postgres_url_without_the_driver_installed_says_what_to_install(monkeypatch):
import importlib.util
real = importlib.util.find_spec
monkeypatch.setattr(importlib.util, "find_spec", lambda name, *a, **k: None if name == "asyncpg" else real(name, *a, **k))
with pytest.raises(RuntimeError, match=r"backend\[postgres\]"):
db_base.make_engine("postgresql+asyncpg://u:pw@127.0.0.1:1/db")
def test_the_startup_line_names_the_database_without_the_password():
from sqlalchemy.engine import make_url
line = db_base.describe_database()
password = make_url(db_base.settings.db_url).password
assert ("PostgreSQL" if PG else "SQLite") in line
assert not password or password not in line
# --------------------------------------------------------------------------
# 连着库:两种库同一套结果
# --------------------------------------------------------------------------
async def _runs(origins: list[dict | None]) -> tuple[str, list[str]]:
tag = uuid.uuid4().hex[:12]
ids = []
async with SessionLocal() as session:
for i, origin in enumerate(origins):
row = Run(workflow_name=f"便携-{tag}-{i}", origin=origin, input={"question": "虚构的问题"} if i % 2 else
{"question": 7})
session.add(row)
await session.flush()
ids.append(row.id)
await session.commit()
return tag, ids
async def _match(tag: str, *where) -> list[int]:
async with SessionLocal() as session:
names = (await session.execute(select(Run.workflow_name).where(
Run.workflow_name.like(f"便携-{tag}-%"), *where))).scalars().all()
return sorted(int(n.rsplit("-", 1)[1]) for n in names)
async def test_json_helpers_match_the_same_rows_on_both_databases():
tag, _ = await _runs([
{"v": 3, "s": "3", "flag": True, "k": "甲"}, # 0
{"v": "3", "s": 3, "flag": 1, "k": None}, # 1
{"v": 4, "flag": 0, "k": "乙", "uses": ["r-1", "r-22"]}, # 2
{"flag": False}, # 3
{}, # 4
None, # 5(JSON 的 null)
{"v": True, "n": 1.0, "b": False}, # 6
{"v": 1, "n": 1, "b": 0}, # 7
])
# 按 JSON 的类型比:数字 3 不等于字符串 "3";true 不等于 1、false 不等于 0(SQLite 的 json_extract 单用时分不开);
# 数 1 等于 1.0
assert await _match(tag, json_eq(Run.origin, "v", 3)) == [0]
assert await _match(tag, json_eq(Run.origin, "v", "3")) == [1]
assert await _match(tag, json_eq(Run.origin, "s", "3")) == [0]
assert await _match(tag, json_eq(Run.origin, "v", 1)) == [7]
assert await _match(tag, json_eq(Run.origin, "v", True)) == [6]
assert await _match(tag, json_eq(Run.origin, "b", False)) == [6]
assert await _match(tag, json_eq(Run.origin, "b", 0)) == [7]
assert await _match(tag, json_eq(Run.origin, "n", 1)) == [6, 7]
assert await _match(tag, json_eq(Run.origin, "n", 1.0)) == [6, 7]
with pytest.raises(TypeError):
json_eq(Run.origin, "v", {"a": 1})
# None:没有这个键、或者值是 null
assert await _match(tag, json_eq(Run.origin, "k", None)) == [1, 3, 4, 5, 6, 7]
# 标记位:true 和非零的数算真
assert await _match(tag, json_flag(Run.origin, "flag")) == [0, 1]
from sqlalchemy import not_
assert await _match(tag, not_(json_flag(Run.origin, "flag"))) == [2, 3, 4, 5, 6, 7]
# 文本:字符串是它本身,数、true / false 是 JSON 写法(两种库都是)
assert await _match(tag, json_text(Run.origin, "k") == "乙") == [2]
assert await _match(tag, func.coalesce(json_text(Run.origin, "k"), "").in_(["甲", ""])) == [0, 1, 3, 4, 5, 6, 7]
assert await _match(tag, json_text(Run.origin, "v") == "3") == [0, 1]
assert await _match(tag, json_text(Run.origin, "v") == "1") == [7]
assert await _match(tag, json_text(Run.origin, "v") == "true") == [6]
assert await _match(tag, json_text(Run.origin, "n") == "1.0") == [6]
assert await _match(tag, json_text(Run.origin, "uses").contains("r-22", autoescape=True)) == [2]
# 是不是字符串:input.question 偶数行是数字、奇数行是字符串
assert await _match(tag, json_is_string(Run.input, "question")) == [1, 3, 5, 7]
async def test_json_columns_keep_the_order_of_keys():
"""JSON 列存原文:对象里键的先后读回来不变(JSONB 会重排,应用里按键的先后出列名、出段落的地方就乱了)。"""
keys = ["单号", "金额", "电话", "b", "a", "aa"]
async with SessionLocal() as session:
row = Run(workflow_name="键序", origin={k: i for i, k in enumerate(keys)})
session.add(row)
await session.commit()
rid = row.id
async with SessionLocal() as session:
got = (await session.execute(select(Run.origin).where(Run.id == rid))).scalar_one()
assert list(got) == keys
async def test_json_null_and_sql_null_stay_apart():
"""JSON 列写 None 存的是 JSON 的 null(不是 SQL 的 NULL),两种库一样:按 IS NULL 筛不到它。"""
async with SessionLocal() as session:
row = Run(workflow_name="空值", origin=None)
session.add(row)
await session.commit()
rid = row.id
async with SessionLocal() as session:
is_sql_null = (await session.execute(select(Run.origin.is_(None)).where(Run.id == rid))).scalar_one()
raw = (await session.execute(text("SELECT CAST(origin AS TEXT) FROM runs WHERE id = :i"), {"i": rid})).scalar_one()
assert is_sql_null is False and raw == "null"
async def test_long_strings_and_big_integers_are_kept():
"""String(n) 超长、Integer 过 21 亿:SQLite 照存,PostgreSQL 上也照存(列不限长、是 BIGINT)。"""
long_name = "长" * 500 # workflow_name 是 String(200)
big = 5_000_000_000
async with SessionLocal() as session:
row = Run(workflow_name=long_name, last_seq=big)
session.add(row)
await session.commit()
rid = row.id
async with SessionLocal() as session:
got = (await session.execute(select(Run.workflow_name, Run.last_seq).where(
Run.id == rid, Run.last_seq > big - 1))).one()
assert tuple(got) == (long_name, big)
async def test_non_finite_floats_in_json_are_written():
"""JSON 里的 NaN:SQLite 照存;PostgreSQL 的 json 不收,写成 null(同浏览器的 JSON.stringify),不让整条写入失败。"""
async with SessionLocal() as session:
row = Run(workflow_name="非有限", origin={"x": float("nan"), "y": 1.5, "s": "a\x00b"})
session.add(row)
await session.commit()
rid = row.id
async with SessionLocal() as session:
got = (await session.execute(select(Run.origin).where(Run.id == rid))).scalar_one()
assert got["y"] == 1.5
if db_base.engine.dialect.name == "postgresql":
assert got["x"] is None and got["s"] == "ab"
else:
assert got["x"] != got["x"] and got["s"] == "a\x00b" # NaN、NUL 原样
async def test_unique_violation_names_the_table_and_columns():
sid = uuid.uuid4().hex
async with SessionLocal() as session:
session.add(TableRecipe(source_id=sid, seq=1, recipe={}, recipe_sha256="c" * 64))
await session.commit()
session.add(TableRecipe(source_id=sid, seq=1, recipe={}, recipe_sha256="c" * 64))
with pytest.raises(IntegrityError) as caught:
await session.commit()
assert unique_violation(caught.value, "table_recipes", "source_id", "seq")
assert not unique_violation(caught.value, "table_recipes", "source_id")
assert not unique_violation(caught.value, "data_sources", "source_id", "seq")
async def test_order_by_puts_nulls_where_sqlite_does():
tag = uuid.uuid4().hex[:12]
from datetime import datetime, timezone
async with SessionLocal() as session:
for i, started in enumerate([datetime(2026, 1, 2, tzinfo=timezone.utc), None,
datetime(2026, 1, 1, tzinfo=timezone.utc)]):
session.add(Run(workflow_name=f"排序-{tag}-{i}", started_at=started))
await session.commit()
async with SessionLocal() as session:
where = Run.workflow_name.like(f"排序-{tag}-%")
up = (await session.execute(select(Run.workflow_name).where(where).order_by(Run.started_at))).scalars().all()
down = (await session.execute(select(Run.workflow_name).where(where)
.order_by(Run.started_at.desc()))).scalars().all()
assert [n[-1] for n in up] == ["1", "2", "0"] # 升序:NULL 在前
assert [n[-1] for n in down] == ["0", "2", "1"] # 降序:NULL 在后
async def test_text_sorts_and_compares_byte_by_byte_like_sqlite():
"""大写在小写前、标点按码位:两种库排出来一样(PostgreSQL 上不看建库时的区域设置)。"""
tag = uuid.uuid4().hex[:12]
names = ["b", "B", "a-c", "ab", "_z", "Z", "中"]
async with SessionLocal() as session:
for name in names:
session.add(Run(workflow_name=f"字节-{tag}-{name}"))
await session.commit()
async with SessionLocal() as session:
got = (await session.execute(select(Run.workflow_name).where(Run.workflow_name.like(f"字节-{tag}-%"))
.order_by(Run.workflow_name))).scalars().all()
lower = (await session.execute(select(func.count()).select_from(Run).where(
Run.workflow_name.like(f"字节-{tag}-%"), Run.workflow_name < f"字节-{tag}-a"))).scalar_one()
assert [g.removeprefix(f"字节-{tag}-") for g in got] == sorted(names)
assert lower == sum(1 for n in names if n < "a")
async def test_nul_characters_in_text_columns():
"""文本列里的 NUL:SQLite 照存;PostgreSQL 的 text / varchar 不收,写入时去掉(同 JSON 列),不让整条写入失败。"""
async with SessionLocal() as session:
row = Run(workflow_name="带\x00NUL\x00的名字", error="错\x00误")
session.add(row)
await session.commit()
rid = row.id
await session.execute(text("UPDATE runs SET end_reason = :r WHERE id = :i"), {"r": "x\x00y", "i": rid})
await session.commit()
async with SessionLocal() as session:
got = (await session.execute(select(Run.workflow_name, Run.error, Run.end_reason).where(Run.id == rid))).one()
if db_base.engine.dialect.name == "postgresql":
assert tuple(got) == ("带NUL的名字", "错误", "xy")
else:
assert tuple(got) == ("带\x00NUL\x00的名字", "错\x00误", "x\x00y")
async def test_case_insensitive_search_folds_like_the_database_does():
"""contains_ci:ASCII 两种库都不分大小写;非 ASCII(É/é、Ж/ж、全角A/a)按库自己会不会折——SQLite 看构建,PostgreSQL 看
建库的区域设置(列本身是 COLLATE "C",列上的 ILIKE 只认 ASCII,所以模式带 COLLATE "default")。% 和 _ 按字面。"""
from app.db.portable import contains_ci
tag = uuid.uuid4().hex[:12]
async with SessionLocal() as session:
session.add(Run(workflow_name=f"大小写-{tag}-Éclair Жук Ab Report_100%"))
await session.commit()
pg = db_base.engine.dialect.name == "postgresql"
folds = (await session.execute(text(
"SELECT lower('ÉЖA' COLLATE \"default\") = 'éжa'" if pg else "SELECT lower('ÉЖA') = 'éжa'"))).scalar()
async def hit(needle: str) -> bool:
return bool((await session.execute(select(func.count()).select_from(Run).where(
Run.workflow_name.like(f"大小写-{tag}-%"), contains_ci(Run.workflow_name, needle)))).scalar())
assert await hit("report") and await hit("REPORT_100%")
assert not await hit("report-100") and not await hit("100%%")
assert await hit("éclair") == bool(folds) and await hit("жук") == bool(folds) and await hit("ab") == bool(folds)
assert await hit("Éclair") and await hit("Жук")
async def test_timestamps_read_back_as_aware_utc():
from datetime import datetime, timedelta, timezone
local = datetime(2026, 3, 4, 9, 30, tzinfo=timezone(timedelta(hours=8)))
async with SessionLocal() as session:
row = Run(workflow_name="时区", started_at=local)
session.add(row)
await session.commit()
rid = row.id
async with SessionLocal() as session:
got = (await session.execute(select(Run.started_at).where(Run.id == rid, Run.started_at == local))).scalar_one()
assert got == local and got.tzinfo == timezone.utc and got.hour == 1
# --------------------------------------------------------------------------
# PostgreSQL 上的老库迁移
# --------------------------------------------------------------------------
@pytest.fixture
def scratch_pg():
"""同一台服务器上另建一个空库,给迁移测试造「老版本建的库」。只在 PostgreSQL 时有。"""
if not PG:
pytest.skip("要 AGENTLAB_TEST_DATABASE_URL(scripts/test-postgres.sh)")
import psycopg
from sqlalchemy import create_engine
from sqlalchemy.engine import make_url
from app.db.urls import libpq_conninfo
admin = make_url(db_base.settings.db_url)
name = f"{admin.database}_mig_{uuid.uuid4().hex[:8]}"
conninfo = libpq_conninfo(admin.render_as_string(hide_password=False))
with psycopg.connect(conninfo, autocommit=True) as conn:
conn.execute(f'CREATE DATABASE "{name}"')
url = admin.set(drivername="postgresql+psycopg", database=name)
engine = create_engine(url)
try:
yield engine
finally:
engine.dispose()
with psycopg.connect(conninfo, autocommit=True) as conn:
conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
def _columns(conn, table: str) -> dict[str, tuple[str, str | None]]:
rows = conn.execute(text("SELECT column_name, data_type, column_default FROM information_schema.columns "
"WHERE table_name = :t"), {"t": table}).all()
return {r[0]: (r[1], r[2]) for r in rows}
def test_old_postgres_databases_get_the_missing_columns_and_indexes(scratch_pg):
"""老版本建的 PostgreSQL 库(少几列、少索引、有并发留下的重复行):补上的列类型按模型的、缺省值回填,布尔的缺省是
false 不是 0,索引补上,唯一索引建之前先去重;再跑一遍什么都不做。"""
with scratch_pg.begin() as conn:
conn.execute(text("CREATE TABLE runs (id VARCHAR PRIMARY KEY, workflow_id VARCHAR, version BIGINT, "
"status VARCHAR, last_seq BIGINT)"))
conn.execute(text("INSERT INTO runs (id, status, last_seq) VALUES ('r1', 'succeeded', 9)"))
conn.execute(text("CREATE TABLE schedules (id VARCHAR PRIMARY KEY, name VARCHAR)"))
conn.execute(text("INSERT INTO schedules (id, name) VALUES ('s1', '虚构的定时')"))
conn.execute(text("CREATE TABLE conversations (id VARCHAR PRIMARY KEY, kind VARCHAR, archived BOOLEAN, "
"workflow_id VARCHAR, last_active_at TIMESTAMP)"))
for cid, at in (("c1", "2026-01-01"), ("c2", "2026-01-02")):
conn.execute(text("INSERT INTO conversations (id, kind, archived, last_active_at) "
"VALUES (:i, 'app', false, :at)"), {"i": cid, "at": at})
# 加发布依据这一列(K07)之前建的库:这张表少「发布时要不要证明」这一列,而且它定过义:回填 false
conn.execute(text("CREATE TABLE workflow_publications (id VARCHAR PRIMARY KEY, workflow_id VARCHAR, "
"version BIGINT, level VARCHAR, manifest VARCHAR)"))
conn.execute(text("INSERT INTO workflow_publications (id, workflow_id, version, level, manifest) "
"VALUES ('p1', 'w1', 1, 'published', 'm1')"))
db_base._migrate(conn)
db_base._migrate(conn)
runs = _columns(conn, "runs")
assert runs["options"][0] == "json" and runs["options"][1].startswith("'{}'")
assert runs["schedule_id"][0] == "character varying" and runs["manifest_seq"][0] == "bigint"
assert runs["started_by"][0] == "character varying"
assert conn.execute(text("SELECT CAST(options AS TEXT), run_class FROM runs WHERE id = 'r1'")).one() \
== ("{}", "exploratory")
schedules = _columns(conn, "schedules")
assert schedules["trigger_kind"][1].startswith("'cron'")
assert conn.execute(text("SELECT trigger_kind FROM schedules")).scalar_one() == "cron"
indexes = {r[0] for r in conn.execute(text("SELECT indexname FROM pg_indexes WHERE tablename = 'runs'"))}
assert {"ix_runs_schedule_id", "ix_runs_wf_ver_status"} <= indexes
# 补上的布尔列:类型是 boolean、缺省是 false(SQLite 的 0 换成 false),存量行回填 false 而不是 NULL
publications = _columns(conn, "workflow_publications")
assert publications["required"][0] == "boolean" and publications["required"][1].startswith("false")
assert conn.execute(text("SELECT required FROM workflow_publications WHERE id = 'p1'")).scalar_one() is False
# 同一个人在同一个应用里两段没归档的助手对话:created_by 是迁移补上的列(NULL 不受约束),唯一索引照样建上
convs = {r[0] for r in conn.execute(text("SELECT indexname FROM pg_indexes WHERE tablename = 'conversations'"))}
assert "ux_conversations_app_owner" in convs
def test_old_postgres_duplicates_are_settled_before_the_unique_index(scratch_pg):
with scratch_pg.begin() as conn:
conn.execute(text("CREATE TABLE conversations (id VARCHAR PRIMARY KEY, kind VARCHAR, archived BOOLEAN, "
"app_id VARCHAR, created_by VARCHAR, last_active_at TIMESTAMP)"))
for cid, at in (("c1", "2026-01-01"), ("c2", "2026-01-03"), ("c3", "2026-01-02")):
conn.execute(text("INSERT INTO conversations VALUES (:i, 'app', false, 'a1', '甲', :at)"),
{"i": cid, "at": at})
db_base._migrate(conn)
kept = conn.execute(text("SELECT id FROM conversations WHERE archived = false")).scalars().all()
assert kept == ["c2"], "留最近活跃的那一段,别的归档"
with pytest.raises(Exception, match="ux_conversations_app_owner"):
with conn.begin_nested():
conn.execute(text("INSERT INTO conversations VALUES ('c4', 'app', false, 'a1', '甲', '2026-02-01')"))
async def test_new_postgres_databases_have_the_partial_unique_indexes():
"""新库靠 create_all:部分唯一索引在 PostgreSQL 上也是部分索引(postgresql_where),条件里的布尔写成 false。"""
if not PG:
pytest.skip("要 AGENTLAB_TEST_DATABASE_URL(scripts/test-postgres.sh)")
async with db_base.engine.connect() as conn:
rows = await conn.execute(text("SELECT indexname, indexdef FROM pg_indexes WHERE indexname LIKE 'ux_%'"))
got = {r[0]: r[1] for r in rows}
assert "WHERE (((kind)::text = 'app'::text) AND (archived = false))" in got["ux_conversations_app_owner"]
assert "WHERE ((status)::text = ANY" in got["ux_catalog_draft_jobs_active"]