#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Volatility {
Immutable,
Stable,
Volatile,
}
const BUILTIN_VOLATILITY: &[(&str, Volatility)] = &[
("CURRENT_TIMESTAMP", Volatility::Stable),
("clock_timestamp()", Volatility::Volatile),
("current_date", Volatility::Stable),
("current_time", Volatility::Stable),
("current_timestamp", Volatility::Stable),
("gen_random_uuid()", Volatility::Volatile),
("now()", Volatility::Stable),
("random()", Volatility::Volatile),
("statement_timestamp()", Volatility::Stable),
("transaction_timestamp()", Volatility::Stable),
];
pub fn classify_default_expression(expr: &str) -> Volatility {
let trimmed = expr.trim();
if is_literal_shape(trimmed) {
return Volatility::Immutable;
}
if let Ok(idx) = BUILTIN_VOLATILITY.binary_search_by_key(&trimmed, |(name, _)| *name) {
return BUILTIN_VOLATILITY[idx].1;
}
Volatility::Volatile
}
fn is_literal_shape(expr: &str) -> bool {
let bytes = expr.as_bytes();
if bytes.is_empty() {
return false;
}
if expr.eq_ignore_ascii_case("true")
|| expr.eq_ignore_ascii_case("false")
|| expr.eq_ignore_ascii_case("null")
{
return true;
}
if is_single_quoted_run(bytes, 0) {
return true;
}
if bytes.len() >= 3
&& (bytes[0] == b'E' || bytes[0] == b'e')
&& is_single_e_string_run(bytes, 1)
{
return true;
}
if is_single_dollar_quoted_run(bytes) {
return true;
}
is_numeric_literal(bytes)
}
fn is_single_quoted_run(bytes: &[u8], start: usize) -> bool {
if start + 2 > bytes.len() {
return false;
}
if bytes[start] != b'\'' {
return false;
}
let mut idx = start + 1;
while idx < bytes.len() {
if bytes[idx] == b'\'' {
if idx + 1 < bytes.len() && bytes[idx + 1] == b'\'' {
idx += 2;
continue;
}
return idx + 1 == bytes.len();
}
idx += 1;
}
false
}
fn is_single_e_string_run(bytes: &[u8], start: usize) -> bool {
if start + 2 > bytes.len() {
return false;
}
if bytes[start] != b'\'' {
return false;
}
let mut idx = start + 1;
while idx < bytes.len() {
if bytes[idx] == b'\\' {
if idx + 1 >= bytes.len() {
return false;
}
idx += 2;
continue;
}
if bytes[idx] == b'\'' {
if idx + 1 < bytes.len() && bytes[idx + 1] == b'\'' {
idx += 2;
continue;
}
return idx + 1 == bytes.len();
}
idx += 1;
}
false
}
fn is_single_dollar_quoted_run(bytes: &[u8]) -> bool {
if bytes.is_empty() || bytes[0] != b'$' {
return false;
}
let mut tag_end = 1;
while tag_end < bytes.len() && bytes[tag_end] != b'$' {
tag_end += 1;
}
if tag_end >= bytes.len() {
return false;
}
let tag = &bytes[..=tag_end];
if bytes.len() < tag.len() * 2 {
return false;
}
let close_start = bytes.len() - tag.len();
if &bytes[close_start..] != tag {
return false;
}
let body = &bytes[tag.len()..close_start];
if body.windows(tag.len()).any(|w| w == tag) {
return false;
}
true
}
fn is_numeric_literal(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
let mut idx: usize = 0;
if bytes[0] == b'+' || bytes[0] == b'-' {
idx += 1;
}
let int_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == int_start {
if idx >= bytes.len() || bytes[idx] != b'.' {
return false;
}
}
if idx < bytes.len() && bytes[idx] == b'.' {
idx += 1;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
}
if idx < bytes.len() && (bytes[idx] == b'e' || bytes[idx] == b'E') {
idx += 1;
if idx < bytes.len() && (bytes[idx] == b'+' || bytes[idx] == b'-') {
idx += 1;
}
let exp_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == exp_start {
return false;
}
}
idx == bytes.len()
}
#[cfg(test)]
mod tests {
use super::{BUILTIN_VOLATILITY, Volatility, classify_default_expression, is_literal_shape};
#[test]
fn builtin_volatility_table_is_sorted() {
for window in BUILTIN_VOLATILITY.windows(2) {
assert!(
window[0].0 < window[1].0,
"BUILTIN_VOLATILITY entries out of order: {:?} should sort before {:?}",
window[0].0,
window[1].0,
);
}
}
#[test]
fn builtin_volatility_table_has_no_duplicate_keys() {
for window in BUILTIN_VOLATILITY.windows(2) {
assert_ne!(
window[0].0, window[1].0,
"BUILTIN_VOLATILITY contains duplicate key {:?}",
window[0].0
);
}
}
#[test]
fn string_literals_classify_as_immutable() {
assert_eq!(
classify_default_expression("'hello'"),
Volatility::Immutable
);
assert_eq!(classify_default_expression("''"), Volatility::Immutable);
assert_eq!(
classify_default_expression("E'with\\nescape'"),
Volatility::Immutable
);
assert_eq!(
classify_default_expression("$tag$dollar quoted$tag$"),
Volatility::Immutable
);
}
#[test]
fn e_string_with_escaped_quote_classifies_as_immutable() {
assert_eq!(
classify_default_expression(r"E'it\'s'"),
Volatility::Immutable
);
assert_eq!(
classify_default_expression(r"E'tab\there'"),
Volatility::Immutable
);
assert_eq!(
classify_default_expression("E'don''t'"),
Volatility::Immutable
);
}
#[test]
fn numeric_literals_classify_as_immutable() {
assert_eq!(classify_default_expression("0"), Volatility::Immutable);
assert_eq!(classify_default_expression("42"), Volatility::Immutable);
assert_eq!(classify_default_expression("-7"), Volatility::Immutable);
assert_eq!(classify_default_expression("+1"), Volatility::Immutable);
assert_eq!(classify_default_expression("3.14"), Volatility::Immutable);
assert_eq!(classify_default_expression("1e10"), Volatility::Immutable);
}
#[test]
fn boolean_literals_classify_as_immutable() {
assert_eq!(classify_default_expression("true"), Volatility::Immutable);
assert_eq!(classify_default_expression("TRUE"), Volatility::Immutable);
assert_eq!(classify_default_expression("false"), Volatility::Immutable);
assert_eq!(classify_default_expression("False"), Volatility::Immutable);
}
#[test]
fn null_literal_classifies_as_immutable() {
assert_eq!(classify_default_expression("NULL"), Volatility::Immutable);
assert_eq!(classify_default_expression("null"), Volatility::Immutable);
assert_eq!(classify_default_expression("Null"), Volatility::Immutable);
}
#[test]
fn stable_builtins_classify_as_stable() {
assert_eq!(classify_default_expression("now()"), Volatility::Stable);
assert_eq!(
classify_default_expression("CURRENT_TIMESTAMP"),
Volatility::Stable
);
assert_eq!(
classify_default_expression("current_timestamp"),
Volatility::Stable
);
assert_eq!(
classify_default_expression("current_date"),
Volatility::Stable
);
assert_eq!(
classify_default_expression("current_time"),
Volatility::Stable
);
assert_eq!(
classify_default_expression("statement_timestamp()"),
Volatility::Stable
);
assert_eq!(
classify_default_expression("transaction_timestamp()"),
Volatility::Stable
);
}
#[test]
fn volatile_builtins_classify_as_volatile() {
assert_eq!(
classify_default_expression("clock_timestamp()"),
Volatility::Volatile
);
assert_eq!(
classify_default_expression("random()"),
Volatility::Volatile
);
assert_eq!(
classify_default_expression("gen_random_uuid()"),
Volatility::Volatile
);
}
#[test]
fn unknown_identifiers_default_to_volatile() {
assert_eq!(
classify_default_expression("myapp_helper()"),
Volatility::Volatile
);
assert_eq!(
classify_default_expression("custom_seq_next()"),
Volatility::Volatile
);
assert_eq!(
classify_default_expression("extension_func(arg)"),
Volatility::Volatile
);
}
#[test]
fn surrounding_whitespace_is_trimmed() {
assert_eq!(classify_default_expression(" now() "), Volatility::Stable);
assert_eq!(
classify_default_expression("\tnull\n"),
Volatility::Immutable
);
assert_eq!(
classify_default_expression(" clock_timestamp() "),
Volatility::Volatile
);
}
#[test]
fn empty_expression_classifies_as_volatile() {
assert_eq!(classify_default_expression(""), Volatility::Volatile);
assert_eq!(classify_default_expression(" "), Volatility::Volatile);
}
#[test]
fn is_literal_shape_recognises_strings() {
assert!(is_literal_shape("'foo'"));
assert!(is_literal_shape("E'foo'"));
assert!(is_literal_shape("e'foo'"));
assert!(is_literal_shape("$tag$foo$tag$"));
}
#[test]
fn is_literal_shape_recognises_numbers() {
assert!(is_literal_shape("0"));
assert!(is_literal_shape("123"));
assert!(is_literal_shape("-1"));
assert!(is_literal_shape("+42"));
}
#[test]
fn is_literal_shape_rejects_function_calls() {
assert!(!is_literal_shape("now()"));
assert!(!is_literal_shape("clock_timestamp()"));
assert!(!is_literal_shape("myapp_helper()"));
}
#[test]
fn is_literal_shape_rejects_bare_identifiers() {
assert!(!is_literal_shape("some_column"));
assert!(!is_literal_shape("CURRENT_TIMESTAMP"));
}
#[test]
fn is_literal_shape_rejects_compound_numeric_expressions() {
assert!(!is_literal_shape("1 + random()"));
assert!(!is_literal_shape("1+random()"));
assert!(!is_literal_shape("0 + clock_timestamp()"));
assert!(!is_literal_shape("42 * 2"));
assert!(!is_literal_shape("-1 + 2"));
}
#[test]
fn is_literal_shape_rejects_string_followed_by_call() {
assert!(!is_literal_shape("'a' || random()::text"));
assert!(!is_literal_shape("'foo' || 'bar'"));
}
#[test]
fn is_literal_shape_accepts_decimals_and_exponents() {
assert!(is_literal_shape("3.14"));
assert!(is_literal_shape("-0.5"));
assert!(is_literal_shape("1e10"));
assert!(is_literal_shape("1.5E-3"));
assert!(is_literal_shape("+0.0"));
}
#[test]
fn is_literal_shape_rejects_malformed_numbers() {
assert!(!is_literal_shape("1.2.3"));
assert!(!is_literal_shape("1e"));
assert!(!is_literal_shape("1.5e+"));
assert!(!is_literal_shape("1 2"));
}
#[test]
fn classify_compound_with_volatile_call_returns_volatile() {
assert_eq!(
classify_default_expression("1 + random()"),
Volatility::Volatile
);
assert_eq!(
classify_default_expression("0 + clock_timestamp()"),
Volatility::Volatile
);
}
#[test]
fn volatility_variants_are_distinct() {
fn classify(v: Volatility) -> u8 {
match v {
Volatility::Immutable => 0,
Volatility::Stable => 1,
Volatility::Volatile => 2,
}
}
assert_eq!(classify(Volatility::Immutable), 0);
assert_eq!(classify(Volatility::Stable), 1);
assert_eq!(classify(Volatility::Volatile), 2);
assert_ne!(Volatility::Immutable, Volatility::Stable);
assert_ne!(Volatility::Stable, Volatility::Volatile);
}
}