Skip to main content

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`]
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`]
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`](crate::Error) with
202/// [`ErrorCode::InvalidDecimal`](crate::ErrorCode::InvalidDecimal)
203/// if the string contains non-numerical characters.
204impl<'a> TryInto<DecimalView<'a>> for &'a str {
205    type Error = crate::Error;
206
207    fn try_into(self) -> Result<DecimalView<'a>> {
208        DecimalView::try_new_string(self)
209    }
210}
211
212#[cfg(feature = "rust_decimal")]
213impl<'a> TryInto<DecimalView<'a>> for &'a rust_decimal::Decimal {
214    type Error = crate::Error;
215
216    fn try_into(self) -> Result<DecimalView<'a>> {
217        let raw = self.mantissa().to_be_bytes();
218        let bytes = trim_leading_sign_bytes(&raw);
219        DecimalView::try_new_scaled(self.scale(), bytes)
220    }
221}
222
223#[cfg(feature = "bigdecimal")]
224impl<'a> TryInto<DecimalView<'a>> for &'a bigdecimal::BigDecimal {
225    type Error = crate::Error;
226
227    fn try_into(self) -> Result<DecimalView<'a>> {
228        let (unscaled, mut scale) = self.as_bigint_and_scale();
229
230        // QuestDB binary ILP doesn't support negative scale, we need to upscale the
231        // unscaled value to be compliant
232        let bytes = if scale < 0 {
233            use bigdecimal::num_bigint;
234            let unscaled =
235                unscaled.into_owned() * num_bigint::BigInt::from(10).pow((-scale) as u32);
236            scale = 0;
237            unscaled.to_signed_bytes_be()
238        } else {
239            unscaled.to_signed_bytes_be()
240        };
241
242        let bytes = trim_leading_sign_bytes(&bytes);
243
244        DecimalView::try_new_scaled(scale as u32, bytes)
245    }
246}
247
248#[cfg(any(feature = "rust_decimal", feature = "bigdecimal"))]
249fn trim_leading_sign_bytes(bytes: &[u8]) -> Vec<u8> {
250    if bytes.is_empty() {
251        return vec![0];
252    }
253
254    let negative = bytes[0] & 0x80 != 0;
255    let mut keep_from = 0usize;
256
257    while keep_from < bytes.len() - 1 {
258        let current = bytes[keep_from];
259        let next = bytes[keep_from + 1];
260
261        let should_trim = if negative {
262            current == 0xFF && (next & 0x80) == 0x80
263        } else {
264            current == 0x00 && (next & 0x80) == 0x00
265        };
266
267        if should_trim {
268            keep_from += 1;
269        } else {
270            break;
271        }
272    }
273
274    bytes[keep_from..].to_vec()
275}