Files
AgentLab/backend/app/db/postgres.py
T
ThearchyHelios 7febf2bcdb 两种库行为再对齐一轮(复核 NUL、R-a4、R-a6、R-a7、R-a10)
- 编排者的决定:PostgreSQL 下文本列写入时去掉 NUL 字符(before_cursor_execute 钩子,只装在应用库引擎上,看所有字符串参数,没有 NUL 的原样交回),SQLite 不变;同 JSON 列的处理
- R-a4 应用库地址里的 sslmode= 换成 asyncpg 的 ssl=(取值同一套),sslrootcert 等证书参数、application_name 这类 asyncpg 不收的参数启动时就报错并说怎么写(证书用 PGSSLROOTCERT 等环境变量);断点库连接串换回 sslmode=
- R-a6 不分大小写的搜索换成 db/portable.contains_ci:SQLite 上同 ilike(lower LIKE lower),PostgreSQL 上模式带 COLLATE "default"(列是 COLLATE "C",列上的 ILIKE 只认 ASCII,É/é、Ж/ж、全角A/a 匹配不上);运行列表按名字、对象索引按注释两处都改;改正 db/postgres 里那句说明
- R-a7 JSON 守卫改成编译层:tests/jsonguard 挂在应用库编译器上(conftest,两种库、跑全量时一直生效),整个 JSON 值出现在 = / != / IN / IS DISTINCT FROM / 大小比较、SELECT DISTINCT、GROUP BY、ORDER BY、UNION(去重的几种)里就报错;按类型认,变量别名、跨行 order_by、select(Run.origin).distinct()、JSON 下标的比较都抓得到,测试拿这些反例证明,json_eq、IS NULL、UNION ALL 不受影响。原来的逐行正则测试删掉
- R-a10 json_eq 两种库同一语义、按 JSON 类型比:字符串只等于字符串,数只等于数(1 等于 1.0),True / False 只等于 JSON 的 true / false(SQLite 上用 json_type 分开,以前 1 能匹配 true);非标量 TypeError。json_text 两种库都是「字符串取本身、别的取 JSON 写法」(SQLite 上同 PostgreSQL 的 ->>),数 1 的 json_text 两边都是 '1'
- db/postgres 说明里写清 NULL 排序补写的范围(只认直接按列排的)
2026-10-19 12:06:31 +08:00

