Merge 'Implement json_quote' from Pedro Muniz

Hi! This is my first PR on the project, so I apologize if I did not
follow a convention from the project.
#127
This PR implements json_quote as specified in their source:
https://www.sqlite.org/json1.html#jquote. It follows the internal doc
guidelines for implementing functions. Most tests were added from sqlite
test suite for json_quote, while some others were added by me. Sqlite
test suite for json_quote depends on json_valid to test for correct
escape control characters, so that specific test at the moment cannot be
done the same way.

Reviewed-by: Jussi Saurio <jussi.saurio@gmail.com>
Reviewed-by: Sonny (@sonhmai)

Closes #763
This commit is contained in:
Pekka Enberg
2025-02-07 13:33:05 +02:00
7 changed files with 102 additions and 3 deletions

View File

@@ -380,7 +380,7 @@ Modifiers:
| json_type(json,path) | Yes | |
| json_valid(json) | Yes | |
| json_valid(json,flags) | | |
| json_quote(value) | | |
| json_quote(value) | Yes | |
| json_group_array(value) | | |
| jsonb_group_array(value) | | |
| json_group_object(label,value) | | |

View File

@@ -89,3 +89,7 @@ test-time:
test-sqlite3: limbo-c
LIBS="$(SQLITE_LIB)" HEADERS="$(SQLITE_LIB_HEADERS)" make -C sqlite3/tests test
.PHONY: test-sqlite3
test-json:
SQLITE_EXEC=$(SQLITE_EXEC) ./testing/json.test
.PHONY: test-json

View File

@@ -84,6 +84,7 @@ pub enum JsonFunc {
JsonRemove,
JsonPretty,
JsonSet,
JsonQuote,
}
#[cfg(feature = "json")]
@@ -107,6 +108,7 @@ impl Display for JsonFunc {
Self::JsonRemove => "json_remove".to_string(),
Self::JsonPretty => "json_pretty".to_string(),
Self::JsonSet => "json_set".to_string(),
Self::JsonQuote => "json_quote".to_string(),
}
)
}
@@ -568,6 +570,8 @@ impl Func {
"json_pretty" => Ok(Self::Json(JsonFunc::JsonPretty)),
#[cfg(feature = "json")]
"json_set" => Ok(Self::Json(JsonFunc::JsonSet)),
#[cfg(feature = "json")]
"json_quote" => Ok(Self::Json(JsonFunc::JsonQuote)),
"unixepoch" => Ok(Self::Scalar(ScalarFunc::UnixEpoch)),
"julianday" => Ok(Self::Scalar(ScalarFunc::JulianDay)),
"hex" => Ok(Self::Scalar(ScalarFunc::Hex)),

View File

@@ -674,6 +674,43 @@ pub fn is_json_valid(json_value: &OwnedValue) -> crate::Result<OwnedValue> {
}
}
pub fn json_quote(value: &OwnedValue) -> crate::Result<OwnedValue> {
match value {
OwnedValue::Text(ref t) => {
// If X is a JSON value returned by another JSON function,
// then this function is a no-op
if t.subtype == TextSubtype::Json {
// Should just return the json value with no quotes
return Ok(value.to_owned());
}
let mut escaped_value = String::with_capacity(t.value.len() + 4);
escaped_value.push('"');
for c in t.as_str().chars() {
match c {
'"' | '\\' | '\n' | '\r' | '\t' | '\u{0008}' | '\u{000c}' => {
escaped_value.push('\\');
escaped_value.push(c);
}
c => escaped_value.push(c),
}
}
escaped_value.push('"');
Ok(OwnedValue::Text(Text::new(Rc::new(escaped_value))))
}
// Numbers are unquoted in json
OwnedValue::Integer(ref int) => Ok(OwnedValue::Integer(int.to_owned())),
OwnedValue::Float(ref float) => Ok(OwnedValue::Float(float.to_owned())),
OwnedValue::Blob(_) => crate::bail_constraint_error!("JSON cannot hold BLOB values"),
OwnedValue::Null => Ok(OwnedValue::Text(Text::new(Rc::new("null".to_string())))),
_ => {
unreachable!()
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -975,7 +975,7 @@ pub fn translate_expr(
translate_function(
program,
&args,
args,
referenced_tables,
resolver,
target_register,
@@ -1017,6 +1017,17 @@ pub fn translate_expr(
});
Ok(target_register)
}
JsonFunc::JsonQuote => {
let args = expect_arguments_exact!(args, 1, j);
translate_function(
program,
args,
referenced_tables,
resolver,
target_register,
func_ctx,
)
}
JsonFunc::JsonPretty => {
let args = expect_arguments_max!(args, 2, j);

View File

@@ -49,7 +49,7 @@ use crate::{
function::JsonFunc, json::get_json, json::is_json_valid, json::json_array,
json::json_array_length, json::json_arrow_extract, json::json_arrow_shift_extract,
json::json_error_position, json::json_extract, json::json_object, json::json_patch,
json::json_remove, json::json_set, json::json_type,
json::json_quote, json::json_remove, json::json_set, json::json_type,
};
use crate::{resolve_ext_path, Connection, Result, TransactionState, DATABASE_VERSION};
use insn::{
@@ -1973,6 +1973,14 @@ impl Program {
Err(e) => return Err(e),
}
}
JsonFunc::JsonQuote => {
let json_value = &state.registers[*start_reg];
match json_quote(json_value) {
Ok(result) => state.registers[*dest] = result,
Err(e) => return Err(e),
}
}
},
crate::function::Func::Scalar(scalar_func) => match scalar_func {
ScalarFunc::Cast => {

View File

@@ -878,3 +878,38 @@ do_execsql_test json_set_add_array_in_array_in_nested_object {
do_execsql_test json_set_add_array_in_array_in_nested_object_out_of_bounds {
SELECT json_set('{}', '$.object[123].another', 'value', '$.field', 'value');
} {{{"field":"value"}}}
# The json_quote() function transforms an SQL value into a JSON value.
# String values are quoted and interior quotes are escaped. NULL values
# are rendered as the unquoted string "null".
#
do_execsql_test json_quote_string_literal {
SELECT json_quote('abc"xyz');
} {{"abc\"xyz"}}
do_execsql_test json_quote_float {
SELECT json_quote(3.14159);
} {3.14159}
do_execsql_test json_quote_integer {
SELECT json_quote(12345);
} {12345}
do_execsql_test json_quote_null {
SELECT json_quote(null);
} {"null"}
do_execsql_test json_quote_null_caps {
SELECT json_quote(NULL);
} null
do_execsql_test json_quote_json_value {
SELECT json_quote(json('{a:1, b: "test"}'));
} {{{"a":1,"b":"test"}}}
# Escape character tests in sqlite source depend on json_valid and in some syntax that is not implemented
# yet in limbo.
# See https://github.com/sqlite/sqlite/blob/255548562b125e6c148bb27d49aaa01b2fe61dba/test/json102.test#L690
# So for now not all control characters escaped are tested
# do_execsql_test json102-1501 {
# WITH RECURSIVE c(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM c WHERE x<0x1f)
# SELECT sum(json_valid(json_quote('a'||char(x)||'z'))) FROM c ORDER BY x;
# } {31}