Skip to main content

uqa_sql/expr/
uuid.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! PostgreSQL-compatible UUID parsing, generation, and extraction.
8
9use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use uqa_core::{TemporalValue, Value};
13
14use crate::error::{Result, SQLError};
15
16use super::{out_of_range, time::timestamp_plus_interval};
17
18const NANOS_PER_MICROSECOND: i64 = 1_000;
19const NANOS_PER_MILLISECOND: i64 = 1_000_000;
20const UUID_V1_UNIX_EPOCH_OFFSET_TICKS: i128 = 0x01b2_1dd2_1381_4000;
21const UUID_V7_SUBMILLISECOND_BITS: u32 = 12;
22const UUID_V7_MAX_UNIX_MILLISECONDS: i64 = 0x0000_ffff_ffff_ffff;
23
24#[cfg(any(target_os = "macos", target_os = "windows"))]
25const UUID_V7_CLOCK_PRECISION_BITS: u32 = 10;
26#[cfg(not(any(target_os = "macos", target_os = "windows")))]
27const UUID_V7_CLOCK_PRECISION_BITS: u32 = 12;
28
29const UUID_V7_MINIMUM_STEP_NANOS: i64 =
30    NANOS_PER_MILLISECOND / (1_i64 << UUID_V7_CLOCK_PRECISION_BITS) + 1;
31static UUID_V7_PREVIOUS_NANOS: AtomicI64 = AtomicI64::new(0);
32
33pub(super) fn canonicalize_uuid(text: &str) -> Result<String> {
34    parse_uuid_bytes(text).map(format_uuid)
35}
36
37pub(super) fn extract_uuid_version(value: &Value) -> Result<Value> {
38    let bytes = uuid_value_bytes(value)?;
39    Ok(uuid_version(&bytes).map_or(Value::Null, |version| Value::Int(i64::from(version))))
40}
41
42pub(super) fn extract_uuid_timestamp(value: &Value) -> Result<Value> {
43    let bytes = uuid_value_bytes(value)?;
44    let Some(version) = uuid_version(&bytes) else {
45        return Ok(Value::Null);
46    };
47    let micros = match version {
48        1 => uuid_v1_unix_micros(&bytes),
49        7 => uuid_v7_unix_micros(&bytes),
50        _ => return Ok(Value::Null),
51    }?;
52    Ok(Value::Temporal(TemporalValue::TimestampTz { micros }))
53}
54
55pub(super) fn generate_random_uuid() -> Result<String> {
56    let mut bytes = [0u8; 16];
57    getrandom::fill(&mut bytes)
58        .map_err(|error| SQLError::Internal(format!("failed to obtain random bytes: {error}")))?;
59    bytes[6] = (bytes[6] & 0x0f) | 0x40;
60    bytes[8] = (bytes[8] & 0x3f) | 0x80;
61    Ok(format_uuid(bytes))
62}
63
64pub(super) fn generate_uuid_v7(shift: Option<&TemporalValue>) -> Result<String> {
65    let now_nanos = real_time_nanos_ascending()?;
66    let now_micros = now_nanos.div_euclid(NANOS_PER_MICROSECOND);
67    let sub_microsecond_nanos = now_nanos.rem_euclid(NANOS_PER_MICROSECOND);
68    let timestamp_micros = match shift {
69        None => now_micros,
70        Some(TemporalValue::Interval {
71            months,
72            days,
73            micros,
74        }) => timestamp_plus_interval(now_micros, *months, *days, *micros)?,
75        Some(other) => {
76            return Err(SQLError::TypeMismatch(format!(
77                "uuidv7: expected interval, got {other:?}"
78            )));
79        }
80    };
81    let unix_millis = timestamp_micros.div_euclid(1_000);
82    if !(0..=UUID_V7_MAX_UNIX_MILLISECONDS).contains(&unix_millis) {
83        return Err(out_of_range("uuidv7 timestamp"));
84    }
85    let sub_millisecond_nanos = timestamp_micros
86        .rem_euclid(1_000)
87        .checked_mul(NANOS_PER_MICROSECOND)
88        .and_then(|nanos| nanos.checked_add(sub_microsecond_nanos))
89        .ok_or_else(|| out_of_range("uuidv7 timestamp"))?;
90    let sub_millisecond_nanos =
91        u32::try_from(sub_millisecond_nanos).map_err(|_| out_of_range("uuidv7 timestamp"))?;
92    generate_uuid_v7_at(unix_millis as u64, sub_millisecond_nanos)
93}
94
95/// Parse every UUID input spelling accepted by `PostgreSQL` into network-order bytes.
96pub fn parse_uuid_bytes(text: &str) -> Result<[u8; 16]> {
97    let digits = text
98        .strip_prefix('{')
99        .and_then(|text| text.strip_suffix('}'))
100        .unwrap_or(text);
101    if digits.starts_with('{') || digits.ends_with('}') {
102        return Err(invalid_uuid(text));
103    }
104    let mut normalized = String::with_capacity(32);
105    let mut group_digits = 0_usize;
106    for character in digits.chars() {
107        if character == '-' {
108            if group_digits == 0 || !group_digits.is_multiple_of(4) {
109                return Err(invalid_uuid(text));
110            }
111            group_digits = 0;
112            continue;
113        }
114        if !character.is_ascii_hexdigit() {
115            return Err(invalid_uuid(text));
116        }
117        normalized.push(character.to_ascii_lowercase());
118        group_digits += 1;
119    }
120    if normalized.len() != 32 || group_digits == 0 {
121        return Err(invalid_uuid(text));
122    }
123    let mut bytes = [0_u8; 16];
124    for (index, pair) in normalized.as_bytes().chunks_exact(2).enumerate() {
125        bytes[index] = (hex_value(pair[0]) << 4) | hex_value(pair[1]);
126    }
127    Ok(bytes)
128}
129
130fn uuid_value_bytes(value: &Value) -> Result<[u8; 16]> {
131    match value {
132        Value::Str(text) | Value::FixedChar(text) => parse_uuid_bytes(text),
133        other => Err(SQLError::TypeMismatch(format!(
134            "expected uuid value, got {other:?}"
135        ))),
136    }
137}
138
139fn uuid_version(bytes: &[u8; 16]) -> Option<u8> {
140    ((bytes[8] & 0xc0) == 0x80).then_some(bytes[6] >> 4)
141}
142
143fn uuid_v1_unix_micros(bytes: &[u8; 16]) -> Result<i64> {
144    let low = u32::from_be_bytes(bytes[0..4].try_into().expect("UUID time_low width"));
145    let middle = u16::from_be_bytes(bytes[4..6].try_into().expect("UUID time_mid width"));
146    let high = u16::from_be_bytes(bytes[6..8].try_into().expect("UUID time_high width")) & 0x0fff;
147    let ticks = (i128::from(high) << 48) | (i128::from(middle) << 32) | i128::from(low);
148    i64::try_from((ticks - UUID_V1_UNIX_EPOCH_OFFSET_TICKS).div_euclid(10))
149        .map_err(|_| out_of_range("uuid timestamp"))
150}
151
152fn uuid_v7_unix_micros(bytes: &[u8; 16]) -> Result<i64> {
153    let milliseconds = bytes[..6]
154        .iter()
155        .fold(0_i64, |value, byte| (value << 8) | i64::from(*byte));
156    milliseconds
157        .checked_mul(1_000)
158        .ok_or_else(|| out_of_range("uuid timestamp"))
159}
160
161fn real_time_nanos_ascending() -> Result<i64> {
162    let elapsed = SystemTime::now()
163        .duration_since(UNIX_EPOCH)
164        .map_err(|_| out_of_range("uuidv7 timestamp"))?;
165    let actual = i64::try_from(elapsed.as_nanos()).map_err(|_| out_of_range("uuidv7 timestamp"))?;
166    loop {
167        let previous = UUID_V7_PREVIOUS_NANOS.load(AtomicOrdering::Relaxed);
168        let minimum = previous
169            .checked_add(UUID_V7_MINIMUM_STEP_NANOS)
170            .ok_or_else(|| out_of_range("uuidv7 timestamp"))?;
171        let candidate = if minimum >= actual { minimum } else { actual };
172        if UUID_V7_PREVIOUS_NANOS
173            .compare_exchange_weak(
174                previous,
175                candidate,
176                AtomicOrdering::Relaxed,
177                AtomicOrdering::Relaxed,
178            )
179            .is_ok()
180        {
181            return Ok(candidate);
182        }
183    }
184}
185
186fn generate_uuid_v7_at(unix_millis: u64, sub_millisecond_nanos: u32) -> Result<String> {
187    if unix_millis > UUID_V7_MAX_UNIX_MILLISECONDS as u64
188        || sub_millisecond_nanos >= NANOS_PER_MILLISECOND as u32
189    {
190        return Err(out_of_range("uuidv7 timestamp"));
191    }
192    let mut bytes = [0u8; 16];
193    getrandom::fill(&mut bytes[8..])
194        .map_err(|error| SQLError::Internal(format!("failed to obtain random bytes: {error}")))?;
195    let timestamp = unix_millis.to_be_bytes();
196    bytes[..6].copy_from_slice(&timestamp[2..]);
197    let increased_clock_precision = (u64::from(sub_millisecond_nanos)
198        * (1_u64 << UUID_V7_SUBMILLISECOND_BITS))
199        / NANOS_PER_MILLISECOND as u64;
200    bytes[6] = (increased_clock_precision >> 8) as u8;
201    bytes[7] = increased_clock_precision as u8;
202
203    #[cfg(any(target_os = "macos", target_os = "windows"))]
204    {
205        bytes[7] ^= bytes[8] >> 6;
206    }
207
208    bytes[6] = (bytes[6] & 0x0f) | 0x70;
209    bytes[8] = (bytes[8] & 0x3f) | 0x80;
210    Ok(format_uuid(bytes))
211}
212
213fn format_uuid(bytes: [u8; 16]) -> String {
214    format!(
215        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
216        bytes[0], bytes[1], bytes[2], bytes[3],
217        bytes[4], bytes[5],
218        bytes[6], bytes[7],
219        bytes[8], bytes[9],
220        bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
221    )
222}
223
224fn hex_value(byte: u8) -> u8 {
225    match byte {
226        b'0'..=b'9' => byte - b'0',
227        b'a'..=b'f' => byte - b'a' + 10,
228        _ => unreachable!("UUID parser retained only lowercase hexadecimal digits"),
229    }
230}
231
232fn invalid_uuid(text: &str) -> SQLError {
233    SQLError::Routine {
234        sqlstate: "22P02".into(),
235        message: format!("invalid input syntax for type uuid: \"{text}\""),
236    }
237}