121 lines
6.9 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.
"""应用库是 PostgreSQL 时,让它在 SQLite 上写成的那些假设下照样成立。
只装在应用库那个引擎的方言实例上(db/base.make_engine),业务数据源的 PostgreSQL 引擎不受影响。
**建表的列类型。** 应用是在 SQLite 上写成的:SQLite 不查 VARCHAR 的长度,INTEGER 是 64 位。PostgreSQL 上
VARCHAR(n) 超长直接报错、INTEGER 只有 32 位。模型里的 String(n) 是写给人看的「大致多长」,从来没有被库约束过:
逐列核对只核得到测试走到的值,漏一处就是线上的写入失败,从 SQLite 导过来的存量数据里也可能已经有超长的。所以
应用库上 String(n) 建成不限长的 VARCHAR(PostgreSQL 里 varchar、varchar(n)、text 存储和性能一样,限长只多一次
检查)。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() 也按 C 的规则,只认 ASCII 的大小写(É/é、全角A/a 不认):要不分大小写地找,用
db/portable.contains_ci,模式带 COLLATE "default",按建库时的区域设置折大小写。
**ORDER BY 里 NULL 的位置。** SQLite 升序 NULL 在前、降序 NULL 在后,PostgreSQL 正好相反。应用里两百多处排序,
可空的列(开始时间、到期时间、发布时间……)排出来的先后要和 SQLite 一致:可空列上没写 nulls_first / nulls_last
的 ASC 补 NULLS FIRST、DESC 补 NULLS LAST,ORDER BY 里直接写列名(隐含 ASC)的同样补上。不可空的列不动——
补了会让索引用不上,又没有 NULL 可排。窗口函数 OVER (ORDER BY …) 里同样补。
只认直接写的列(Column,含它的 asc() / desc())。这几种不补,两种库排出来的先后可能不同,要紧的地方自己写明
nulls_first / nulls_last:label 出来的聚合(max(...) 之类)、coalesce / case 这类表达式、外连接进来的表上的列(模型里
不可空,外连接没连上时却是 NULL)、原生 text() 里的 ORDER BY(db/base 的去重语句里写明了)。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql.asyncpg import PGCompiler_asyncpg
from sqlalchemy.dialects.postgresql.base import PGTypeCompiler
from sqlalchemy.sql import operators
from sqlalchemy.sql.elements import ClauseList, UnaryExpression
#: 外层已经写了 nulls_first / nulls_last:里面那一层 ASC / DESC 不再补
_EXPLICIT = "_agentlab_explicit_nulls"
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 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)。"""
return [c.asc() if _nullable(c) else c for c in clauses]
class AppCompiler(PGCompiler_asyncpg):
def order_by_clause(self, select: Any, **kw: Any) -> str:
order_by = self._generate_delimited_list(_ordered(select._order_by_clauses), ", ", **kw)
return " ORDER BY " + order_by if order_by else ""
def visit_over(self, over: Any, **kw: Any) -> str:
clauses = list(over.order_by) if over.order_by is not None else []
if any(_nullable(c) for c in clauses):
# 在副本上换:语句对象可能被缓存、被别处共用,不能改原件
over = over._clone()
over.order_by = ClauseList(*_ordered(clauses))
return super().visit_over(over, **kw)
def visit_unary(self, unary: UnaryExpression, **kw: Any) -> str:
modifier = unary.modifier
if modifier in (operators.nulls_first_op, operators.nulls_last_op):
return super().visit_unary(unary, **{**kw, _EXPLICIT: True})
explicit = kw.pop(_EXPLICIT, False)
text = super().visit_unary(unary, **kw)
if not explicit and modifier in (operators.asc_op, operators.desc_op) and _nullable(unary.element):
text += " NULLS FIRST" if modifier is operators.asc_op else " NULLS LAST"
return text
def _has_nul(value: Any) -> bool:
return isinstance(value, str) and "\x00" in value
def _clean_row(params: Any) -> Any:
if isinstance(params, dict):
return {k: v.replace("\x00", "") if _has_nul(v) else v for k, v in params.items()} \
if any(_has_nul(v) for v in params.values()) else params
if isinstance(params, (list, tuple)) and any(_has_nul(v) for v in params):
return type(params)(v.replace("\x00", "") if _has_nul(v) else v for v in params)
return params
def strip_nul(conn: Any, cursor: Any, statement: str, parameters: Any, context: Any,
executemany: bool) -> tuple[str, Any]:
"""写进 PostgreSQL 之前去掉文本参数里的 NUL 字符(before_cursor_execute,retval=True)。
PostgreSQL 的 text / varchar 不收 \\x00(报 invalid byte sequence),SQLite 照存:一段带 NUL 的模型输出、一个从二进制
文件里粘出来的问题,在 SQLite 上写得进去,在 PostgreSQL 上整条写入失败。所以和 JSON 列(db/base.pg_json_dumps)一样
去掉。只看参数里的字符串,bytes(LargeBinary)不动;没有 NUL 的参数原样交回,不多一次拷贝。"""
if executemany:
if any(_clean_row(row) is not row for row in parameters):
return statement, [_clean_row(row) for row in parameters]
return statement, parameters
return statement, _clean_row(parameters)
def install(dialect: Any) -> None:
"""装到应用库引擎的方言实例上。"""
dialect.statement_compiler = AppCompiler
dialect.type_compiler_instance = dialect.type_compiler = AppTypeCompiler(dialect)