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)]
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 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![],
};
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![],
};
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());
}
}