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}