questdb/ingress/
decimal.rs

1/*******************************************************************************
2 *     ___                  _   ____  ____
3 *    / _ \ _   _  ___  ___| |_|  _ \| __ )
4 *   | | | | | | |/ _ \/ __| __| | | |  _ \
5 *   | |_| | |_| |  __/\__ \ |_| |_| | |_) |
6 *    \__\_\\__,_|\___||___/\__|____/|____/
7 *
8 *  Copyright (c) 2014-2019 Appsicle
9 *  Copyright (c) 2019-2025 QuestDB
10 *
11 *  Licensed under the Apache License, Version 2.0 (the "License");
12 *  you may not use this file except in compliance with the License.
13 *  You may obtain a copy of the License at
14 *
15 *  http://www.apache.org/licenses/LICENSE-2.0
16 *
17 *  Unless required by applicable law or agreed to in writing, software
18 *  distributed under the License is distributed on an "AS IS" BASIS,
19 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 *  See the License for the specific language governing permissions and
21 *  limitations under the License.
22 *
23 ******************************************************************************/
24
25use crate::{Result, error};
26use std::borrow::Cow;
27
28/// A decimal value backed by either a string representation or a scaled mantissa.
29///
30/// Decimal values can be serialized in two formats:
31///
32/// ### Text Format
33/// The decimal is written as a string representation followed by a `'d'` suffix.
34///
35/// Example: `"123.45d"` or `"1.5e-3d"`
36///
37/// ### Binary Format
38/// A more compact binary encoding consisting of:
39///
40/// 1. Binary format marker: `'='` (0x3D)
41/// 2. Type identifier: [`DECIMAL_BINARY_FORMAT_TYPE`](crate::ingress::DECIMAL_BINARY_FORMAT_TYPE) byte
42/// 3. Scale: 1 byte (0-76 inclusive) - number of decimal places
43/// 4. Length: 1 byte - number of bytes in the unscaled value
44/// 5. Unscaled value: variable-length byte array in two's complement format, big-endian
45///
46/// Example: For decimal `123.45` with scale 2:
47/// ```text
48/// Unscaled value: 12345
49/// Binary representation:
50///   = [23] [2] [2] [0x30] [0x39]
51///   │  │    │   │  └───────────┘
52///   │  │    │   │        └─ Mantissa bytes (12345 in big-endian)
53///   │  │    │   └─ Length: 2 bytes
54///   │  │    └─ Scale: 2
55///   │  └─ Type: DECIMAL_BINARY_FORMAT_TYPE (23)
56///   └─ Binary marker: '='
57/// ```
58///
59/// #### Binary Format Notes
60/// - The unscaled value must be encoded in two's complement big-endian format
61/// - Maximum scale is 76
62/// - Length byte indicates how many bytes follow for the unscaled value
63#[derive(Debug)]
64pub enum DecimalView<'a> {
65    String { value: &'a str },
66    Scaled { scale: u8, value: Cow<'a, [u8]> },
67}
68
69impl<'a> DecimalView<'a> {
70    /// Creates a [`DecimalView::Scaled`] from a mantissa buffer and scale.
71    ///
72    /// Validates that:
73    /// - `scale` does not exceed the QuestDB maximum of 76 decimal places.
74    /// - The mantissa fits into at most 32 bytes (ILP binary limit).
75    ///
76    /// Returns an [`error::ErrorCode::InvalidDecimal`](crate::error::ErrorCode::InvalidDecimal)
77    /// error if either constraint is violated.
78    pub fn try_new_scaled<T>(scale: u32, value: T) -> Result<Self>
79    where
80        T: Into<Cow<'a, [u8]>>,
81    {
82        if scale > 76 {
83            return Err(error::fmt!(
84                InvalidDecimal,
85                "QuestDB ILP does not support decimal scale greater than 76, got {}",
86                scale
87            ));
88        }
89        let value: Cow<'a, [u8]> = value.into();
90        if value.len() > 32_usize {
91            return Err(error::fmt!(
92                InvalidDecimal,
93                "QuestDB ILP does not support decimal longer than 32 bytes, got {}",
94                value.len()
95            ));
96        }
97        Ok(DecimalView::Scaled {
98            scale: scale as u8,
99            value,
100        })
101    }
102
103    /// Creates a [`DecimalView::String`] from a textual decimal representation.
104    ///
105    /// Thousand separators (commas) are not allowed and the decimal point must be a dot (`.`).
106    ///
107    /// Performs lightweight validation and rejects values containing ILP-reserved characters.
108    /// Accepts plain decimals, optional `+/-` prefixes, `NaN`, `Infinity`, and scientific
109    /// notation (`e`/`E`).
110    ///
111    /// Returns [`error::ErrorCode::InvalidDecimal`](crate::error::ErrorCode::InvalidDecimal)
112    /// if disallowed characters are encountered.
113    pub fn try_new_string(value: &'a str) -> Result<Self> {
114        // Basic validation: ensure only numerical characters are present (accepts NaN, Inf[inity], and e-notation)
115        for b in value.chars() {
116            match b {
117                '0'..='9'
118                | '.'
119                | '-'
120                | '+'
121                | 'e'
122                | 'E'
123                | 'N'
124                | 'a'
125                | 'I'
126                | 'n'
127                | 'f'
128                | 'i'
129                | 't'
130                | 'y' => {}
131                _ => {
132                    return Err(error::fmt!(
133                        InvalidDecimal,
134                        "Decimal string contains ILP reserved character {:?}",
135                        b
136                    ));
137                }
138            }
139        }
140        Ok(DecimalView::String { value })
141    }
142
143    /// Serializes the decimal view into the provided output buffer using the ILP encoding.
144    ///
145    /// Delegates to [`serialize_string`] for textual representations and [`serialize_scaled`] for
146    /// the compact binary format.
147    pub(crate) fn serialize(&self, out: &mut Vec<u8>) {
148        match self {
149            DecimalView::String { value } => Self::serialize_string(value, out),
150            DecimalView::Scaled { scale, value } => {
151                Self::serialize_scaled(*scale, value.as_ref(), out)
152            }
153        }
154    }
155
156    /// Serializes a textual decimal by copying the string and appending the `d` suffix.
157    fn serialize_string(value: &str, out: &mut Vec<u8>) {
158        // Pre-allocate space for the string content plus the 'd' suffix
159        out.reserve(value.len() + 1);
160
161        out.extend_from_slice(value.as_bytes());
162
163        // Append the 'd' suffix to mark this as a decimal value
164        out.push(b'd');
165    }
166
167    /// Serializes a scaled decimal into the binary ILP format, writing the marker, type tag,
168    /// scale, mantissa length, and mantissa bytes.
169    fn serialize_scaled(scale: u8, value: &[u8], out: &mut Vec<u8>) {
170        // Write binary format: '=' marker + type + scale + length + mantissa bytes
171        out.push(b'=');
172        out.push(crate::ingress::DECIMAL_BINARY_FORMAT_TYPE);
173        out.push(scale);
174        out.push(value.len() as u8);
175        out.extend_from_slice(value);
176    }
177}
178
179/// Implementation for string slices containing decimal representations.
180///
181/// This implementation uses the text format.
182///
183/// # Format
184/// The string is validated and written as-is, followed by the 'd' suffix. Thousand separators
185/// (commas) are not allowed and the decimal point must be a dot (`.`).
186///
187/// # Validation
188/// The implementation performs **partial validation only**:
189/// - Rejects non-numerical characters (not -/+, 0-9, ., Infinity, NaN, e/E)
190/// - Does NOT validate the actual decimal syntax (e.g., "e2e" would pass)
191///
192/// This is intentional: full parsing would add overhead. The QuestDB server performs complete
193/// validation and will reject malformed decimals.
194///
195/// # Examples
196/// - `"123.45"` → `"123.45d"`
197/// - `"1.5e-3"` → `"1.5e-3d"`
198/// - `"-0.001"` → `"-0.001d"`
199///
200/// # Errors
201/// Returns [`Error`] with [`ErrorCode::InvalidDecimal`](crate::error::ErrorCode::InvalidDecimal)
202/// if the string contains non-numerical characters.
203impl<'a> TryInto<DecimalView<'a>> for &'a str {
204    type Error = crate::Error;
205
206    fn try_into(self) -> Result<DecimalView<'a>> {
207        DecimalView::try_new_string(self)
208    }
209}
210
211#[cfg(feature = "rust_decimal")]
212impl<'a> TryInto<DecimalView<'a>> for &'a rust_decimal::Decimal {
213    type Error = crate::Error;
214
215    fn try_into(self) -> Result<DecimalView<'a>> {
216        let raw = self.mantissa().to_be_bytes();
217        let bytes = trim_leading_sign_bytes(&raw);
218        DecimalView::try_new_scaled(self.scale(), bytes)
219    }
220}
221
222#[cfg(feature = "bigdecimal")]
223impl<'a> TryInto<DecimalView<'a>> for &'a bigdecimal::BigDecimal {
224    type Error = crate::Error;
225
226    fn try_into(self) -> Result<DecimalView<'a>> {
227        let (unscaled, mut scale) = self.as_bigint_and_scale();
228
229        // QuestDB binary ILP doesn't support negative scale, we need to upscale the
230        // unscaled value to be compliant
231        let bytes = if scale < 0 {
232            use bigdecimal::num_bigint;
233            let unscaled =
234                unscaled.into_owned() * num_bigint::BigInt::from(10).pow((-scale) as u32);
235            scale = 0;
236            unscaled.to_signed_bytes_be()
237        } else {
238            unscaled.to_signed_bytes_be()
239        };
240
241        let bytes = trim_leading_sign_bytes(&bytes);
242
243        DecimalView::try_new_scaled(scale as u32, bytes)
244    }
245}
246
247#[cfg(any(feature = "rust_decimal", feature = "bigdecimal"))]
248fn trim_leading_sign_bytes(bytes: &[u8]) -> Vec<u8> {
249    if bytes.is_empty() {
250        return vec![0];
251    }
252
253    let negative = bytes[0] & 0x80 != 0;
254    let mut keep_from = 0usize;
255
256    while keep_from < bytes.len() - 1 {
257        let current = bytes[keep_from];
258        let next = bytes[keep_from + 1];
259
260        let should_trim = if negative {
261            current == 0xFF && (next & 0x80) == 0x80
262        } else {
263            current == 0x00 && (next & 0x80) == 0x00
264        };
265
266        if should_trim {
267            keep_from += 1;
268        } else {
269            break;
270        }
271    }
272
273    bytes[keep_from..].to_vec()
274}