mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
PostgreSQL 上文本按字节比大小、排序,同 SQLite:应用库建表时文本列带 COLLATE "C"(PostgreSQL 缺省按建库的区域设置比,en_US.UTF-8 下大小写混排、标点被忽略,排出来的先后和 key < '…' 的结果都跟 SQLite 不一样),不看建库时选了什么;驱动报错的第一句去掉 asyncpg 带的「<class '…'>: 」前缀
- 测试:大小写、标点、中文混在一起的排序和比较两种库一样;建表语句里带 COLLATE "C",SQLite 的不带 - README:和 SQLite 一致的那几条补上排序规则
This commit is contained in:
@@ -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` 都还是文件。
|
||||
|
||||
|
||||
@@ -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) [^>]*>")
|
||||
|
||||
@@ -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)。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user