Merge 'Add Printf Support' from Zaid Humayun

Add basic printf function support in limbo
![Screenshot 2025-02-04 at 8 08 23 PM](https://github.com/user-
attachments/assets/b12931eb-8e79-4c8a-af77-c25c34cc5834)

Closes #886
This commit is contained in:
Pekka Enberg 2025-02-04 17:53:27 +02:00
commit bf1ef13c91
8 changed files with 307 additions and 1 deletions

View file

@ -228,7 +228,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 | |

View file

@ -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}")]

View file

@ -223,6 +223,7 @@ pub enum ScalarFunc {
#[cfg(not(target_family = "wasm"))]
LoadExtension,
StrfTime,
Printf,
}
impl Display for ScalarFunc {
@ -276,6 +277,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)
}
@ -576,6 +578,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),
}
}

View file

@ -1679,6 +1679,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() {

View file

@ -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};
@ -2151,6 +2153,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) => {

265
core/vdbe/printf.rs Normal file
View file

@ -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<OwnedValue> {
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);
}
}
}

View file

@ -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

View file

@ -0,0 +1,17 @@
#!/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}}