wp_arrow/contract/mod.rs
1//! 线协议契约:`wp_model_core::model::DataType` → Arrow 列类型,以及值层(`DataRecord` → 列)。
2//!
3//! 这是 **wparse(sink 侧)↔ wfusion(接收侧)Arrow 列类型契约**的实现,
4//! 规格表见 `wp-reactor/docs/design/arrow-type-mapping.md`(§3 是逐行口径表,§4 是已知差异登记)。
5//!
6//! 两层:
7//! - [`wp_type_to_arrow`](本模块)—— 表:某类型该是什么 Arrow 列;
8//! - [`value`] —— 值:值怎么写进那一列([`encode_record`] / [`encode_records`])。
9//! - 两层都按 **Arrow 列类型**说事,所以口径只有一处:先查表得到列类型,值层只认列类型。
10//!
11//! # 归属(A-2)
12//!
13//! 本模块是契约实现的**目标落点**。迁移前它住在 `wp-connector-utils`
14//! (`arrow::wp_type_to_arrow`)——那是「面向 sink 的 connector 工具」crate,
15//! 把线协议契约放在那里是**定位倒置**:按自我声明去找权威实现的人会找到
16//! `wp-arrow`(`schema.rs`/`convert.rs` 的 9 变体类型化前端),拿到的是另一套口径,
17//! 于2026-09-19 报出「`wp-arrow` 与 `wp-connector-utils` 不一致」(wp-labs/warp-fusion#102)。
18//!
19//! **迁移状态**:本模块已落地(A-2 2a),值层已随 2c 迁入([`value`]);
20//! `wp-connector-utils` 的 `arrow::wp_type_to_arrow` / `arrow::record` 已改为转发到这里,
21//! 并经 `wp-arrow` 发布版进入生产(跨仓发布顺序见规格表 §5)。
22//!
23//! # 不要把本表与 [`crate::schema`] 混为一谈
24//!
25//! [`crate::schema::WpDataType`] 是 9 变体的**类型化前端**,它的 `Array` → `List(inner)`、
26//! `BigInt` → `Decimal256(39,0)` 是它自己的口径(保留结构化/数值语义),**与线协议无关**
27//! (规格表 §1 A-0、§4 DIV-2/DIV-3)。契约口径是**保守的**:结构化字段一律 `Utf8`(JSON 文本),
28//! 任意精度整数一律十进制 `Utf8`。
29
30use arrow::datatypes::{DataType, TimeUnit};
31
32pub mod value;
33
34pub use value::{encode_record, encode_records};
35
36/// `wp_model_core::model::DataType` → Arrow 列类型(**线协议契约口径**)。
37///
38/// match 是**穷尽的**(无 `_` 兜底):`wp-model-core` 新增变体会直接**编译失败** ——
39/// 这是刻意的,逼对新类型表态,而不是静默兜到 `Utf8`。
40///
41/// 每个变体的理由与已知差异(DIV-1/2/3)见 `wp-reactor/docs/design/arrow-type-mapping.md`。
42pub fn wp_type_to_arrow(dt: &wp_model_core::model::DataType) -> DataType {
43 use wp_model_core::model::DataType as WpDt;
44 match dt {
45 WpDt::Bool => DataType::Boolean,
46 WpDt::Int => DataType::Int64,
47 // 任意精度整数(BigUint):以十进制字符串输出(与 format_utf8_value 的 to_string 一致)
48 WpDt::BigInt => DataType::Utf8,
49 WpDt::Float => DataType::Float64,
50 WpDt::Port => DataType::Int32,
51 WpDt::Time
52 | WpDt::TimeISO
53 | WpDt::TimeRFC3339
54 | WpDt::TimeRFC2822
55 | WpDt::TimeTIMESTAMP
56 | WpDt::TimeCLF => DataType::Timestamp(TimeUnit::Nanosecond, None),
57 // DIV-1(已修复):`hex` 走 Utf8(十六进制字符串)。期望侧(`wf-runtime`)与
58 // `wp-arrow` 的类型化前端都是 Utf8,且 `Value::Hex` 的 `Display` 就是 `{:#X}`
59 // (`wp-model-core` primitive.rs),与 sink 值层的 Utf8 输出同形 ——
60 // 所以只需这一行对齐,值层不用改。规格表:DIV-1。
61 WpDt::Hex => DataType::Utf8,
62 WpDt::Base64 => DataType::Binary,
63 WpDt::Chars
64 | WpDt::Symbol
65 | WpDt::PeekSymbol
66 | WpDt::IP
67 | WpDt::IpNet
68 | WpDt::Domain
69 | WpDt::Email
70 | WpDt::Url
71 | WpDt::SN
72 | WpDt::IdCard
73 | WpDt::MobilePhone
74 | WpDt::KV
75 | WpDt::KvArr
76 | WpDt::Json
77 | WpDt::ExactJson
78 | WpDt::HttpRequest
79 | WpDt::HttpStatus
80 | WpDt::HttpAgent
81 | WpDt::HttpMethod
82 | WpDt::Auto
83 | WpDt::ProtoText
84 | WpDt::Obj
85 | WpDt::Ignore => DataType::Utf8,
86 // DIV-3:结构化数组走 `Utf8`(JSON 文本),**不带** `wfl_field_type` 元数据;
87 // 接收侧对该形态返回「兼容」,语义在 coerce 阶段兑现(有意设计,规格表 §4)。
88 WpDt::Array(_) => DataType::Utf8,
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 /// 规格表钉桩:`wp_type_to_arrow` 的**全 37 个** `DataType` 变体映射。
97 ///
98 /// 规格表:`wp-reactor/docs/design/arrow-type-mapping.md` §3。该表是 sink 侧与
99 /// 接收侧(`wf-runtime`)之间 Arrow 列类型契约的单一事实来源;本测试把它钉死,
100 /// 使任何口径漂移都以测试失败暴露,而不是线上静默出错。
101 ///
102 /// match 是穷尽的(无 `_` 兜底),所以 wp-model-core 新增变体会直接编译失败;
103 /// `cases.len()` 断言则保证本表与文档同步更新。
104 ///
105 /// 迁移期(A-2 第 2 步完成前)`wp-connector-utils` 也有一份同样的钉桩 —— 两份同时
106 /// 存在是刻意的:任一侧漂移都会在**两个**仓里各报一次,直到第 2 步把那份删掉。
107 /// ⚠️ 该编译期守卫的前提:`wp_model_core::model::DataType` **不是** `#[non_exhaustive]`
108 /// (2026-09 现状如此)。若上游改成 non_exhaustive,下游会被迫补 `_` 兜底,守卫就**静默失效**
109 /// (而 `cases.len()` 仍为 37)——那时必须把 `_` 分支改成显式拒绝,或在 `_` 上挂
110 /// `deny(non_exhaustive_omitted_patterns)`。
111 #[test]
112 fn wire_contract_full_mapping_is_pinned() {
113 use wp_model_core::model::{ArraySubtype, DataType as WpDt};
114
115 let ts = DataType::Timestamp(TimeUnit::Nanosecond, None);
116 let cases: Vec<(WpDt, DataType)> = vec![
117 (WpDt::Bool, DataType::Boolean),
118 (WpDt::Chars, DataType::Utf8),
119 (WpDt::Symbol, DataType::Utf8),
120 (WpDt::PeekSymbol, DataType::Utf8),
121 (WpDt::Int, DataType::Int64),
122 // DIV-2:wp-arrow 的**类型化前端**为 Decimal256(39,0),契约是十进制字符串
123 (WpDt::BigInt, DataType::Utf8),
124 (WpDt::Float, DataType::Float64),
125 // `Ignore` 本身映 Utf8;"剔除 Ignore 字段" 的语义在 sink 侧的 schema 推断里,
126 // 本模块只做类型映射,不做字段取舍
127 (WpDt::Ignore, DataType::Utf8),
128 (WpDt::Time, ts.clone()),
129 (WpDt::TimeISO, ts.clone()),
130 (WpDt::TimeRFC3339, ts.clone()),
131 (WpDt::TimeRFC2822, ts.clone()),
132 (WpDt::TimeTIMESTAMP, ts.clone()),
133 (WpDt::TimeCLF, ts.clone()),
134 (WpDt::IP, DataType::Utf8),
135 (WpDt::IpNet, DataType::Utf8),
136 (WpDt::Domain, DataType::Utf8),
137 (WpDt::Email, DataType::Utf8),
138 (WpDt::Port, DataType::Int32),
139 (WpDt::SN, DataType::Utf8),
140 // DIV-1(已修复):Hex 走 Utf8(十六进制字符串)
141 (WpDt::Hex, DataType::Utf8),
142 (WpDt::Base64, DataType::Binary),
143 (WpDt::KV, DataType::Utf8),
144 (WpDt::KvArr, DataType::Utf8),
145 (WpDt::Json, DataType::Utf8),
146 (WpDt::ExactJson, DataType::Utf8),
147 (WpDt::HttpRequest, DataType::Utf8),
148 (WpDt::HttpStatus, DataType::Utf8),
149 (WpDt::HttpAgent, DataType::Utf8),
150 (WpDt::HttpMethod, DataType::Utf8),
151 (WpDt::Url, DataType::Utf8),
152 (WpDt::Auto, DataType::Utf8),
153 (WpDt::ProtoText, DataType::Utf8),
154 // DIV-3:结构化字段只给 Utf8 且不带 `wfl_field_type` 元数据(有意设计)
155 (WpDt::Obj, DataType::Utf8),
156 (WpDt::Array(ArraySubtype::new("int")), DataType::Utf8),
157 (WpDt::IdCard, DataType::Utf8),
158 (WpDt::MobilePhone, DataType::Utf8),
159 ];
160
161 assert_eq!(
162 cases.len(),
163 37,
164 "wp-model-core DataType 变体数变化 → 同步更新规格表 §3"
165 );
166 for (dt, expected) in cases {
167 assert_eq!(wp_type_to_arrow(&dt), expected, "DataType::{dt:?}");
168 }
169 }
170
171 /// DIV-1 回归:`hex` 必须是 `Utf8`(十六进制字符串),不是 `Binary`。
172 ///
173 /// 断链症状:sink 侧若退回 `Binary`,接收侧期望 `Utf8` 且 `hex` 不带结构化元数据
174 /// → 走严格相等 → `arrow source schema mismatch`;即使绕过校验,`coerce_column`
175 /// 的 `_ => NullArray` 也会把整列变 null。
176 #[test]
177 fn hex_must_be_utf8() {
178 assert_eq!(
179 wp_type_to_arrow(&wp_model_core::model::DataType::Hex),
180 DataType::Utf8
181 );
182 }
183
184 /// 与本 crate 的类型化前端**刻意不同**的两行:契约是保守口径,前端保留结构与数值语义。
185 ///
186 /// 这个 `assert_ne!`/差异断言是防呆:若有人「顺手」把两边对齐,这里会失败 ——
187 /// 对齐本身不是目标,目标是不让人把 [`crate::schema`] 当成契约
188 /// (规格表 §1 A-0 / §4 DIV-2·DIV-3)。
189 ///
190 /// 差异**只有两行**(`BigInt` / `Array`);其余重叠行反而应当一致 —— 一并钉住,
191 /// 免得读者以为前端「到处都是另一套口径」。
192 #[test]
193 fn wire_contract_differs_from_the_typed_frontend_on_two_rows() {
194 use crate::schema::{BIGINT_DECIMAL_PRECISION, WpDataType, to_arrow_type};
195 use wp_model_core::model::{ArraySubtype, DataType as WpDt};
196
197 // ── 仅有的两行差异 ──
198 // DIV-2 BigInt:契约十进制字符串 vs 前端 Decimal256(39,0)(数值语义)
199 assert_eq!(wp_type_to_arrow(&WpDt::BigInt), DataType::Utf8);
200 assert_eq!(
201 to_arrow_type(&WpDataType::BigInt),
202 DataType::Decimal256(BIGINT_DECIMAL_PRECISION, 0)
203 );
204
205 // DIV-3 结构化数组:契约 Utf8(JSON 文本) vs 前端 List(inner)(逐元素带类型)
206 assert_eq!(
207 wp_type_to_arrow(&WpDt::Array(ArraySubtype::new("int"))),
208 DataType::Utf8
209 );
210 assert!(
211 matches!(
212 to_arrow_type(&WpDataType::Array(Box::new(WpDataType::Digit))),
213 DataType::List(_)
214 ),
215 "前端对结构化数组给 List(inner)(它自己的口径)"
216 );
217 }
218
219 /// 两侧**都能表达**的重叠行必须一致(差异只限于上面那两行)。
220 ///
221 /// 这栏是给下一个读者看的:前端的价值在于“保留结构/数值语义的强类型 API”,
222 /// 不在于另一套列类型口径 —— 除 BigInt/Array 外,它与契约同口径。
223 #[test]
224 fn wire_contract_and_frontend_agree_on_the_overlap() {
225 use crate::schema::{WpDataType, to_arrow_type};
226 use wp_model_core::model::DataType as WpDt;
227
228 let rows = [
229 (WpDt::Bool, WpDataType::Bool),
230 (WpDt::Int, WpDataType::Digit),
231 (WpDt::Float, WpDataType::Float),
232 (WpDt::Time, WpDataType::Time),
233 (WpDt::Chars, WpDataType::Chars),
234 (WpDt::IP, WpDataType::Ip),
235 // DIV-1:Hex 两边都是 Utf8(十六进制字符串)
236 (WpDt::Hex, WpDataType::Hex),
237 ];
238 for (model, frontend) in rows {
239 assert_eq!(
240 wp_type_to_arrow(&model),
241 to_arrow_type(&frontend),
242 "重叠行应一致:{model:?} vs {frontend:?}"
243 );
244 }
245 }
246}