Skip to main content

oxisql_postgres/
types.rs

1//! Type conversion between `tokio-postgres` values and `oxisql_core` types.
2//!
3//! Supported Postgres types -> OxiSQL [`Value`] mapping:
4//!
5//! | Postgres OID group | OxiSQL variant |
6//! |---|---|
7//! | BOOL | `Value::Bool` |
8//! | INT2, INT4 | `Value::I64` (widened) |
9//! | INT8 | `Value::I64` |
10//! | FLOAT4 | `Value::F64` (widened) |
11//! | FLOAT8 | `Value::F64` |
12//! | TEXT, VARCHAR, BPCHAR, NAME | `Value::Text` |
13//! | BYTEA | `Value::Blob` |
14//! | DATE | `Value::Date` (days since Unix epoch 1970-01-01) |
15//! | TIMESTAMP | `Value::Timestamp` (microseconds since Unix epoch, no tz) |
16//! | TIMESTAMPTZ | `Value::Timestamp` (microseconds since Unix epoch, UTC) |
17//! | TIME | `Value::Time` (microseconds since midnight) |
18//! | UUID | `Value::Uuid` (u128 big-endian) |
19//! | JSON, JSONB | `Value::Json` |
20//! | NUMERIC | `Value::Decimal` (exact decimal string) |
21//! | BOOL[] / INT2[] / INT4[] / INT8[] / FLOAT4[] / FLOAT8[] / TEXT[] / VARCHAR[] | `Value::Array(Vec<Value>)` |
22//! | INTERVAL | `Value::Text` (HH:MM:SS or "N months N days HH:MM:SS") |
23//! | NULL (any) | `Value::Null` |
24//! | everything else | `Value::Text` via type-name marker |
25
26use bytes::{Buf, BytesMut};
27use tokio_postgres::types::{FromSql, IsNull, ToSql, Type};
28use tokio_postgres::Row as PgRow;
29
30use oxisql_core::{Row, Value};
31
32use crate::error::PgError;
33
34// ── NUMERIC binary decoder ────────────────────────────────────────────────────
35
36/// Newtype that captures raw Postgres NUMERIC bytes via a custom [`FromSql`] impl.
37///
38/// Postgres NUMERIC binary format:
39/// - 2 bytes (big-endian u16): `ndigits` — number of base-10000 digit groups
40/// - 2 bytes (big-endian i16): `weight` — index of the most-significant digit group
41///   (weight=0 means the group represents 1..9999)
42/// - 2 bytes (big-endian u16): `sign`   — 0x0000 positive, 0x4000 negative, 0xC000 NaN
43/// - 2 bytes (big-endian u16): `dscale` — number of decimal digits after the point
44/// - `ndigits` × 2 bytes (big-endian u16): digit groups, each 0..9999
45struct RawNumeric(Vec<u8>);
46
47impl<'a> FromSql<'a> for RawNumeric {
48    fn from_sql(
49        _ty: &Type,
50        raw: &'a [u8],
51    ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
52        Ok(RawNumeric(raw.to_vec()))
53    }
54
55    fn accepts(ty: &Type) -> bool {
56        *ty == Type::NUMERIC
57    }
58}
59
60/// Decode a Postgres NUMERIC binary payload into a decimal string.
61///
62/// Returns `"NaN"` for Postgres NaN values.  Returns an error if the byte
63/// buffer is too short or otherwise malformed.
64fn decode_pg_numeric(raw: &[u8]) -> Result<String, PgError> {
65    if raw.len() < 8 {
66        return Err(PgError::TypeConversion(format!(
67            "NUMERIC binary too short: {} bytes",
68            raw.len()
69        )));
70    }
71
72    let mut buf = raw;
73
74    let ndigits = buf.get_u16() as usize;
75    let weight = buf.get_i16(); // signed: negative means pure fraction
76    let sign = buf.get_u16();
77    let dscale = buf.get_u16() as usize;
78
79    const SIGN_NAN: u16 = 0xC000;
80    const SIGN_NEG: u16 = 0x4000;
81
82    if sign == SIGN_NAN {
83        return Ok("NaN".to_string());
84    }
85
86    // Collect digit groups (each is a base-10000 value).
87    let mut digits: Vec<u16> = Vec::with_capacity(ndigits);
88    for _ in 0..ndigits {
89        if buf.remaining() < 2 {
90            return Err(PgError::TypeConversion(
91                "NUMERIC binary truncated while reading digit groups".to_string(),
92            ));
93        }
94        digits.push(buf.get_u16());
95    }
96
97    // Build the integer part: digit groups at positions weight, weight-1, …, 0
98    // (each group contributes 4 decimal digits; the highest group may have fewer).
99    //
100    // A group at weight position `w` represents the range:
101    //   10000^w  …  10000^(w+1) - 1
102    //
103    // We synthesise from the most-significant group down to weight=0, then
104    // continue with fractional groups.
105
106    let mut integer_part = String::new();
107    let mut fraction_part = String::new();
108
109    // Number of groups that lie at weight >= 0 (integer part).
110    let int_groups = (weight + 1).max(0) as usize;
111
112    // Fill integer groups, padding with zeros for missing leading groups.
113    // We zip a range with padded digits so clippy doesn't see needless indexing.
114    let int_digit_iter = (0..int_groups).map(|i| if i < ndigits { digits[i] } else { 0 });
115    for (i, digit) in int_digit_iter.enumerate() {
116        if i == 0 {
117            // First group: no leading zeros.
118            integer_part.push_str(&digit.to_string());
119        } else {
120            // Subsequent groups: always 4 digits with leading zeros.
121            integer_part.push_str(&format!("{digit:04}"));
122        }
123    }
124
125    if integer_part.is_empty() {
126        integer_part.push('0');
127    }
128
129    // Fractional groups start at index `int_groups` in the digits array.
130    // We need `dscale` decimal digits total in the fraction.
131    if dscale > 0 {
132        let mut remaining_scale = dscale;
133        let frac_groups_needed = dscale.div_ceil(4);
134
135        for i in 0..frac_groups_needed {
136            let digit_idx = int_groups + i;
137            let digit = if digit_idx < ndigits {
138                digits[digit_idx]
139            } else {
140                0
141            };
142            let group_str = format!("{digit:04}");
143            // Only append as many digits as dscale demands.
144            let take = remaining_scale.min(4);
145            fraction_part.push_str(&group_str[..take]);
146            remaining_scale = remaining_scale.saturating_sub(4);
147        }
148    }
149
150    let mut result = if sign == SIGN_NEG {
151        format!("-{integer_part}")
152    } else {
153        integer_part
154    };
155
156    if !fraction_part.is_empty() {
157        result.push('.');
158        result.push_str(&fraction_part);
159    }
160
161    Ok(result)
162}
163
164// ── INTERVAL binary decoder ───────────────────────────────────────────────────
165
166/// Newtype that captures raw Postgres INTERVAL bytes via a custom [`FromSql`] impl.
167///
168/// Postgres INTERVAL binary format (16 bytes total):
169/// - 8 bytes (big-endian i64): microseconds part
170/// - 4 bytes (big-endian i32): days part
171/// - 4 bytes (big-endian i32): months part
172struct RawInterval(Vec<u8>);
173
174impl<'a> FromSql<'a> for RawInterval {
175    fn from_sql(
176        _ty: &Type,
177        raw: &'a [u8],
178    ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
179        Ok(RawInterval(raw.to_vec()))
180    }
181
182    fn accepts(ty: &Type) -> bool {
183        *ty == Type::INTERVAL
184    }
185}
186
187/// Decode a Postgres INTERVAL binary payload into a human-readable string.
188///
189/// Produces a representation like `"1Y 2M 3D 01:30:00"` or just `"01:30:00"`.
190fn decode_pg_interval(raw: &[u8]) -> Result<String, PgError> {
191    if raw.len() < 16 {
192        return Err(PgError::TypeConversion(format!(
193            "INTERVAL binary too short: {} bytes",
194            raw.len()
195        )));
196    }
197    let mut buf = raw;
198    let micros = buf.get_i64();
199    let days = buf.get_i32();
200    let months = buf.get_i32();
201
202    let years = months / 12;
203    let rem_months = months % 12;
204    let hours = micros / 3_600_000_000;
205    let rem_micros = micros % 3_600_000_000;
206    let minutes = rem_micros / 60_000_000;
207    let rem_micros2 = rem_micros % 60_000_000;
208    let secs = rem_micros2 / 1_000_000;
209    let frac_micros = (rem_micros2 % 1_000_000).unsigned_abs();
210
211    let mut parts: Vec<String> = Vec::new();
212    if years != 0 {
213        parts.push(format!("{years}Y"));
214    }
215    if rem_months != 0 {
216        parts.push(format!("{rem_months}M"));
217    }
218    if days != 0 {
219        parts.push(format!("{days}D"));
220    }
221
222    let has_time = hours != 0 || minutes != 0 || secs != 0 || frac_micros != 0;
223    if has_time {
224        if frac_micros > 0 {
225            parts.push(format!(
226                "{hours:02}:{minutes:02}:{secs:02}.{frac_micros:06}"
227            ));
228        } else {
229            parts.push(format!("{hours:02}:{minutes:02}:{secs:02}"));
230        }
231    }
232
233    if parts.is_empty() {
234        Ok("00:00:00".to_string())
235    } else {
236        Ok(parts.join(" "))
237    }
238}
239
240// ── Parameter encoding ────────────────────────────────────────────────────────
241
242/// Owned SQL parameter that implements `ToSql`.
243///
244/// `tokio-postgres` expects `&[&(dyn ToSql + Sync)]` parameters.  Since our
245/// source values live in a `Vec<Value>`, we first convert them to this owned
246/// enum, collect references, then pass a slice.
247#[derive(Debug)]
248pub enum OwnedParam {
249    /// SQL NULL.
250    Null,
251    /// Boolean.
252    Bool(bool),
253    /// 64-bit signed integer.
254    I64(i64),
255    /// 64-bit float.
256    F64(f64),
257    /// UTF-8 string.
258    Text(String),
259    /// Binary blob.
260    Blob(Vec<u8>),
261}
262
263impl ToSql for OwnedParam {
264    fn to_sql(
265        &self,
266        ty: &Type,
267        out: &mut BytesMut,
268    ) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
269        match self {
270            OwnedParam::Null => Ok(IsNull::Yes),
271            OwnedParam::Bool(v) => v.to_sql(ty, out),
272            OwnedParam::I64(v) => {
273                if *ty == Type::INT4 {
274                    let v4 = i32::try_from(*v)
275                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Sync + Send>)?;
276                    v4.to_sql(ty, out)
277                } else if *ty == Type::INT2 {
278                    let v2 = i16::try_from(*v)
279                        .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Sync + Send>)?;
280                    v2.to_sql(ty, out)
281                } else {
282                    v.to_sql(ty, out)
283                }
284            }
285            OwnedParam::F64(v) => {
286                if *ty == Type::FLOAT4 {
287                    #[allow(clippy::cast_possible_truncation)]
288                    let v4 = *v as f32;
289                    v4.to_sql(ty, out)
290                } else {
291                    v.to_sql(ty, out)
292                }
293            }
294            OwnedParam::Text(v) => v.to_sql(ty, out),
295            OwnedParam::Blob(v) => v.to_sql(ty, out),
296        }
297    }
298
299    fn accepts(_ty: &Type) -> bool
300    where
301        Self: Sized,
302    {
303        // We accept any type at this stage; incompatibility will produce an
304        // error from the underlying concrete `to_sql` call.
305        true
306    }
307
308    tokio_postgres::types::to_sql_checked!();
309}
310
311/// Convert an [`oxisql_core::Value`] to an [`OwnedParam`].
312///
313/// Extended types (Timestamp, Date, Time, Uuid, Json, Decimal, Array) are
314/// converted to their string representation and sent as Text parameters.
315/// The Postgres server handles the implicit cast for typed columns.
316pub fn value_to_param(v: &Value) -> OwnedParam {
317    match v {
318        Value::Null => OwnedParam::Null,
319        Value::Bool(b) => OwnedParam::Bool(*b),
320        Value::I64(n) => OwnedParam::I64(*n),
321        Value::F64(f) => OwnedParam::F64(*f),
322        Value::Text(s) => OwnedParam::Text(s.clone()),
323        Value::Blob(b) => OwnedParam::Blob(b.clone()),
324        // Extended types: send as text, Postgres handles the cast
325        Value::Timestamp(us) => {
326            let secs = us / 1_000_000;
327            let frac = (us % 1_000_000).unsigned_abs();
328            OwnedParam::Text(format!("{secs}.{frac:06}"))
329        }
330        Value::Date(days) => OwnedParam::Text(format!("{days}")),
331        Value::Time(us) => {
332            let total_secs = us / 1_000_000;
333            let hours = total_secs / 3600;
334            let mins = (total_secs % 3600) / 60;
335            let secs = total_secs % 60;
336            let frac = (us % 1_000_000).unsigned_abs();
337            if frac == 0 {
338                OwnedParam::Text(format!("{hours:02}:{mins:02}:{secs:02}"))
339            } else {
340                OwnedParam::Text(format!("{hours:02}:{mins:02}:{secs:02}.{frac:06}"))
341            }
342        }
343        Value::Uuid(u) => OwnedParam::Text(format!("{}", Value::Uuid(*u))),
344        Value::Json(s) => OwnedParam::Text(s.clone()),
345        Value::Decimal(s) => OwnedParam::Text(s.clone()),
346        Value::Array(vals) => {
347            // Render as Postgres array literal: {val1,val2,...}
348            let items: Vec<String> = vals.iter().map(|v| format!("{v}")).collect();
349            OwnedParam::Text(format!("{{{}}}", items.join(",")))
350        }
351    }
352}
353
354// ── Row decoding ──────────────────────────────────────────────────────────────
355
356/// Convert a `tokio_postgres` [`PgRow`] into an `oxisql_core` [`Row`].
357///
358/// Column types are read from the row's `Column` descriptors so each value
359/// is extracted using the most specific supported type.  Unsupported column
360/// types fall back to a placeholder [`Value::Text`].
361pub fn pg_row_to_row(pg_row: PgRow) -> Result<Row, PgError> {
362    let cols = pg_row.columns();
363    let mut names = Vec::with_capacity(cols.len());
364    let mut values = Vec::with_capacity(cols.len());
365
366    for (i, col) in cols.iter().enumerate() {
367        names.push(col.name().to_string());
368        let value = extract_value(&pg_row, i, col.type_())?;
369        values.push(value);
370    }
371
372    Ok(Row::new(names, values))
373}
374
375fn extract_value(row: &PgRow, idx: usize, ty: &Type) -> Result<Value, PgError> {
376    match *ty {
377        Type::BOOL => {
378            let v: Option<bool> = row
379                .try_get(idx)
380                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
381            Ok(v.map(Value::Bool).unwrap_or(Value::Null))
382        }
383        Type::INT2 => {
384            let v: Option<i16> = row
385                .try_get(idx)
386                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
387            Ok(v.map(|n| Value::I64(i64::from(n))).unwrap_or(Value::Null))
388        }
389        Type::INT4 => {
390            let v: Option<i32> = row
391                .try_get(idx)
392                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
393            Ok(v.map(|n| Value::I64(i64::from(n))).unwrap_or(Value::Null))
394        }
395        Type::INT8 => {
396            let v: Option<i64> = row
397                .try_get(idx)
398                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
399            Ok(v.map(Value::I64).unwrap_or(Value::Null))
400        }
401        Type::FLOAT4 => {
402            let v: Option<f32> = row
403                .try_get(idx)
404                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
405            Ok(v.map(|f| Value::F64(f64::from(f))).unwrap_or(Value::Null))
406        }
407        Type::FLOAT8 => {
408            let v: Option<f64> = row
409                .try_get(idx)
410                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
411            Ok(v.map(Value::F64).unwrap_or(Value::Null))
412        }
413        Type::BYTEA => {
414            let v: Option<Vec<u8>> = row
415                .try_get(idx)
416                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
417            Ok(v.map(Value::Blob).unwrap_or(Value::Null))
418        }
419        // JSON and JSONB: extract as String and wrap in Value::Json
420        Type::JSON | Type::JSONB => {
421            let v: Option<String> = row
422                .try_get(idx)
423                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
424            Ok(v.map(Value::Json).unwrap_or(Value::Null))
425        }
426        // DATE: extract as time::Date, convert to days since Unix epoch (1970-01-01).
427        //
428        // `time::Date::to_julian_day()` returns the proleptic Gregorian Julian Day Number:
429        //   - 1970-01-01 → 2_440_588
430        //   - 2000-01-01 → 2_451_545
431        //
432        // `Value::Date` stores days since Unix epoch, so we subtract the JDN of the epoch.
433        Type::DATE => {
434            let v: Option<time::Date> = row
435                .try_get(idx)
436                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
437            Ok(v.map(|d| {
438                const UNIX_EPOCH_JDN: i32 = 2_440_588; // Julian Day Number of 1970-01-01
439                Value::Date(d.to_julian_day() - UNIX_EPOCH_JDN)
440            })
441            .unwrap_or(Value::Null))
442        }
443        // TIMESTAMP (no time zone): extract as PrimitiveDateTime, treat as UTC.
444        Type::TIMESTAMP => {
445            let v: Option<time::PrimitiveDateTime> = row
446                .try_get(idx)
447                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
448            Ok(v.map(|dt| {
449                // assume_utc() converts PrimitiveDateTime to OffsetDateTime at UTC offset.
450                let us = dt.assume_utc().unix_timestamp_nanos() / 1_000;
451                #[allow(clippy::cast_possible_truncation)]
452                Value::Timestamp(us as i64)
453            })
454            .unwrap_or(Value::Null))
455        }
456        // TIMESTAMPTZ: extract as OffsetDateTime (already carries its UTC offset).
457        Type::TIMESTAMPTZ => {
458            let v: Option<time::OffsetDateTime> = row
459                .try_get(idx)
460                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
461            Ok(v.map(|dt| {
462                let us = dt.unix_timestamp_nanos() / 1_000;
463                #[allow(clippy::cast_possible_truncation)]
464                Value::Timestamp(us as i64)
465            })
466            .unwrap_or(Value::Null))
467        }
468        // TIME (no time zone): microseconds since midnight.
469        Type::TIME => {
470            let v: Option<time::Time> = row
471                .try_get(idx)
472                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
473            Ok(v.map(|t| {
474                let (h, m, s, ns) = t.as_hms_nano();
475                let us = i64::from(h) * 3_600_000_000
476                    + i64::from(m) * 60_000_000
477                    + i64::from(s) * 1_000_000
478                    + i64::from(ns) / 1_000;
479                Value::Time(us)
480            })
481            .unwrap_or(Value::Null))
482        }
483        // UUID: extract as uuid::Uuid, store as big-endian u128.
484        Type::UUID => {
485            let v: Option<uuid::Uuid> = row
486                .try_get(idx)
487                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
488            Ok(v.map(|u| Value::Uuid(u.as_u128())).unwrap_or(Value::Null))
489        }
490        // NUMERIC: decode binary payload manually into an exact decimal string.
491        Type::NUMERIC => {
492            let v: Option<RawNumeric> = row
493                .try_get(idx)
494                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
495            match v {
496                None => Ok(Value::Null),
497                Some(raw) => {
498                    let s = decode_pg_numeric(&raw.0)?;
499                    Ok(Value::Decimal(s))
500                }
501            }
502        }
503        // INTERVAL: decode binary payload manually into a human-readable string.
504        Type::INTERVAL => {
505            let v: Option<RawInterval> = row
506                .try_get(idx)
507                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
508            match v {
509                None => Ok(Value::Null),
510                Some(raw) => {
511                    let s = decode_pg_interval(&raw.0)?;
512                    Ok(Value::Text(s))
513                }
514            }
515        }
516        // ARRAY types: each maps to Value::Array(Vec<Value>).
517        Type::BOOL_ARRAY => {
518            let v: Option<Vec<Option<bool>>> = row
519                .try_get(idx)
520                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
521            Ok(v.map(|items| {
522                Value::Array(
523                    items
524                        .into_iter()
525                        .map(|opt| opt.map(Value::Bool).unwrap_or(Value::Null))
526                        .collect(),
527                )
528            })
529            .unwrap_or(Value::Null))
530        }
531        Type::INT2_ARRAY => {
532            let v: Option<Vec<Option<i16>>> = row
533                .try_get(idx)
534                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
535            Ok(v.map(|items| {
536                Value::Array(
537                    items
538                        .into_iter()
539                        .map(|opt| opt.map(|n| Value::I64(i64::from(n))).unwrap_or(Value::Null))
540                        .collect(),
541                )
542            })
543            .unwrap_or(Value::Null))
544        }
545        Type::INT4_ARRAY => {
546            let v: Option<Vec<Option<i32>>> = row
547                .try_get(idx)
548                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
549            Ok(v.map(|items| {
550                Value::Array(
551                    items
552                        .into_iter()
553                        .map(|opt| opt.map(|n| Value::I64(i64::from(n))).unwrap_or(Value::Null))
554                        .collect(),
555                )
556            })
557            .unwrap_or(Value::Null))
558        }
559        Type::INT8_ARRAY => {
560            let v: Option<Vec<Option<i64>>> = row
561                .try_get(idx)
562                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
563            Ok(v.map(|items| {
564                Value::Array(
565                    items
566                        .into_iter()
567                        .map(|opt| opt.map(Value::I64).unwrap_or(Value::Null))
568                        .collect(),
569                )
570            })
571            .unwrap_or(Value::Null))
572        }
573        Type::FLOAT4_ARRAY => {
574            let v: Option<Vec<Option<f32>>> = row
575                .try_get(idx)
576                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
577            Ok(v.map(|items| {
578                Value::Array(
579                    items
580                        .into_iter()
581                        .map(|opt| opt.map(|f| Value::F64(f64::from(f))).unwrap_or(Value::Null))
582                        .collect(),
583                )
584            })
585            .unwrap_or(Value::Null))
586        }
587        Type::FLOAT8_ARRAY => {
588            let v: Option<Vec<Option<f64>>> = row
589                .try_get(idx)
590                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
591            Ok(v.map(|items| {
592                Value::Array(
593                    items
594                        .into_iter()
595                        .map(|opt| opt.map(Value::F64).unwrap_or(Value::Null))
596                        .collect(),
597                )
598            })
599            .unwrap_or(Value::Null))
600        }
601        Type::TEXT_ARRAY | Type::VARCHAR_ARRAY => {
602            let v: Option<Vec<Option<String>>> = row
603                .try_get(idx)
604                .map_err(|e| PgError::TypeConversion(e.to_string()))?;
605            Ok(v.map(|items| {
606                Value::Array(
607                    items
608                        .into_iter()
609                        .map(|opt| opt.map(Value::Text).unwrap_or(Value::Null))
610                        .collect(),
611                )
612            })
613            .unwrap_or(Value::Null))
614        }
615        // TEXT, VARCHAR, BPCHAR, NAME, UNKNOWN and anything String-compatible.
616        ref other => {
617            if <String as FromSql>::accepts(other) {
618                let v: Option<String> = row
619                    .try_get(idx)
620                    .map_err(|e| PgError::TypeConversion(e.to_string()))?;
621                Ok(v.map(Value::Text).unwrap_or(Value::Null))
622            } else {
623                // Unknown type: return an opaque placeholder rather than failing.
624                Ok(Value::Text(format!("<opaque:{}>", other.name())))
625            }
626        }
627    }
628}