diff --git a/COMPAT.md b/COMPAT.md index 2b8194695..09036d4ae 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -227,7 +227,7 @@ Feature support of [sqlite expr syntax](https://www.sqlite.org/lang_expr.html). | min(X,Y,...) | Yes | | | nullif(X,Y) | Yes | | | octet_length(X) | Yes | | -| printf(FORMAT,...) | No | | +| printf(FORMAT,...) | Yes | Still need support additional modifiers | | quote(X) | Yes | | | random() | Yes | | | randomblob(N) | Yes | | diff --git a/core/error.rs b/core/error.rs index cac4e3f45..cfe1a827d 100644 --- a/core/error.rs +++ b/core/error.rs @@ -39,6 +39,10 @@ pub enum LimboError { InvalidTime(String), #[error("Modifier parsing error: {0}")] InvalidModifier(String), + #[error("Invalid argument supplied: {0}")] + InvalidArgument(String), + #[error("Invalid formatter supplied: {0}")] + InvalidFormatter(String), #[error("Runtime error: {0}")] Constraint(String), #[error("Extension error: {0}")] diff --git a/core/function.rs b/core/function.rs index be0065d07..dd8375067 100644 --- a/core/function.rs +++ b/core/function.rs @@ -221,6 +221,7 @@ pub enum ScalarFunc { #[cfg(not(target_family = "wasm"))] LoadExtension, StrfTime, + Printf, } impl Display for ScalarFunc { @@ -274,6 +275,7 @@ impl Display for ScalarFunc { #[cfg(not(target_family = "wasm"))] Self::LoadExtension => "load_extension".to_string(), Self::StrfTime => "strftime".to_string(), + Self::Printf => "printf".to_string(), }; write!(f, "{}", str) } @@ -572,6 +574,7 @@ impl Func { #[cfg(not(target_family = "wasm"))] "load_extension" => Ok(Self::Scalar(ScalarFunc::LoadExtension)), "strftime" => Ok(Self::Scalar(ScalarFunc::StrfTime)), + "printf" => Ok(Self::Scalar(ScalarFunc::Printf)), _ => crate::bail_parse_error!("no such function: {}", name), } } diff --git a/core/translate/expr.rs b/core/translate/expr.rs index 1c275f9da..c89b00ab5 100644 --- a/core/translate/expr.rs +++ b/core/translate/expr.rs @@ -1752,6 +1752,14 @@ pub fn translate_expr( }); Ok(target_register) } + ScalarFunc::Printf => translate_function( + program, + args.as_deref().unwrap_or(&[]), + referenced_tables, + resolver, + target_register, + func_ctx, + ), } } Func::Math(math_func) => match math_func.arity() { diff --git a/core/vdbe/mod.rs b/core/vdbe/mod.rs index 6d00692ec..afc557ee4 100644 --- a/core/vdbe/mod.rs +++ b/core/vdbe/mod.rs @@ -22,6 +22,7 @@ mod datetime; pub mod explain; pub mod insn; pub mod likeop; +mod printf; pub mod sorter; mod strftime; @@ -57,6 +58,7 @@ use insn::{ exec_subtract, }; use likeop::{construct_like_escape_arg, exec_glob, exec_like_with_escape}; +use printf::exec_printf; use rand::distributions::{Distribution, Uniform}; use rand::{thread_rng, Rng}; use regex::{Regex, RegexBuilder}; @@ -2108,6 +2110,12 @@ impl Program { ); state.registers[*dest] = result; } + ScalarFunc::Printf => { + let result = exec_printf( + &state.registers[*start_reg..*start_reg + arg_count], + )?; + state.registers[*dest] = result; + } }, crate::function::Func::External(f) => match f.func { ExtFunc::Scalar(f) => { diff --git a/core/vdbe/printf.rs b/core/vdbe/printf.rs new file mode 100644 index 000000000..c4fb6a153 --- /dev/null +++ b/core/vdbe/printf.rs @@ -0,0 +1,265 @@ +use std::rc::Rc; + +use crate::types::OwnedValue; +use crate::LimboError; + +#[inline(always)] +pub fn exec_printf(values: &[OwnedValue]) -> crate::Result { + if values.is_empty() { + return Ok(OwnedValue::Null); + } + let format_str = match &values[0] { + OwnedValue::Text(t) => &t.value, + _ => return Ok(OwnedValue::Null), + }; + + let mut result = String::new(); + let mut args_index = 1; + let mut chars = format_str.chars().peekable(); + + while let Some(c) = chars.next() { + if c != '%' { + result.push(c); + continue; + } + + match chars.next() { + Some('%') => { + result.push('%'); + continue; + } + Some('d') => { + if args_index >= values.len() { + return Err(LimboError::InvalidArgument("not enough arguments".into())); + } + match &values[args_index] { + OwnedValue::Integer(i) => result.push_str(&i.to_string()), + OwnedValue::Float(f) => result.push_str(&f.to_string()), + _ => result.push_str("0".into()), + } + args_index += 1; + } + Some('s') => { + if args_index >= values.len() { + return Err(LimboError::InvalidArgument("not enough arguments".into())); + } + match &values[args_index] { + OwnedValue::Text(t) => result.push_str(&t.value), + OwnedValue::Null => result.push_str("(null)"), + v => result.push_str(&v.to_string()), + } + args_index += 1; + } + Some('f') => { + if args_index >= values.len() { + return Err(LimboError::InvalidArgument("not enough arguments".into())); + } + match &values[args_index] { + OwnedValue::Float(f) => result.push_str(&f.to_string()), + OwnedValue::Integer(i) => result.push_str(&(*i as f64).to_string()), + _ => result.push_str("0.0".into()), + } + args_index += 1; + } + None => { + return Err(LimboError::InvalidArgument( + "incomplete format specifier".into(), + )) + } + _ => { + return Err(LimboError::InvalidFormatter( + "this formatter is not supported".into(), + )); + } + } + } + Ok(OwnedValue::build_text(Rc::new(result))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + fn text(value: &str) -> OwnedValue { + OwnedValue::build_text(Rc::new(value.to_string())) + } + + fn integer(value: i64) -> OwnedValue { + OwnedValue::Integer(value) + } + + fn float(value: f64) -> OwnedValue { + OwnedValue::Float(value) + } + + #[test] + fn test_printf_no_args() { + assert_eq!(exec_printf(&[]).unwrap(), OwnedValue::Null); + } + + #[test] + fn test_printf_basic_string() { + assert_eq!( + exec_printf(&[text("Hello World")]).unwrap(), + text("Hello World") + ); + } + + #[test] + fn test_printf_string_formatting() { + let test_cases = vec![ + // Simple string substitution + ( + vec![text("Hello, %s!"), text("World")], + text("Hello, World!"), + ), + // Multiple string substitutions + ( + vec![text("%s %s!"), text("Hello"), text("World")], + text("Hello World!"), + ), + // String with null value + ( + vec![text("Hello, %s!"), OwnedValue::Null], + text("Hello, (null)!"), + ), + // String with number conversion + (vec![text("Value: %s"), integer(42)], text("Value: 42")), + // Escaping percent sign + (vec![text("100%% complete")], text("100% complete")), + ]; + for (input, output) in test_cases { + assert_eq!(exec_printf(&input).unwrap(), output); + } + } + + #[test] + fn test_printf_integer_formatting() { + let test_cases = vec![ + // Basic integer formatting + (vec![text("Number: %d"), integer(42)], text("Number: 42")), + // Negative integer + (vec![text("Number: %d"), integer(-42)], text("Number: -42")), + // Multiple integers + ( + vec![text("%d + %d = %d"), integer(2), integer(3), integer(5)], + text("2 + 3 = 5"), + ), + // Non-numeric value defaults to 0 + ( + vec![text("Number: %d"), text("not a number")], + text("Number: 0"), + ), + ]; + for (input, output) in test_cases { + assert_eq!(exec_printf(&input).unwrap(), output) + } + } + + #[test] + fn test_printf_float_formatting() { + let test_cases = vec![ + // Basic float formatting + (vec![text("Number: %f"), float(42.5)], text("Number: 42.5")), + // Negative float + ( + vec![text("Number: %f"), float(-42.5)], + text("Number: -42.5"), + ), + // Integer as float + (vec![text("Number: %f"), integer(42)], text("Number: 42")), + // Multiple floats + ( + vec![text("%f + %f = %f"), float(2.5), float(3.5), float(6.0)], + text("2.5 + 3.5 = 6"), + ), + // Non-numeric value defaults to 0.0 + ( + vec![text("Number: %f"), text("not a number")], + text("Number: 0.0"), + ), + ]; + + for (input, expected) in test_cases { + assert_eq!(exec_printf(&input).unwrap(), expected); + } + } + + #[test] + fn test_printf_mixed_formatting() { + let test_cases = vec![ + // Mix of string and integer + ( + vec![text("%s: %d"), text("Count"), integer(42)], + text("Count: 42"), + ), + // Mix of all types + ( + vec![ + text("%s: %d (%f%%)"), + text("Progress"), + integer(75), + float(75.5), + ], + text("Progress: 75 (75.5%)"), + ), + // Complex format + ( + vec![ + text("Name: %s, ID: %d, Score: %f"), + text("John"), + integer(123), + float(95.5), + ], + text("Name: John, ID: 123, Score: 95.5"), + ), + ]; + + for (input, expected) in test_cases { + assert_eq!(exec_printf(&input).unwrap(), expected); + } + } + + #[test] + fn test_printf_error_cases() { + let error_cases = vec![ + // Not enough arguments + vec![text("%d %d"), integer(42)], + // Invalid format string + vec![text("%z"), integer(42)], + // Incomplete format specifier + vec![text("incomplete %")], + ]; + + for case in error_cases { + assert!(exec_printf(&case).is_err()); + } + } + + #[test] + fn test_printf_edge_cases() { + let test_cases = vec![ + // Empty format string + (vec![text("")], text("")), + // Only percent signs + (vec![text("%%%%")], text("%%")), + // String with no format specifiers + (vec![text("No substitutions")], text("No substitutions")), + // Multiple consecutive format specifiers + ( + vec![text("%d%d%d"), integer(1), integer(2), integer(3)], + text("123"), + ), + // Format string with special characters + ( + vec![text("Special chars: %s"), text("\n\t\r")], + text("Special chars: \n\t\r"), + ), + ]; + + for (input, expected) in test_cases { + assert_eq!(exec_printf(&input).unwrap(), expected); + } + } +} diff --git a/testing/all.test b/testing/all.test index 05cd2d196..f750534be 100755 --- a/testing/all.test +++ b/testing/all.test @@ -23,3 +23,4 @@ source $testdir/compare.test source $testdir/changes.test source $testdir/total-changes.test source $testdir/offset.test +source $testdir/scalar-functions-printf.test \ No newline at end of file diff --git a/testing/scalar-functions-printf.test b/testing/scalar-functions-printf.test new file mode 100644 index 000000000..e152698d3 --- /dev/null +++ b/testing/scalar-functions-printf.test @@ -0,0 +1,22 @@ +#!/usr/bin/env tclsh + +set testdir [file dirname $argv0] +source $testdir/tester.tcl + +# Basic string formatting +do_execsql_test printf-basic-string { + SELECT printf('Hello World!'); +} {{Hello World!}} + +do_execsql_test printf-string-replacement { + SELECT printf('Hello, %s', 'Alice'); +} {{Hello, Alice}} + +do_execsql_test printf-numeric-replacement { + SELECT printf('My number is: %d', 42); +} {{My number is: 42}} + +# Multiple consecutive format specifiers +do_execsql_test printf-consecutive-formats { + SELECT printf('%d%s%f', 1, 'test', 2.5); +} {{1test2.500000}} \ No newline at end of file