diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index bba9e5009..2ea62e62a 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -105,6 +105,7 @@ pub enum PropertyKind { Position(u32), Rated(i32, RatingKind), CustomDataNumber { key: String, value: f32 }, + CustomDataString { key: String, value: String }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -409,6 +410,7 @@ fn parse_prop(prop_clause: &str) -> ParseResult { tag("rated"), tag("resched"), recognize(preceded(tag("cdn:"), alphanumeric1)), + recognize(preceded(tag("cds:"), alphanumeric1)), ))(prop_clause) .map_err(|_| { parse_failure( @@ -448,15 +450,15 @@ fn parse_prop(prop_clause: &str) -> ParseResult { "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), "lapses" => PropertyKind::Lapses(parse_u32(num, prop_clause)?), "pos" => PropertyKind::Position(parse_u32(num, prop_clause)?), - other => { - let Some(prop) = other.strip_prefix("cdn:") else { - unreachable!() - }; - PropertyKind::CustomDataNumber { - key: prop.into(), - value: parse_f32(num, prop_clause)?, - } - } + prop if prop.starts_with("cdn:") => PropertyKind::CustomDataNumber { + key: prop.strip_prefix("cdn:").unwrap().into(), + value: parse_f32(num, prop_clause)?, + }, + prop if prop.starts_with("cds:") => PropertyKind::CustomDataString { + key: prop.strip_prefix("cds:").unwrap().into(), + value: num.into(), + }, + _ => unreachable!(), }; Ok(SearchNode::Property { @@ -927,6 +929,27 @@ mod test { } })] ); + assert_eq!( + parse("prop:cds:abc=foo")?, + vec![Search(Property { + operator: "=".into(), + kind: PropertyKind::CustomDataString { + key: "abc".into(), + value: "foo".into() + } + })] + ); + assert_eq!( + parse("\"prop:cds:abc=foo bar\"")?, + vec![Search(Property { + operator: "=".into(), + kind: PropertyKind::CustomDataString { + key: "abc".into(), + value: "foo bar".into() + } + })] + ); + assert_eq!(parse("has-cd:r")?, vec![Search(CustomData("r".into()))]); Ok(()) } @@ -1148,6 +1171,18 @@ mod test { provided: "cdn:=5".to_string(), }, ); + assert_err_kind( + "prop:cds=s", + InvalidPropProperty { + provided: "cds=s".to_string(), + }, + ); + assert_err_kind( + "prop:cds:=s", + InvalidPropProperty { + provided: "cds:=s".to_string(), + }, + ); assert_err_kind( "prop:lapses", diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 42715a0c5..f20fc89b1 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -360,7 +360,14 @@ impl SqlWriter<'_> { PropertyKind::CustomDataNumber { key, value } => { write!( self.sql, - "extract_custom_data_number(c.data, '{key}') {op} {value}" + "cast(extract_custom_data(c.data, '{key}') as float) {op} {value}" + ) + .unwrap(); + } + PropertyKind::CustomDataString { key, value } => { + write!( + self.sql, + "extract_custom_data(c.data, '{key}') {op} '{value}'" ) .unwrap(); } @@ -370,7 +377,7 @@ impl SqlWriter<'_> { } fn write_custom_data(&mut self, key: &str) -> Result<()> { - write!(self.sql, "has_custom_data(c.data, '{key}')").unwrap(); + write!(self.sql, "extract_custom_data(c.data, '{key}') is not null").unwrap(); Ok(()) } @@ -1187,7 +1194,11 @@ c.odue != 0 then c.odue else c.due end) != {days}) or (c.queue in (1,4) and assert_eq!(s(ctx, "prop:rated>-5:3").0, s(ctx, "rated:5:3").0); assert_eq!( &s(ctx, "prop:cdn:r=1").0, - "(extract_custom_data_number(c.data, 'r') = 1)" + "(cast(extract_custom_data(c.data, 'r') as float) = 1)" + ); + assert_eq!( + &s(ctx, "prop:cds:r=s").0, + "(extract_custom_data(c.data, 'r') = 's')" ); // note types by name @@ -1229,6 +1240,12 @@ c.odue != 0 then c.odue else c.due end) != {days}) or (c.queue in (1,4) and vec![r"(?i)\b.*fo.o.*\b".into()] ) ); + + // has-cd + assert_eq!( + &s(ctx, "has-cd:r").0, + "(extract_custom_data(c.data, 'r') is not null)" + ); } #[test] diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index b1445cde2..3937c6a38 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -173,6 +173,9 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { RatingKind::ManualReschedule => format!("prop:resched{}{}", operator, u), }, CustomDataNumber { key, value } => format!("prop:cdn:{key}{operator}{value}"), + CustomDataString { key, value } => { + maybe_quote(&format!("prop:cds:{key}{operator}{value}",)) + } } } diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 262d2f769..253c3f591 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -69,8 +69,7 @@ fn open_or_create_collection_db(path: &Path) -> Result { add_regexp_tags_function(&db)?; add_without_combining_function(&db)?; add_fnvhash_function(&db)?; - add_extract_custom_data_number_function(&db)?; - add_has_custom_data_function(&db)?; + add_extract_custom_data_function(&db)?; db.create_collation("unicase", unicase_compare)?; @@ -202,10 +201,10 @@ fn add_regexp_tags_function(db: &Connection) -> rusqlite::Result<()> { ) } -/// eg. extract_custom_data_number(card.data, 'r') -> float | null -fn add_extract_custom_data_number_function(db: &Connection) -> rusqlite::Result<()> { +/// eg. extract_custom_data(card.data, 'r') -> string | null +fn add_extract_custom_data_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( - "extract_custom_data_number", + "extract_custom_data", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { @@ -224,36 +223,11 @@ fn add_extract_custom_data_number_function(db: &Connection) -> rusqlite::Result< let Ok(value) = serde_json::from_str::(custom_data) else { return Ok(None); }; - let num = value.get(key).and_then(|v| v.as_f64()); - Ok(num) - }, - ) -} - -/// eg. has_custom_data(card.data, 'r') -> bool -fn add_has_custom_data_function(db: &Connection) -> rusqlite::Result<()> { - db.create_scalar_function( - "has_custom_data", - 2, - FunctionFlags::SQLITE_DETERMINISTIC, - move |ctx| { - assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); - - let Ok(card_data) = ctx.get_raw(0).as_str() else { - return Ok(None); - }; - if card_data.is_empty() { - return Ok(Some(false)); - } - let Ok(key) = ctx.get_raw(1).as_str() else { - return Ok(Some(false)); - }; - let custom_data = &CardData::from_str(card_data).custom_data; - let Ok(value) = serde_json::from_str::(custom_data) else { - return Ok(Some(false)); - }; - - Ok(value.get(key).map(|_| true)) + let v = value.get(key).map(|v| match v { + Value::String(s) => s.to_owned(), + _ => v.to_string(), + }); + Ok(v) }, ) }