use std::collections::{BTreeMap, HashMap};
use roaring::RoaringBitmap;
use serde::{Deserialize, Serialize};
use crate::StorageError;
pub use pylon_kernel::ManifestSearchConfig as SearchConfig;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchQuery {
#[serde(default)]
pub query: String,
#[serde(default)]
pub filters: HashMap<String, serde_json::Value>,
#[serde(default)]
pub facets: Vec<String>,
#[serde(default)]
pub sort: Option<(String, String)>,
#[serde(default)]
pub page: usize,
#[serde(default = "default_page_size")]
pub page_size: usize,
}
fn default_page_size() -> usize {
20
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
pub hits: Vec<serde_json::Value>,
pub facet_counts: BTreeMap<String, BTreeMap<String, u64>>,
pub total: u64,
pub took_ms: u64,
}
pub fn merge_row(old_row: &serde_json::Value, patch: &serde_json::Value) -> serde_json::Value {
let mut merged = old_row.as_object().cloned().unwrap_or_default();
if let Some(obj) = patch.as_object() {
for (k, v) in obj {
merged.insert(k.clone(), v.clone());
}
}
serde_json::Value::Object(merged)
}
pub fn stringify_facet(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::Null => None,
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => None,
}
}
pub fn serialize_bitmap(b: &RoaringBitmap) -> Result<Vec<u8>, StorageError> {
let mut out = Vec::with_capacity(b.serialized_size());
b.serialize_into(&mut out)
.map_err(|e| StorageError::new("BITMAP_SERIALIZE_FAILED", &e.to_string()))?;
Ok(out)
}
pub fn deserialize_bitmap(bytes: &[u8]) -> Result<RoaringBitmap, StorageError> {
RoaringBitmap::deserialize_from(bytes)
.map_err(|e| StorageError::new("BITMAP_DESERIALIZE_FAILED", &e.to_string()))
}
pub fn create_fts_table_sql(entity: &str, config: &SearchConfig) -> Option<String> {
if config.text.is_empty() {
return None;
}
let cols = config
.text
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS \"_fts_{entity}\" USING fts5(\
entity_id UNINDEXED, {cols}, \
tokenize = 'unicode61 remove_diacritics 2'\
);"
))
}
pub fn create_facet_table_sql() -> &'static str {
"CREATE TABLE IF NOT EXISTS \"_facet_bitmap\" (\
entity TEXT NOT NULL,\
facet TEXT NOT NULL,\
value TEXT NOT NULL,\
bitmap BLOB NOT NULL,\
row_count INTEGER NOT NULL,\
PRIMARY KEY (entity, facet, value)\
);"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bitmap_roundtrip() {
let mut b = RoaringBitmap::new();
for i in 0..10_000 {
if i % 3 == 0 {
b.insert(i);
}
}
let bytes = serialize_bitmap(&b).unwrap();
let round = deserialize_bitmap(&bytes).unwrap();
assert_eq!(b, round);
}
#[test]
fn fts_sql_skipped_when_no_text_fields() {
let cfg = SearchConfig {
text: vec![],
facets: vec!["brand".into()],
sortable: vec![],
language: None,
};
assert!(create_fts_table_sql("Product", &cfg).is_none());
}
#[test]
fn fts_sql_lists_declared_text_columns() {
let cfg = SearchConfig {
text: vec!["name".into(), "description".into()],
facets: vec![],
sortable: vec![],
language: None,
};
let sql = create_fts_table_sql("Product", &cfg).unwrap();
assert!(sql.contains("\"_fts_Product\""));
assert!(sql.contains("\"name\""));
assert!(sql.contains("\"description\""));
assert!(sql.contains("unicode61"));
}
#[test]
fn bitmap_intersect_popcount_is_facet_count() {
let mut matches = RoaringBitmap::new();
matches.insert_range(0..1_000_000u32);
let mut brand_nike = RoaringBitmap::new();
for i in (0..1_000_000u32).step_by(7) {
brand_nike.insert(i);
}
let and = &matches & &brand_nike;
assert_eq!(and.len(), brand_nike.len());
}
}