Rust in Everything
基于 sea-orm 2.0.4

column "level" is of type smallint but expression is of type "char"

主线位置:定义主线和写入主线的交界。一个 i8 字段在定义主线上得到 ColumnType::TinyInteger,建表时写成 smallint;在写入主线上变成 Value::TinyInt,绑到 sqlx 时用的是 sqlx 给 i8 定的 Postgres 类型 "char"。两条线对同一个字段给出了两种类型。Value 到 sqlx 参数的对应表见绑到 sqlx。

flowchart TB
  fld["字段 level: i8"] --> ct["ColumnType TinyInteger"]
  fld --> val["Value TinyInt"]
  ct --> ddl["建表: smallint"]
  val --> bind["sqlx: i8 是 char"]
  ddl --> clash["smallint 对 char"]
  bind --> clash
  clash --> e1["INSERT: 42804"]
  clash --> e2["WHERE: 42883"]
  clash --> e3["读取: mismatched types"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4",对照组 =1.1.20。

现象

src/bin/i8char.rs 用一个宏定义几个只有两列的实体(id: i32 主键加 level),level 分别是 i8、i16、u8。表由 Schema::create_table_from_entity 按实体建,也就是 sea-orm 自己认为正确的表:

level_entity!(tiny, "tiny", i8);
level_entity!(small, "small", i16);
level_entity!(unsigned, "unsigned8", u8);

// ...
  let create = schema.create_table_from_entity(tiny::Entity);
  println!("{}", db.get_database_backend().build(&create));
  db.execute(&create).await?;
  let r = tiny::Entity::insert(tiny::ActiveModel { id: Set(1), level: Set(1) }).exec(&db).await;
  println!("insert: {r:?}");
  db.execute_unprepared("INSERT INTO tiny VALUES (1, 1)").await?;
  show("filter", tiny::Entity::find().filter(tiny::Column::Level.eq(1_i8)).all(&db).await);
  show("select", tiny::Entity::find_by_id(1).one(&db).await);

输出(sqlx 日志只留了 INSERT 那一条):

=== i8
CREATE TABLE "tiny" ( "id" integer NOT NULL PRIMARY KEY, "level" smallint NOT NULL )
 INFO sqlx::query: summary="INSERT INTO \"tiny\" (\"id\", …" db.statement="\n\nINSERT INTO \"tiny\" (\"id\", \"level\") VALUES ($1, $2) RETURNING \"id\"\n" rows_affected=0 rows_returned=0 elapsed=1.79525ms elapsed_secs=0.00179525
insert: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42804", message: "column \"level\" is of type smallint but expression is of type \"char\"", detail: None, hint: Some("You will need to rewrite or cast the expression."), position: Some(Original(48)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_target.c"), line: Some(586), routine: Some("transformAssignedExpr") }))))
filter: Query Error: error returned from database: operator does not exist: smallint = "char" at line 647
select: Query Error: error occurred while decoding column "level": mismatched types; Rust type `core::option::Option<i8>` (as SQL type `"CHAR"`) is not compatible with SQL type `INT2`

三处都坏:

  • 插入:$2 的类型是 "char",Postgres 没有从 "char" 到 smallint 的赋值转换,报 42804。
  • 过滤:smallint = "char" 没有这个运算符,报 42883。
  • 读取:行是手工用 SQL 插进去的,服务端返回 INT2,sqlx 检查 Rust 类型 i8 对应的 "CHAR" 和它不兼容,在解码前就拒绝了。Option<i8> 是因为 sea-orm 总是向 sqlx 要 Option<T>(列与值)。

本站用的 1.1.20(exp-seaorm-pit1-old/src/bin/i8char_old.rs,同样的实体,展开写法):

CREATE TABLE "tiny" ( "id" integer NOT NULL PRIMARY KEY, "level" smallint NOT NULL )
insert: Execution Error: error returned from database: column "level" is of type smallint but expression is of type "char"
filter: Query Error: error returned from database: operator does not exist: smallint = "char"
select: Query Error: error occurred while decoding column "level": mismatched types; Rust type `core::option::Option<i8>` (as SQL type `"CHAR"`) is not compatible with SQL type `INT2`

建表和三处报错都一样,只是插入那一条在 1.1.20 里包成 DbErr::Exec(Execution Error),2.0.4 里是 DbErr::Query。

出处

一个 i8 在三个地方各被翻译一次。

sea-query 的 type_to_value!(i8, TinyInt, TinyInteger) 定下 i8 的值是 Value::TinyInt、列类型是 ColumnType::TinyInteger。Postgres 没有 1 字节整数,Postgres 的建表代码把它升成 smallint:

            ColumnType::TinyInteger | ColumnType::TinyUnsigned => sql.write_str("smallint"),

绑定时,sea-query-sqlx 的 Value::TinyInt 分支把 Option<i8> 原样交给 sqlx,没有升宽;隔几行的无符号分支是升宽了的:

                Value::TinyInt(i) => {
                    let _ = args.add(i);
                }
                // ...
                Value::TinyUnsigned(i) => {
                    let _ = args.add(i.map(|i| i as i16));
                }

sqlx 的 impl Type<Postgres> for i8 返回 PgTypeInfo::CHAR,也就是 Postgres 内部那个单字节的 "char"(OID 18)。它的 Decode 里有一句注释说明了这个选择:「decoding here is for the "char" type as Postgres does not have a native 1-byte integer type」。所以建表的一侧说 smallint,绑定和解码的一侧说 "char",两边谁都没错,合在一起就不能用。

上游报过几次,都没有改:

  • SeaQL/sea-orm#2632(「postgresql int2 mapped i8 error」,2025-06 提出,仍开着):和上面一样,用 create_table_from_entity 建表,读回时报同一句 mismatched types。
  • SeaQL/sea-query#884(「When using Postgres, the i8 type cannot be queried. It indicates that i8 does not match the char type.」):以 not planned 关闭。
  • SeaQL/sea-orm#1673(「i8 enum value is mistaken for a char in postgresql」):DeriveActiveEnum 写 rs_type = "i8",插入 integer 列报 column "xx" is of type integer but expression is of type "char"。维护者的回答是「changing the rs_type to any integer types except i8 will solve the problem. This limitation comes from the fact that Postgres treat i8 as char.」,问题按已解决关闭,代码没有改。
📝NOTE
没有蠢问题

问: 无符号的 u8 在绑定时升成了 i16,那把字段换成 u8 行不行?

答: 能写,不能读。同一个程序的最后一段:

=== u8
 INFO sqlx::query: summary="INSERT INTO \"unsigned8\" (\"id\", …" db.statement="\n\nINSERT INTO \"unsigned8\" (\"id\", \"level\") VALUES ($1, $2) RETURNING \"id\"\n" rows_affected=1 rows_returned=1 elapsed=1.228375ms elapsed_secs=0.001228375
insert: Ok(1)
 INFO sqlx::query: summary="SELECT \"unsigned8\".\"id\", \"unsigned8\".\"level\" FROM …" db.statement="\n\nSELECT \"unsigned8\".\"id\", \"unsigned8\".\"level\" FROM \"unsigned8\" WHERE \"unsigned8\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=567.125µs elapsed_secs=0.000567125
select: Type Error: u8 unsupported by sqlx-postgres

读取走 sea-orm 的 try_getable_unsigned!,它的 Postgres 分支不去读,直接返回 "{} unsupported by sqlx-postgres"。所以 u8 字段在 Postgres 上只写得进去,find 一律失败。

修法

字段换成 i16,和 smallint 一一对应。同一个程序的 i16 段:

=== i16
 INFO sqlx::query: summary="CREATE TABLE \"small\" ( …" db.statement="\n\nCREATE TABLE \"small\" ( \"id\" integer NOT NULL PRIMARY KEY, \"level\" smallint NOT NULL )\n" rows_affected=0 rows_returned=0 elapsed=3.893084ms elapsed_secs=0.003893084
 INFO sqlx::query: summary="INSERT INTO \"small\" (\"id\", …" db.statement="\n\nINSERT INTO \"small\" (\"id\", \"level\") VALUES ($1, $2) RETURNING \"id\"\n" rows_affected=1 rows_returned=1 elapsed=1.328042ms elapsed_secs=0.001328042
insert: Ok(1)
 INFO sqlx::query: summary="SELECT \"small\".\"id\", \"small\".\"level\" FROM …" db.statement="\n\nSELECT \"small\".\"id\", \"small\".\"level\" FROM \"small\" WHERE \"small\".\"level\" = $1\n" rows_affected=1 rows_returned=1 elapsed=640.292µs elapsed_secs=0.000640292
filter: Ok([Model { id: 1, level: 1 }])
 INFO sqlx::query: summary="SELECT \"small\".\"id\", \"small\".\"level\" FROM …" db.statement="\n\nSELECT \"small\".\"id\", \"small\".\"level\" FROM \"small\" WHERE \"small\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=586µs elapsed_secs=0.000586
select: Ok(Some(Model { id: 1, level: 1 }))

表结构不用动:i8 建出来的本来就是 smallint,改的只是 Rust 字段的类型。值域要限制在 -128..=127 的,在 ActiveModelBehavior::before_save 或数据库的 CHECK 约束里做,不要靠字段类型。DeriveActiveEnum 同理,rs_type 用 i16 或 i32,不用 "i8"。

怎么确认没中招

  • 在实体目录里找 : i8、Option<i8>、: u8、Option<u8> 字段和 rs_type = "i8" / "u8" 的 DeriveActiveEnum。只要后端是 Postgres,这些都是坏的,而且编译期没有任何提示。
  • 本站:按固定提交 89a61b1 在 crates/ 下找,实体里没有 i8 / u8 字段,也没有 DeriveActiveEnum,不受影响。

小结

  • i8 在 sea-query 里是 TinyInt / TinyInteger,Postgres 建表升成 smallint;绑定和解码却按 sqlx 给 i8 定的 "char"(OID 18)走。
  • 结果是插入报 42804、过滤报 42883 operator does not exist: smallint = "char"、读取报 mismatched types,2.0.4 和 1.1.20 一样。
  • 上游有 sea-orm #2632(开着)、sea-query #884(not planned)、sea-orm #1673(回答是「除了 i8 都行」),代码没改。
  • u8 绑定时升成 i16,写得进去,但读取报 u8 unsupported by sqlx-postgres。
  • 修法:Postgres 上整数字段用 i16 起步,表不用改。

💬 关联讨论

发起讨论
加载中...