Skip to main content

wp_arrow/
contract.rs

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