Skip to main content

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}