Skip to main content

laminar_connectors/ibmmq/
metadata.rs

1//! Typed `MQMD` fields and message properties: selectable columns appended after the payload.
2//!
3//! Identifiers stay binary (24-byte `MsgId`, `CorrelId`, `GroupId`), numeric fields keep their
4//! MQI units and sentinels (`Expiry` in tenths of a second, `-1` unlimited), and character fields
5//! are trimmed of MQ's space padding. `UserIdentifier` and `PutApplName` are the received
6//! context as the queue manager recorded it.
7
8use std::sync::Arc;
9
10use arrow_array::builder::{
11    BinaryBuilder, FixedSizeBinaryBuilder, Int32Builder, ListBuilder, StringBuilder, StructBuilder,
12    TimestampMillisecondBuilder,
13};
14use arrow_array::ArrayRef;
15use arrow_schema::{DataType, Field, Fields, Schema, SchemaRef, TimeUnit};
16
17use crate::error::ConnectorError;
18
19/// Message properties as `(name, value bytes)`; a NULL value is `None`.
20pub(super) type RenderedProperties = Vec<(String, Option<Vec<u8>>)>;
21
22/// A received message: payload, descriptor, and properties.
23pub(super) struct Received {
24    pub(super) data: Vec<u8>,
25    pub(super) md: libmqm_sys::MQMD,
26    pub(super) properties: RenderedProperties,
27}
28
29/// One selectable metadata column, in canonical output order.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub enum MetadataColumn {
32    /// `_msg_id`: 24-byte `MsgId`.
33    MsgId,
34    /// `_correl_id`: 24-byte `CorrelId`.
35    CorrelId,
36    /// `_group_id`: 24-byte `GroupId`.
37    GroupId,
38    /// `_msg_seq_number`: `MsgSeqNumber` within a group.
39    MsgSeqNumber,
40    /// `_msg_flags`: `MsgFlags` (group and segment flags).
41    MsgFlags,
42    /// `_msg_type`: `MsgType`.
43    MsgType,
44    /// `_format`: `Format`, trimmed.
45    Format,
46    /// `_ccsid`: `CodedCharSetId` of the delivered payload.
47    Ccsid,
48    /// `_encoding`: `Encoding` of the delivered payload.
49    Encoding,
50    /// `_persistence`: `Persistence` (0 not persistent, 1 persistent).
51    Persistence,
52    /// `_priority`: `Priority`.
53    Priority,
54    /// `_expiry`: remaining `Expiry` at retrieval, in tenths of a second; `-1` is unlimited.
55    Expiry,
56    /// `_backout_count`: `BackoutCount`, the number of earlier backed-out retrievals.
57    BackoutCount,
58    /// `_reply_to_queue`: `ReplyToQ`.
59    ReplyToQueue,
60    /// `_reply_to_queue_manager`: `ReplyToQMgr`.
61    ReplyToQueueManager,
62    /// `_put_timestamp`: `PutDate` and `PutTime` (GMT) as milliseconds since the epoch.
63    PutTimestamp,
64    /// `_put_application_name`: `PutApplName`.
65    PutApplicationName,
66    /// `_user_identifier`: `UserIdentifier`.
67    UserIdentifier,
68    /// `_properties`: message properties, values rendered as bytes.
69    Properties,
70}
71
72impl MetadataColumn {
73    const ALL: [Self; 19] = [
74        Self::MsgId,
75        Self::CorrelId,
76        Self::GroupId,
77        Self::MsgSeqNumber,
78        Self::MsgFlags,
79        Self::MsgType,
80        Self::Format,
81        Self::Ccsid,
82        Self::Encoding,
83        Self::Persistence,
84        Self::Priority,
85        Self::Expiry,
86        Self::BackoutCount,
87        Self::ReplyToQueue,
88        Self::ReplyToQueueManager,
89        Self::PutTimestamp,
90        Self::PutApplicationName,
91        Self::UserIdentifier,
92        Self::Properties,
93    ];
94
95    /// Option name used in `metadata.columns`.
96    #[must_use]
97    pub const fn option_name(self) -> &'static str {
98        match self {
99            Self::MsgId => "msg_id",
100            Self::CorrelId => "correl_id",
101            Self::GroupId => "group_id",
102            Self::MsgSeqNumber => "msg_seq_number",
103            Self::MsgFlags => "msg_flags",
104            Self::MsgType => "msg_type",
105            Self::Format => "format",
106            Self::Ccsid => "ccsid",
107            Self::Encoding => "encoding",
108            Self::Persistence => "persistence",
109            Self::Priority => "priority",
110            Self::Expiry => "expiry",
111            Self::BackoutCount => "backout_count",
112            Self::ReplyToQueue => "reply_to_queue",
113            Self::ReplyToQueueManager => "reply_to_queue_manager",
114            Self::PutTimestamp => "put_timestamp",
115            Self::PutApplicationName => "put_application_name",
116            Self::UserIdentifier => "user_identifier",
117            Self::Properties => "properties",
118        }
119    }
120
121    fn field(self) -> Field {
122        let name = format!("_{}", self.option_name());
123        match self {
124            Self::MsgId | Self::CorrelId | Self::GroupId => {
125                Field::new(name, DataType::FixedSizeBinary(24), false)
126            }
127            Self::Format
128            | Self::ReplyToQueue
129            | Self::ReplyToQueueManager
130            | Self::PutApplicationName
131            | Self::UserIdentifier => Field::new(name, DataType::Utf8, false),
132            Self::PutTimestamp => {
133                Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true)
134            }
135            Self::Properties => Field::new(
136                name,
137                DataType::List(Arc::new(Field::new_struct(
138                    "item",
139                    property_fields(),
140                    false,
141                ))),
142                false,
143            ),
144            Self::MsgSeqNumber
145            | Self::MsgFlags
146            | Self::MsgType
147            | Self::Ccsid
148            | Self::Encoding
149            | Self::Persistence
150            | Self::Priority
151            | Self::Expiry
152            | Self::BackoutCount => Field::new(name, DataType::Int32, false),
153        }
154    }
155}
156
157fn property_fields() -> Fields {
158    Fields::from(vec![
159        Field::new("name", DataType::Utf8, false),
160        Field::new("value", DataType::Binary, true),
161    ])
162}
163
164/// The metadata columns a source appends, in canonical order without duplicates.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct MetadataColumns(Vec<MetadataColumn>);
167
168impl MetadataColumns {
169    /// Parse a comma-separated `metadata.columns` list such as `msg_id,backout_count`.
170    ///
171    /// # Errors
172    /// Returns a configuration error for an unknown name.
173    pub fn parse(list: Option<&str>) -> Result<Self, ConnectorError> {
174        let mut columns = Vec::new();
175        for name in list
176            .unwrap_or_default()
177            .split(',')
178            .map(str::trim)
179            .filter(|name| !name.is_empty())
180        {
181            let column = MetadataColumn::ALL
182                .into_iter()
183                .find(|column| column.option_name().eq_ignore_ascii_case(name))
184                .ok_or_else(|| {
185                    let known: Vec<&str> = MetadataColumn::ALL
186                        .iter()
187                        .map(|c| c.option_name())
188                        .collect();
189                    ConnectorError::ConfigurationError(format!(
190                        "unknown IBM MQ metadata column '{name}' (expected one of {})",
191                        known.join(", ")
192                    ))
193                })?;
194            columns.push(column);
195        }
196        columns.sort_unstable();
197        columns.dedup();
198        Ok(Self(columns))
199    }
200
201    /// Whether no metadata column is selected.
202    #[must_use]
203    pub fn is_empty(&self) -> bool {
204        self.0.is_empty()
205    }
206
207    /// Whether message properties are selected.
208    #[must_use]
209    pub fn wants_properties(&self) -> bool {
210        self.0.contains(&MetadataColumn::Properties)
211    }
212
213    /// Reject a payload field that collides, case-insensitively, with a selected column.
214    ///
215    /// # Errors
216    /// Returns a schema mismatch naming the colliding field.
217    pub fn validate_payload(&self, payload: &Schema) -> Result<(), ConnectorError> {
218        let collision = payload.fields().iter().find(|field| {
219            self.0.iter().any(|column| {
220                field
221                    .name()
222                    .eq_ignore_ascii_case(&format!("_{}", column.option_name()))
223            })
224        });
225        match collision {
226            Some(field) => Err(ConnectorError::SchemaMismatch(format!(
227                "IBM MQ payload schema contains configured metadata column '{}'",
228                field.name()
229            ))),
230            None => Ok(()),
231        }
232    }
233
234    /// The payload schema followed by the selected metadata fields.
235    #[must_use]
236    pub fn output_schema(&self, payload: &SchemaRef) -> SchemaRef {
237        if self.0.is_empty() {
238            return Arc::clone(payload);
239        }
240        let mut fields = payload.fields().to_vec();
241        fields.extend(self.0.iter().map(|column| Arc::new(column.field())));
242        Arc::new(Schema::new_with_metadata(
243            fields,
244            payload.metadata().clone(),
245        ))
246    }
247
248    /// The payload prefix of an output schema produced by [`Self::output_schema`].
249    ///
250    /// # Errors
251    /// Returns a schema mismatch when `output` does not end with the selected metadata fields.
252    pub fn payload_schema(&self, output: &SchemaRef) -> Result<SchemaRef, ConnectorError> {
253        let payload_len = output
254            .fields()
255            .len()
256            .checked_sub(self.0.len())
257            .ok_or_else(|| {
258                ConnectorError::SchemaMismatch("source schema lacks its metadata columns".into())
259            })?;
260        let payload = Arc::new(
261            output
262                .project(&(0..payload_len).collect::<Vec<_>>())
263                .map_err(|error| ConnectorError::SchemaMismatch(error.to_string()))?,
264        );
265        if self.output_schema(&payload) != *output {
266            return Err(ConnectorError::SchemaMismatch(
267                "IBM MQ source schema does not end with its configured metadata columns".into(),
268            ));
269        }
270        Ok(payload)
271    }
272
273    /// Build the selected metadata arrays, one row per message.
274    pub(super) fn arrays(&self, messages: &[&Received]) -> Result<Vec<ArrayRef>, ConnectorError> {
275        self.0
276            .iter()
277            .map(|column| column_array(*column, messages))
278            .collect()
279    }
280}
281
282impl std::fmt::Display for MetadataColumns {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        let names: Vec<&str> = self.0.iter().map(|column| column.option_name()).collect();
285        f.write_str(&names.join(","))
286    }
287}
288
289/// MQ character fields are platform `MQCHAR` (signed or unsigned); padding is spaces or NULs.
290pub(super) fn text(field: &[mqi::types::MQCHAR]) -> String {
291    let bytes: Vec<u8> = field
292        .iter()
293        .map(|character| u8::from_ne_bytes(character.to_ne_bytes()))
294        .collect();
295    String::from_utf8_lossy(&bytes)
296        .trim_end_matches([' ', '\0'])
297        .to_string()
298}
299
300fn column_array(
301    column: MetadataColumn,
302    messages: &[&Received],
303) -> Result<ArrayRef, ConnectorError> {
304    let count = messages.len();
305    let id = |value: fn(&libmqm_sys::MQMD) -> &[u8; 24]| -> Result<ArrayRef, ConnectorError> {
306        let mut builder = FixedSizeBinaryBuilder::with_capacity(count, 24);
307        for message in messages {
308            builder
309                .append_value(value(&message.md))
310                .map_err(|error| ConnectorError::Internal(error.to_string()))?;
311        }
312        Ok(Arc::new(builder.finish()))
313    };
314    let int = |value: fn(&libmqm_sys::MQMD) -> i32| -> ArrayRef {
315        let mut builder = Int32Builder::with_capacity(count);
316        for message in messages {
317            builder.append_value(value(&message.md));
318        }
319        Arc::new(builder.finish())
320    };
321    let string = |value: fn(&libmqm_sys::MQMD) -> String| -> ArrayRef {
322        let mut builder = StringBuilder::with_capacity(count, count * 16);
323        for message in messages {
324            builder.append_value(value(&message.md));
325        }
326        Arc::new(builder.finish())
327    };
328    Ok(match column {
329        MetadataColumn::MsgId => id(|md| &md.MsgId)?,
330        MetadataColumn::CorrelId => id(|md| &md.CorrelId)?,
331        MetadataColumn::GroupId => id(|md| &md.GroupId)?,
332        MetadataColumn::MsgSeqNumber => int(|md| md.MsgSeqNumber),
333        MetadataColumn::MsgFlags => int(|md| md.MsgFlags),
334        MetadataColumn::MsgType => int(|md| md.MsgType),
335        MetadataColumn::Format => string(|md| text(&md.Format)),
336        MetadataColumn::Ccsid => int(|md| md.CodedCharSetId),
337        MetadataColumn::Encoding => int(|md| md.Encoding),
338        MetadataColumn::Persistence => int(|md| md.Persistence),
339        MetadataColumn::Priority => int(|md| md.Priority),
340        MetadataColumn::Expiry => int(|md| md.Expiry),
341        MetadataColumn::BackoutCount => int(|md| md.BackoutCount),
342        MetadataColumn::ReplyToQueue => string(|md| text(&md.ReplyToQ)),
343        MetadataColumn::ReplyToQueueManager => string(|md| text(&md.ReplyToQMgr)),
344        MetadataColumn::PutApplicationName => string(|md| text(&md.PutApplName)),
345        MetadataColumn::UserIdentifier => string(|md| text(&md.UserIdentifier)),
346        MetadataColumn::PutTimestamp => {
347            let mut builder = TimestampMillisecondBuilder::with_capacity(count);
348            for message in messages {
349                builder.append_option(put_timestamp(
350                    &text(&message.md.PutDate),
351                    &text(&message.md.PutTime),
352                ));
353            }
354            Arc::new(builder.finish())
355        }
356        MetadataColumn::Properties => properties_array(messages),
357    })
358}
359
360/// `PutDate` `YYYYMMDD` and `PutTime` `HHMMSSTH` (hundredths), GMT; `None` when blank or invalid.
361pub(super) fn put_timestamp(date: &str, time: &str) -> Option<i64> {
362    let date = chrono::NaiveDate::parse_from_str(date, "%Y%m%d").ok()?;
363    if time.len() != 8 || !time.bytes().all(|byte| byte.is_ascii_digit()) {
364        return None;
365    }
366    let field = |range: std::ops::Range<usize>| time[range].parse::<u32>().ok();
367    let moment =
368        date.and_hms_milli_opt(field(0..2)?, field(2..4)?, field(4..6)?, field(6..8)? * 10)?;
369    Some(moment.and_utc().timestamp_millis())
370}
371
372fn properties_array(messages: &[&Received]) -> ArrayRef {
373    let entry = StructBuilder::new(
374        property_fields(),
375        vec![
376            Box::new(StringBuilder::new()),
377            Box::new(BinaryBuilder::new()),
378        ],
379    );
380    let mut builder = ListBuilder::new(entry).with_field(Arc::new(Field::new_struct(
381        "item",
382        property_fields(),
383        false,
384    )));
385    for message in messages {
386        for (name, value) in &message.properties {
387            let entry = builder.values();
388            entry
389                .field_builder::<StringBuilder>(0)
390                .expect("property name builder is Utf8")
391                .append_value(name);
392            entry
393                .field_builder::<BinaryBuilder>(1)
394                .expect("property value builder is Binary")
395                .append_option(value.as_deref());
396            entry.append(true);
397        }
398        builder.append(true);
399    }
400    Arc::new(builder.finish())
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn put_timestamps_use_hundredths_and_reject_blanks() {
409        assert_eq!(
410            put_timestamp("20261017", "10305912"),
411            Some(
412                chrono::NaiveDate::from_ymd_opt(2026, 10, 17)
413                    .unwrap()
414                    .and_hms_milli_opt(10, 30, 59, 120)
415                    .unwrap()
416                    .and_utc()
417                    .timestamp_millis()
418            )
419        );
420        assert_eq!(put_timestamp("", ""), None);
421        assert_eq!(put_timestamp("20261017", "2530xx00"), None);
422    }
423
424    #[test]
425    fn character_fields_trim_mq_padding() {
426        let raw: Vec<mqi::types::MQCHAR> = b"MQSTR   "
427            .iter()
428            .map(|byte| mqi::types::MQCHAR::from_ne_bytes(byte.to_ne_bytes()))
429            .collect();
430        assert_eq!(text(&raw), "MQSTR");
431    }
432
433    #[test]
434    fn metadata_columns_are_canonical_and_round_trip() {
435        let columns = MetadataColumns::parse(Some("backout_count,msg_id,MSG_ID")).unwrap();
436        assert_eq!(columns.to_string(), "msg_id,backout_count");
437        let payload = Arc::new(Schema::new(vec![Field::new("v", DataType::Binary, true)]));
438        let output = columns.output_schema(&payload);
439        assert_eq!(output.field(1).data_type(), &DataType::FixedSizeBinary(24));
440        assert_eq!(columns.payload_schema(&output).unwrap(), payload);
441        assert!(MetadataColumns::parse(Some("offset")).is_err());
442    }
443}