PostgreSQL 上文本按字节比大小、排序,同 SQLite:应用库建表时文本列带 COLLATE "C"(PostgreSQL 缺省按建库的区域设置比,en_US.UTF-8 下大小写混排、标点被忽略,排出来的先后和 key < '…' 的结果都跟 SQLite 不一样),不看建库时选了什么;驱动报错的第一句去掉 asyncpg 带的「<class '…'>: 」前缀

- 测试:大小写、标点、中文混在一起的排序和比较两种库一样;建表语句里带 COLLATE "C",SQLite 的不带
- README:和 SQLite 一致的那几条补上排序规则
This commit is contained in:
ThearchyHelios
2026-10-19 10:26:45 +08:00
parent b2f6d8d7d2
commit b9cfc243e2
4 changed files with 43 additions and 10 deletions
+3 -3
View File
@@ -1050,9 +1050,9 @@ AGENTLAB_DATABASE_URL=postgresql+asyncpg://agentlab:<密码>@127.0.0.1:5432/agen
30 分钟换一次;断点库另有一个小池子。前面有 PgBouncer(事务模式)时:`AGENTLAB_DB_POOL_SIZE` 设成 0(不用连接池)、
地址后加 `?prepared_statement_cache_size=0`;断点库用预备语句,要 PgBouncer 1.21 以上开 `max_prepared_statements`,
或者用 `AGENTLAB_CHECKPOINT_URL` 让它直连 PostgreSQL。
- 和 SQLite 上的行为一致:字符串列不限长(SQLite 本来就不查长度,模型里写的长度只是示意)、整数列是 BIGINT、JSON 列存原文
(PostgreSQL 的 json,不是 JSONB:JSONB 会重排对象里的键)、时间存不带时区的 UTC、排序时可空列的 NULL 照 SQLite 的位置
(升序在前、降序在后)。两处 SQLite 照存而 PostgreSQL 不收的值会被换掉:JSON 里的 NaN / Infinity 写成 null,字符串里的
- 和 SQLite 上的行为一致:字符串列不限长(SQLite 本来就不查长度,模型里写的长度只是示意)、文本按字节比大小和排序
(列上带 COLLATE "C",同 SQLite,不看建库时选的区域设置)、整数列是 BIGINT、JSON 列存原文(PostgreSQL 的 json,不是 JSONB:
JSONB 会重排对象里的键)、时间存不带时区的 UTC、排序时可空列的 NULL 照 SQLite 的位置(升序在前、降序在后)。两处 SQLite 照存而 PostgreSQL 不收的值会被换掉:JSON 里的 NaN / Infinity 写成 null,字符串里的
NUL 字符去掉。
- `AGENTLAB_DATA_DIR` 照旧要设:工件、上传表格的版本库、原件、附件、记录表、沙箱工作区、`.secret_key` 都还是文件。
+3 -2
View File
@@ -35,8 +35,9 @@ _BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError",
_UNREACHABLE = ("connection refused", "nodename nor servname", "name or service not known",
"all connection attempts failed", "network is unreachable")
#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」
_WRAPPED = re.compile(r"^\([\w.]+\)\s*")
#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」;asyncpg(PostgreSQL)的后面还跟着
#: 「<class 'asyncpg.exceptions.UniqueViolationError'>: 」,一起去掉
_WRAPPED = re.compile(r"^\([\w.]+\)\s*(?:<class '[\w.]+'>:\s*)?")
# 内部符号:`a.b.<locals>._run()`、`<function f at 0x…>`、模块路径。对使用者没有意义
_LOCALS = re.compile(r"[\w.]*<locals>\.?[\w.]*(\(\))?")
_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>")
+15 -2
View File
@@ -9,6 +9,11 @@ VARCHAR(n) 超长直接报错、INTEGER 只有 32 位。模型里的 String(n)
检查)。CAST(x AS VARCHAR(n)) 这种查询里的写法不动——那是截断的意思。Integer 列在 PostgreSQL 上是 BIGINT:
那是在模型这一层换的(db/base._portable_integer),绑定参数的类型转换跟着列走,大数不在半路溢出。
**文本怎么比大小、怎么排序。** SQLite 缺省按字节比(BINARY);PostgreSQL 按建库时的区域设置(常见的 en_US.UTF-8 不分
大小写地先比字母、忽略标点),同一列排出来的先后、`key < '…'` 这种比较的结果都会变。应用库上的文本列建表时一律带
COLLATE "C"(按字节比),和 SQLite 一致,不看建库时选了什么区域;LIKE 'xx%' 这种前缀匹配也因此用得上普通索引。
ILIKE、lower() 照旧按库的字符集分大小写。
**ORDER BY 里 NULL 的位置。** SQLite 升序 NULL 在前、降序 NULL 在后,PostgreSQL 正好相反。应用里两百多处排序,
可空的列(开始时间、到期时间、发布时间……)排出来的先后要和 SQLite 一致:可空列上没写 nulls_first / nulls_last
的 ASC 补 NULLS FIRST、DESC 补 NULLS LAST,ORDER BY 里直接写列名(隐含 ASC)的同样补上。不可空的列不动——
@@ -34,12 +39,20 @@ def _nullable(element: Any) -> bool:
return isinstance(element, Column) and bool(element.nullable) and not element.primary_key
#: 建表时文本列的排序规则:按字节比,同 SQLite 的 BINARY(见模块说明)
_COLLATE = ' COLLATE "C"'
class AppTypeCompiler(PGTypeCompiler):
def visit_string(self, type_: Any, **kw: Any) -> str:
if type_.length and isinstance(kw.get("type_expression"), Column):
return "VARCHAR"
if isinstance(kw.get("type_expression"), Column):
return ("VARCHAR" if type_.length else super().visit_string(type_, **kw)) + _COLLATE
return super().visit_string(type_, **kw)
def visit_text(self, type_: Any, **kw: Any) -> str:
text = super().visit_text(type_, **kw)
return text + _COLLATE if isinstance(kw.get("type_expression"), Column) else text
def _ordered(clauses: Any) -> list[Any]:
"""ORDER BY 里直接写的可空列补成 ASC(visit_unary 再给它补 NULLS FIRST)。"""
+22 -3
View File
@@ -59,13 +59,14 @@ def test_checkpoint_tables_do_not_clash_with_app_tables():
def test_postgres_columns_are_unbounded_varchar_bigint_and_json():
"""应用库上 String(n) 建成不限长的 VARCHAR、Integer 是 BIGINT(自增主键 BIGSERIAL)、JSON 是 json(不是 JSONB:
JSONB 会重排对象里的键)、时间是不带时区的 TIMESTAMP。SQLite 上的建表语句不变。"""
"""应用库上 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 NOT NULL" in pg and "VARCHAR(" not in pg
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
@@ -73,6 +74,7 @@ def test_postgres_columns_are_unbounded_varchar_bigint_and_json():
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():
@@ -276,6 +278,23 @@ async def test_order_by_puts_nulls_where_sqlite_does():
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_timestamps_read_back_as_aware_utc():
from datetime import datetime, timedelta, timezone