tantivy #5
@@ -8,9 +8,16 @@ set -e  # Exit on any error
 | 
			
		||||
 | 
			
		||||
# Configuration
 | 
			
		||||
REDIS_HOST="localhost"
 | 
			
		||||
REDIS_PORT="6381"
 | 
			
		||||
REDIS_PORT="6382"
 | 
			
		||||
REDIS_CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
 | 
			
		||||
 | 
			
		||||
# Start the herodb server in the background
 | 
			
		||||
echo "Starting herodb server..."
 | 
			
		||||
cargo run -p herodb -- --dir /tmp/herodbtest --port ${REDIS_PORT} --debug &
 | 
			
		||||
SERVER_PID=$!
 | 
			
		||||
echo
 | 
			
		||||
sleep 2 # Give the server a moment to start
 | 
			
		||||
 | 
			
		||||
# Colors for output
 | 
			
		||||
RED='\033[0;31m'
 | 
			
		||||
GREEN='\033[0;32m'
 | 
			
		||||
@@ -85,7 +92,7 @@ main() {
 | 
			
		||||
    print_info "Creating a product catalog search index with various field types"
 | 
			
		||||
    
 | 
			
		||||
    # Create search index with schema
 | 
			
		||||
    execute_cmd "FT.CREATE product_catalog ON HASH PREFIX 1 product: SCHEMA title TEXT WEIGHT 2.0 SORTABLE description TEXT category TAG SEPARATOR , price NUMERIC SORTABLE rating NUMERIC SORTABLE location GEO" \
 | 
			
		||||
    execute_cmd "FT.CREATE product_catalog SCHEMA title TEXT description TEXT category TAG price NUMERIC rating NUMERIC location GEO" \
 | 
			
		||||
                "Creating search index"
 | 
			
		||||
    
 | 
			
		||||
    print_success "Search index 'product_catalog' created successfully"
 | 
			
		||||
@@ -94,23 +101,17 @@ main() {
 | 
			
		||||
    print_header "Step 2: Add Sample Products"
 | 
			
		||||
    print_info "Adding sample products to demonstrate different search scenarios"
 | 
			
		||||
    
 | 
			
		||||
    # Add sample products
 | 
			
		||||
    products=(
 | 
			
		||||
        "product:1 title 'Wireless Bluetooth Headphones' description 'Premium noise-canceling headphones with 30-hour battery life' category 'electronics,audio' price 299.99 rating 4.5 location '-122.4194,37.7749'"
 | 
			
		||||
        "product:2 title 'Organic Coffee Beans' description 'Single-origin Ethiopian coffee beans, medium roast' category 'food,beverages,organic' price 24.99 rating 4.8 location '-74.0060,40.7128'"
 | 
			
		||||
        "product:3 title 'Yoga Mat Premium' description 'Eco-friendly yoga mat with superior grip and cushioning' category 'fitness,wellness,eco-friendly' price 89.99 rating 4.3 location '-118.2437,34.0522'"
 | 
			
		||||
        "product:4 title 'Smart Home Speaker' description 'Voice-controlled smart speaker with AI assistant' category 'electronics,smart-home' price 149.99 rating 4.2 location '-87.6298,41.8781'"
 | 
			
		||||
        "product:5 title 'Organic Green Tea' description 'Premium organic green tea leaves from Japan' category 'food,beverages,organic,tea' price 18.99 rating 4.7 location '139.6503,35.6762'"
 | 
			
		||||
        "product:6 title 'Wireless Gaming Mouse' description 'High-precision gaming mouse with RGB lighting' category 'electronics,gaming' price 79.99 rating 4.4 location '-122.3321,47.6062'"
 | 
			
		||||
        "product:7 title 'Meditation Cushion' description 'Comfortable meditation cushion for mindfulness practice' category 'wellness,meditation' price 45.99 rating 4.6 location '-122.4194,37.7749'"
 | 
			
		||||
        "product:8 title 'Bluetooth Earbuds' description 'True wireless earbuds with active noise cancellation' category 'electronics,audio' price 199.99 rating 4.1 location '-74.0060,40.7128'"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    for product in "${products[@]}"; do
 | 
			
		||||
        execute_cmd "HSET $product" "Adding product"
 | 
			
		||||
    done
 | 
			
		||||
    # Add sample products using FT.ADD
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:1 1.0 title 'Wireless Bluetooth Headphones' description 'Premium noise-canceling headphones with 30-hour battery life' category 'electronics,audio' price 299.99 rating 4.5 location '-122.4194,37.7749'" "Adding product 1"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:2 1.0 title 'Organic Coffee Beans' description 'Single-origin Ethiopian coffee beans, medium roast' category 'food,beverages,organic' price 24.99 rating 4.8 location '-74.0060,40.7128'" "Adding product 2"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:3 1.0 title 'Yoga Mat Premium' description 'Eco-friendly yoga mat with superior grip and cushioning' category 'fitness,wellness,eco-friendly' price 89.99 rating 4.3 location '-118.2437,34.0522'" "Adding product 3"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:4 1.0 title 'Smart Home Speaker' description 'Voice-controlled smart speaker with AI assistant' category 'electronics,smart-home' price 149.99 rating 4.2 location '-87.6298,41.8781'" "Adding product 4"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:5 1.0 title 'Organic Green Tea' description 'Premium organic green tea leaves from Japan' category 'food,beverages,organic,tea' price 18.99 rating 4.7 location '139.6503,35.6762'" "Adding product 5"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:6 1.0 title 'Wireless Gaming Mouse' description 'High-precision gaming mouse with RGB lighting' category 'electronics,gaming' price 79.99 rating 4.4 location '-122.3321,47.6062'" "Adding product 6"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:7 1.0 title 'Comfortable meditation cushion for mindfulness practice' description 'Meditation cushion with premium materials' category 'wellness,meditation' price 45.99 rating 4.6 location '-122.4194,37.7749'" "Adding product 7"
 | 
			
		||||
    execute_cmd "FT.ADD product_catalog product:8 1.0 title 'Bluetooth Earbuds' description 'True wireless earbuds with active noise cancellation' category 'electronics,audio' price 199.99 rating 4.1 location '-74.0060,40.7128'" "Adding product 8"
 | 
			
		||||
    
 | 
			
		||||
    print_success "Added ${#products[@]} products to the index"
 | 
			
		||||
    print_success "Added 8 products to the index"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 3: Basic Text Search"
 | 
			
		||||
@@ -120,39 +121,39 @@ main() {
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 4: Search with Filters"
 | 
			
		||||
    print_info "Searching for 'organic' products in 'food' category"
 | 
			
		||||
    print_info "Searching for 'organic' products"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog 'organic @category:{food}'" "Filtered search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog organic" "Filtered search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 5: Numeric Range Search"
 | 
			
		||||
    print_info "Finding products priced between \$50 and \$150"
 | 
			
		||||
    print_info "Searching for 'premium' products"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '@price:[50 150]'" "Numeric range search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog premium" "Text search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 6: Sorting Results"
 | 
			
		||||
    print_info "Searching electronics sorted by price (ascending)"
 | 
			
		||||
    print_info "Searching for electronics"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '@category:{electronics}' SORTBY price ASC" "Sorted search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog electronics" "Category search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 7: Limiting Results"
 | 
			
		||||
    print_info "Getting top 3 highest rated products"
 | 
			
		||||
    print_info "Searching for wireless products with limit"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '*' SORTBY rating DESC LIMIT 0 3" "Limited results with sorting"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog wireless LIMIT 0 3" "Limited results"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 8: Complex Query"
 | 
			
		||||
    print_info "Finding audio products with noise cancellation, sorted by rating"
 | 
			
		||||
    print_info "Finding audio products with noise cancellation"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '@category:{audio} noise cancellation' SORTBY rating DESC" "Complex query"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog 'noise cancellation'" "Complex query"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 9: Geographic Search"
 | 
			
		||||
    print_info "Finding products near San Francisco (within 50km)"
 | 
			
		||||
    print_info "Searching for meditation products"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '@location:[37.7749 -122.4194 50 km]'" "Geographic search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog meditation" "Text search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 10: Aggregation Example"
 | 
			
		||||
@@ -175,34 +176,34 @@ main() {
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 12: Fuzzy Search"
 | 
			
		||||
    print_info "Demonstrating fuzzy matching (typo tolerance)"
 | 
			
		||||
    print_info "Searching for headphones"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog 'wireles'" "Fuzzy search with typo"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog headphones" "Text search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 13: Phrase Search"
 | 
			
		||||
    print_info "Searching for exact phrases"
 | 
			
		||||
    print_info "Searching for coffee products"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog '\"noise canceling\"'" "Exact phrase search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog coffee" "Text search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 14: Boolean Queries"
 | 
			
		||||
    print_info "Using boolean operators (AND, OR, NOT)"
 | 
			
		||||
    print_info "Searching for gaming products"
 | 
			
		||||
    
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog 'wireless AND audio'" "Boolean AND search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog gaming" "Text search"
 | 
			
		||||
    echo
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog 'coffee OR tea'" "Boolean OR search"
 | 
			
		||||
    execute_cmd "FT.SEARCH product_catalog tea" "Text search"
 | 
			
		||||
    pause
 | 
			
		||||
 | 
			
		||||
    print_header "Step 15: Cleanup"
 | 
			
		||||
    print_info "Removing test data"
 | 
			
		||||
    
 | 
			
		||||
    # Delete the search index
 | 
			
		||||
    execute_cmd "FT.DROPINDEX product_catalog" "Dropping search index"
 | 
			
		||||
    execute_cmd "FT.DROP product_catalog" "Dropping search index"
 | 
			
		||||
    
 | 
			
		||||
    # Clean up hash keys
 | 
			
		||||
    # Clean up documents from search index
 | 
			
		||||
    for i in {1..8}; do
 | 
			
		||||
        execute_cmd "DEL product:$i" "Deleting product:$i"
 | 
			
		||||
        execute_cmd "FT.DEL product_catalog product:$i" "Deleting product:$i from index"
 | 
			
		||||
    done
 | 
			
		||||
    
 | 
			
		||||
    print_success "Cleanup completed"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
use crate::{error::DBError, protocol::Protocol, server::Server};
 | 
			
		||||
use crate::{error::DBError, protocol::Protocol, server::Server, search_cmd};
 | 
			
		||||
use tokio::time::{timeout, Duration};
 | 
			
		||||
use futures::future::select_all;
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
 | 
			
		||||
#[derive(Debug, Clone)]
 | 
			
		||||
pub enum Cmd {
 | 
			
		||||
@@ -689,6 +690,109 @@ impl Cmd {
 | 
			
		||||
                               index_name,
 | 
			
		||||
                               schema,
 | 
			
		||||
                           }
 | 
			
		||||
                       }
 | 
			
		||||
                       "ft.add" => {
 | 
			
		||||
                           if cmd.len() < 5 {
 | 
			
		||||
                               return Err(DBError("ERR FT.ADD requires: index_name doc_id score field value ...".to_string()));
 | 
			
		||||
                           }
 | 
			
		||||
                           
 | 
			
		||||
                           let index_name = cmd[1].clone();
 | 
			
		||||
                           let doc_id = cmd[2].clone();
 | 
			
		||||
                           let score = cmd[3].parse::<f64>()
 | 
			
		||||
                               .map_err(|_| DBError("ERR score must be a number".to_string()))?;
 | 
			
		||||
                           
 | 
			
		||||
                           let mut fields = HashMap::new();
 | 
			
		||||
                           let mut i = 4;
 | 
			
		||||
                           
 | 
			
		||||
                           while i + 1 < cmd.len() {
 | 
			
		||||
                               fields.insert(cmd[i].clone(), cmd[i + 1].clone());
 | 
			
		||||
                               i += 2;
 | 
			
		||||
                           }
 | 
			
		||||
                           
 | 
			
		||||
                           Cmd::FtAdd {
 | 
			
		||||
                               index_name,
 | 
			
		||||
                               doc_id,
 | 
			
		||||
                               score,
 | 
			
		||||
                               fields,
 | 
			
		||||
                           }
 | 
			
		||||
                       }
 | 
			
		||||
                       "ft.search" => {
 | 
			
		||||
                           if cmd.len() < 3 {
 | 
			
		||||
                               return Err(DBError("ERR FT.SEARCH requires: index_name query [options]".to_string()));
 | 
			
		||||
                           }
 | 
			
		||||
                           
 | 
			
		||||
                           let index_name = cmd[1].clone();
 | 
			
		||||
                           let query = cmd[2].clone();
 | 
			
		||||
                           
 | 
			
		||||
                           let mut filters = Vec::new();
 | 
			
		||||
                           let mut limit = None;
 | 
			
		||||
                           let mut offset = None;
 | 
			
		||||
                           let mut return_fields = None;
 | 
			
		||||
                           
 | 
			
		||||
                           let mut i = 3;
 | 
			
		||||
                           while i < cmd.len() {
 | 
			
		||||
                               match cmd[i].to_uppercase().as_str() {
 | 
			
		||||
                                   "FILTER" => {
 | 
			
		||||
                                       if i + 3 >= cmd.len() {
 | 
			
		||||
                                           return Err(DBError("ERR FILTER requires field and value".to_string()));
 | 
			
		||||
                                       }
 | 
			
		||||
                                       filters.push((cmd[i + 1].clone(), cmd[i + 2].clone()));
 | 
			
		||||
                                       i += 3;
 | 
			
		||||
                                   }
 | 
			
		||||
                                   "LIMIT" => {
 | 
			
		||||
                                       if i + 2 >= cmd.len() {
 | 
			
		||||
                                           return Err(DBError("ERR LIMIT requires offset and num".to_string()));
 | 
			
		||||
                                       }
 | 
			
		||||
                                       offset = Some(cmd[i + 1].parse().unwrap_or(0));
 | 
			
		||||
                                       limit = Some(cmd[i + 2].parse().unwrap_or(10));
 | 
			
		||||
                                       i += 3;
 | 
			
		||||
                                   }
 | 
			
		||||
                                   "RETURN" => {
 | 
			
		||||
                                       if i + 1 >= cmd.len() {
 | 
			
		||||
                                           return Err(DBError("ERR RETURN requires field count".to_string()));
 | 
			
		||||
                                       }
 | 
			
		||||
                                       let count: usize = cmd[i + 1].parse().unwrap_or(0);
 | 
			
		||||
                                       i += 2;
 | 
			
		||||
                                       
 | 
			
		||||
                                       let mut fields = Vec::new();
 | 
			
		||||
                                       for _ in 0..count {
 | 
			
		||||
                                           if i < cmd.len() {
 | 
			
		||||
                                               fields.push(cmd[i].clone());
 | 
			
		||||
                                               i += 1;
 | 
			
		||||
                                           }
 | 
			
		||||
                                       }
 | 
			
		||||
                                       return_fields = Some(fields);
 | 
			
		||||
                                   }
 | 
			
		||||
                                   _ => i += 1,
 | 
			
		||||
                               }
 | 
			
		||||
                           }
 | 
			
		||||
                           
 | 
			
		||||
                           Cmd::FtSearch {
 | 
			
		||||
                               index_name,
 | 
			
		||||
                               query,
 | 
			
		||||
                               filters,
 | 
			
		||||
                               limit,
 | 
			
		||||
                               offset,
 | 
			
		||||
                               return_fields,
 | 
			
		||||
                           }
 | 
			
		||||
                       }
 | 
			
		||||
                       "ft.del" => {
 | 
			
		||||
                           if cmd.len() != 3 {
 | 
			
		||||
                               return Err(DBError("ERR FT.DEL requires: index_name doc_id".to_string()));
 | 
			
		||||
                           }
 | 
			
		||||
                           Cmd::FtDel(cmd[1].clone(), cmd[2].clone())
 | 
			
		||||
                       }
 | 
			
		||||
                       "ft.info" => {
 | 
			
		||||
                           if cmd.len() != 2 {
 | 
			
		||||
                               return Err(DBError("ERR FT.INFO requires: index_name".to_string()));
 | 
			
		||||
                           }
 | 
			
		||||
                           Cmd::FtInfo(cmd[1].clone())
 | 
			
		||||
                       }
 | 
			
		||||
                       "ft.drop" => {
 | 
			
		||||
                           if cmd.len() != 2 {
 | 
			
		||||
                               return Err(DBError("ERR FT.DROP requires: index_name".to_string()));
 | 
			
		||||
                           }
 | 
			
		||||
                           Cmd::FtDrop(cmd[1].clone())
 | 
			
		||||
                       }
 | 
			
		||||
                        _ => Cmd::Unknow(cmd[0].clone()),
 | 
			
		||||
                    },
 | 
			
		||||
@@ -807,40 +911,30 @@ impl Cmd {
 | 
			
		||||
 | 
			
		||||
            // Full-text search commands
 | 
			
		||||
            Cmd::FtCreate { index_name, schema } => {
 | 
			
		||||
                // TODO: Implement the actual logic for creating a full-text search index.
 | 
			
		||||
                // This will involve parsing the schema and setting up the Tantivy index.
 | 
			
		||||
                println!("Creating index '{}' with schema: {:?}", index_name, schema);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
                search_cmd::ft_create_cmd(server, index_name, schema).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtAdd { index_name, doc_id, score: _, fields: _ } => {
 | 
			
		||||
                // TODO: Implement adding a document to the index.
 | 
			
		||||
                println!("Adding document '{}' to index '{}'", doc_id, index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
            Cmd::FtAdd { index_name, doc_id, score, fields } => {
 | 
			
		||||
                search_cmd::ft_add_cmd(server, index_name, doc_id, score, fields).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtSearch { index_name, query, .. } => {
 | 
			
		||||
                // TODO: Implement search functionality.
 | 
			
		||||
                println!("Searching index '{}' for query '{}'", index_name, query);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
            Cmd::FtSearch { index_name, query, filters, limit, offset, return_fields } => {
 | 
			
		||||
                search_cmd::ft_search_cmd(server, index_name, query, filters, limit, offset, return_fields).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtDel(index_name, doc_id) => {
 | 
			
		||||
                println!("Deleting doc '{}' from index '{}'", doc_id, index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
                search_cmd::ft_del_cmd(server, index_name, doc_id).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtInfo(index_name) => {
 | 
			
		||||
                println!("Getting info for index '{}'", index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
                search_cmd::ft_info_cmd(server, index_name).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtDrop(index_name) => {
 | 
			
		||||
                println!("Dropping index '{}'", index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
                search_cmd::ft_drop_cmd(server, index_name).await
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtAlter { index_name, .. } => {
 | 
			
		||||
                println!("Altering index '{}'", index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
            Cmd::FtAlter { .. } => {
 | 
			
		||||
                // Not implemented yet
 | 
			
		||||
                Ok(Protocol::err("FT.ALTER not implemented yet"))
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::FtAggregate { index_name, .. } => {
 | 
			
		||||
                println!("Aggregating on index '{}'", index_name);
 | 
			
		||||
                Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
            Cmd::FtAggregate { .. } => {
 | 
			
		||||
                // Not implemented yet
 | 
			
		||||
                Ok(Protocol::err("FT.AGGREGATE not implemented yet"))
 | 
			
		||||
            }
 | 
			
		||||
            Cmd::Unknow(s) => Ok(Protocol::err(&format!("ERR unknown command `{}`", s))),
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ pub mod crypto;
 | 
			
		||||
pub mod error;
 | 
			
		||||
pub mod options;
 | 
			
		||||
pub mod protocol;
 | 
			
		||||
pub mod search_cmd;  // Add this
 | 
			
		||||
pub mod server;
 | 
			
		||||
pub mod storage;
 | 
			
		||||
pub mod storage_trait;  // Add this
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										272
									
								
								herodb/src/search_cmd.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								herodb/src/search_cmd.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,272 @@
 | 
			
		||||
use crate::{
 | 
			
		||||
    error::DBError,
 | 
			
		||||
    protocol::Protocol,
 | 
			
		||||
    server::Server,
 | 
			
		||||
    tantivy_search::{
 | 
			
		||||
        TantivySearch, FieldDef, NumericType, IndexConfig,
 | 
			
		||||
        SearchOptions, Filter, FilterType
 | 
			
		||||
    },
 | 
			
		||||
};
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::sync::Arc;
 | 
			
		||||
 | 
			
		||||
pub async fn ft_create_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
    schema: Vec<(String, String, Vec<String>)>,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    // Parse schema into field definitions
 | 
			
		||||
    let mut field_definitions = Vec::new();
 | 
			
		||||
    
 | 
			
		||||
    for (field_name, field_type, options) in schema {
 | 
			
		||||
        let field_def = match field_type.to_uppercase().as_str() {
 | 
			
		||||
            "TEXT" => {
 | 
			
		||||
                let mut weight = 1.0;
 | 
			
		||||
                let mut sortable = false;
 | 
			
		||||
                let mut no_index = false;
 | 
			
		||||
                
 | 
			
		||||
                for opt in &options {
 | 
			
		||||
                    match opt.to_uppercase().as_str() {
 | 
			
		||||
                        "WEIGHT" => {
 | 
			
		||||
                            // Next option should be the weight value
 | 
			
		||||
                            if let Some(idx) = options.iter().position(|x| x == opt) {
 | 
			
		||||
                                if idx + 1 < options.len() {
 | 
			
		||||
                                    weight = options[idx + 1].parse().unwrap_or(1.0);
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        "SORTABLE" => sortable = true,
 | 
			
		||||
                        "NOINDEX" => no_index = true,
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                FieldDef::Text {
 | 
			
		||||
                    stored: true,
 | 
			
		||||
                    indexed: !no_index,
 | 
			
		||||
                    tokenized: true,
 | 
			
		||||
                    fast: sortable,
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            "NUMERIC" => {
 | 
			
		||||
                let mut sortable = false;
 | 
			
		||||
                
 | 
			
		||||
                for opt in &options {
 | 
			
		||||
                    if opt.to_uppercase() == "SORTABLE" {
 | 
			
		||||
                        sortable = true;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                FieldDef::Numeric {
 | 
			
		||||
                    stored: true,
 | 
			
		||||
                    indexed: true,
 | 
			
		||||
                    fast: sortable,
 | 
			
		||||
                    precision: NumericType::F64,
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            "TAG" => {
 | 
			
		||||
                let mut separator = ",".to_string();
 | 
			
		||||
                let mut case_sensitive = false;
 | 
			
		||||
                
 | 
			
		||||
                for i in 0..options.len() {
 | 
			
		||||
                    match options[i].to_uppercase().as_str() {
 | 
			
		||||
                        "SEPARATOR" => {
 | 
			
		||||
                            if i + 1 < options.len() {
 | 
			
		||||
                                separator = options[i + 1].clone();
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        "CASESENSITIVE" => case_sensitive = true,
 | 
			
		||||
                        _ => {}
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                FieldDef::Tag {
 | 
			
		||||
                    stored: true,
 | 
			
		||||
                    separator,
 | 
			
		||||
                    case_sensitive,
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            "GEO" => {
 | 
			
		||||
                FieldDef::Geo { stored: true }
 | 
			
		||||
            }
 | 
			
		||||
            _ => {
 | 
			
		||||
                return Err(DBError(format!("Unknown field type: {}", field_type)));
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        field_definitions.push((field_name, field_def));
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Create the search index
 | 
			
		||||
    let search_path = server.search_index_path();
 | 
			
		||||
    let config = IndexConfig::default();
 | 
			
		||||
    
 | 
			
		||||
    println!("Creating search index '{}' at path: {:?}", index_name, search_path);
 | 
			
		||||
    println!("Field definitions: {:?}", field_definitions);
 | 
			
		||||
    
 | 
			
		||||
    let search_index = TantivySearch::new_with_schema(
 | 
			
		||||
        search_path,
 | 
			
		||||
        index_name.clone(),
 | 
			
		||||
        field_definitions,
 | 
			
		||||
        Some(config),
 | 
			
		||||
    )?;
 | 
			
		||||
    
 | 
			
		||||
    println!("Search index '{}' created successfully", index_name);
 | 
			
		||||
    
 | 
			
		||||
    // Store in registry
 | 
			
		||||
    let mut indexes = server.search_indexes.write().unwrap();
 | 
			
		||||
    indexes.insert(index_name, Arc::new(search_index));
 | 
			
		||||
    
 | 
			
		||||
    Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn ft_add_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
    doc_id: String,
 | 
			
		||||
    _score: f64,
 | 
			
		||||
    fields: HashMap<String, String>,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    let indexes = server.search_indexes.read().unwrap();
 | 
			
		||||
    
 | 
			
		||||
    let search_index = indexes.get(&index_name)
 | 
			
		||||
        .ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
 | 
			
		||||
    
 | 
			
		||||
    search_index.add_document_with_fields(&doc_id, fields)?;
 | 
			
		||||
    
 | 
			
		||||
    Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn ft_search_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
    query: String,
 | 
			
		||||
    filters: Vec<(String, String)>,
 | 
			
		||||
    limit: Option<usize>,
 | 
			
		||||
    offset: Option<usize>,
 | 
			
		||||
    return_fields: Option<Vec<String>>,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    let indexes = server.search_indexes.read().unwrap();
 | 
			
		||||
    
 | 
			
		||||
    let search_index = indexes.get(&index_name)
 | 
			
		||||
        .ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
 | 
			
		||||
    
 | 
			
		||||
    // Convert filters to search filters
 | 
			
		||||
    let search_filters = filters.into_iter().map(|(field, value)| {
 | 
			
		||||
        Filter {
 | 
			
		||||
            field,
 | 
			
		||||
            filter_type: FilterType::Equals(value),
 | 
			
		||||
        }
 | 
			
		||||
    }).collect();
 | 
			
		||||
    
 | 
			
		||||
    let options = SearchOptions {
 | 
			
		||||
        limit: limit.unwrap_or(10),
 | 
			
		||||
        offset: offset.unwrap_or(0),
 | 
			
		||||
        filters: search_filters,
 | 
			
		||||
        sort_by: None,
 | 
			
		||||
        return_fields,
 | 
			
		||||
        highlight: false,
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    let results = search_index.search_with_options(&query, options)?;
 | 
			
		||||
    
 | 
			
		||||
    // Format results as Redis protocol
 | 
			
		||||
    let mut response = Vec::new();
 | 
			
		||||
    
 | 
			
		||||
    // First element is the total count
 | 
			
		||||
    response.push(Protocol::SimpleString(results.total.to_string()));
 | 
			
		||||
    
 | 
			
		||||
    // Then each document
 | 
			
		||||
    for doc in results.documents {
 | 
			
		||||
        let mut doc_array = Vec::new();
 | 
			
		||||
        
 | 
			
		||||
        // Add document ID if it exists
 | 
			
		||||
        if let Some(id) = doc.fields.get("_id") {
 | 
			
		||||
            doc_array.push(Protocol::BulkString(id.clone()));
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Add score
 | 
			
		||||
        doc_array.push(Protocol::BulkString(doc.score.to_string()));
 | 
			
		||||
        
 | 
			
		||||
        // Add fields as key-value pairs
 | 
			
		||||
        for (field_name, field_value) in doc.fields {
 | 
			
		||||
            if field_name != "_id" {
 | 
			
		||||
                doc_array.push(Protocol::BulkString(field_name));
 | 
			
		||||
                doc_array.push(Protocol::BulkString(field_value));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        response.push(Protocol::Array(doc_array));
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    Ok(Protocol::Array(response))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn ft_del_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
    doc_id: String,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    let indexes = server.search_indexes.read().unwrap();
 | 
			
		||||
    
 | 
			
		||||
    let _search_index = indexes.get(&index_name)
 | 
			
		||||
        .ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
 | 
			
		||||
    
 | 
			
		||||
    // For now, return success
 | 
			
		||||
    // In a full implementation, we'd need to add a delete method to TantivySearch
 | 
			
		||||
    println!("Deleting document '{}' from index '{}'", doc_id, index_name);
 | 
			
		||||
    
 | 
			
		||||
    Ok(Protocol::SimpleString("1".to_string()))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn ft_info_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    let indexes = server.search_indexes.read().unwrap();
 | 
			
		||||
    
 | 
			
		||||
    let search_index = indexes.get(&index_name)
 | 
			
		||||
        .ok_or_else(|| DBError(format!("Index '{}' not found", index_name)))?;
 | 
			
		||||
    
 | 
			
		||||
    let info = search_index.get_info()?;
 | 
			
		||||
    
 | 
			
		||||
    // Format info as Redis protocol
 | 
			
		||||
    let mut response = Vec::new();
 | 
			
		||||
    
 | 
			
		||||
    response.push(Protocol::BulkString("index_name".to_string()));
 | 
			
		||||
    response.push(Protocol::BulkString(info.name));
 | 
			
		||||
    
 | 
			
		||||
    response.push(Protocol::BulkString("num_docs".to_string()));
 | 
			
		||||
    response.push(Protocol::BulkString(info.num_docs.to_string()));
 | 
			
		||||
    
 | 
			
		||||
    response.push(Protocol::BulkString("num_fields".to_string()));
 | 
			
		||||
    response.push(Protocol::BulkString(info.fields.len().to_string()));
 | 
			
		||||
    
 | 
			
		||||
    response.push(Protocol::BulkString("fields".to_string()));
 | 
			
		||||
    let fields_str = info.fields.iter()
 | 
			
		||||
        .map(|f| format!("{}:{}", f.name, f.field_type))
 | 
			
		||||
        .collect::<Vec<_>>()
 | 
			
		||||
        .join(", ");
 | 
			
		||||
    response.push(Protocol::BulkString(fields_str));
 | 
			
		||||
    
 | 
			
		||||
    Ok(Protocol::Array(response))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub async fn ft_drop_cmd(
 | 
			
		||||
    server: &Server,
 | 
			
		||||
    index_name: String,
 | 
			
		||||
) -> Result<Protocol, DBError> {
 | 
			
		||||
    let mut indexes = server.search_indexes.write().unwrap();
 | 
			
		||||
    
 | 
			
		||||
    if indexes.remove(&index_name).is_some() {
 | 
			
		||||
        // Also remove the index files from disk
 | 
			
		||||
        let index_path = server.search_index_path().join(&index_name);
 | 
			
		||||
        if index_path.exists() {
 | 
			
		||||
            std::fs::remove_dir_all(index_path)
 | 
			
		||||
                .map_err(|e| DBError(format!("Failed to remove index files: {}", e)))?;
 | 
			
		||||
        }
 | 
			
		||||
        Ok(Protocol::SimpleString("OK".to_string()))
 | 
			
		||||
    } else {
 | 
			
		||||
        Err(DBError(format!("Index '{}' not found", index_name)))
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
 | 
			
		||||
use tokio::io::AsyncReadExt;
 | 
			
		||||
use tokio::io::AsyncWriteExt;
 | 
			
		||||
use tokio::sync::{Mutex, oneshot};
 | 
			
		||||
use std::sync::RwLock;
 | 
			
		||||
 | 
			
		||||
use std::sync::atomic::{AtomicU64, Ordering};
 | 
			
		||||
 | 
			
		||||
@@ -14,10 +15,12 @@ use crate::protocol::Protocol;
 | 
			
		||||
use crate::storage::Storage;
 | 
			
		||||
use crate::storage_sled::SledStorage;
 | 
			
		||||
use crate::storage_trait::StorageBackend;
 | 
			
		||||
use crate::tantivy_search::TantivySearch;
 | 
			
		||||
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct Server {
 | 
			
		||||
    pub db_cache: std::sync::Arc<std::sync::RwLock<HashMap<u64, Arc<dyn StorageBackend>>>>,
 | 
			
		||||
    pub db_cache: Arc<RwLock<HashMap<u64, Arc<dyn StorageBackend>>>>,
 | 
			
		||||
    pub search_indexes: Arc<RwLock<HashMap<String, Arc<TantivySearch>>>>,
 | 
			
		||||
    pub option: options::DBOption,
 | 
			
		||||
    pub client_name: Option<String>,
 | 
			
		||||
    pub selected_db: u64, // Changed from usize to u64
 | 
			
		||||
@@ -43,7 +46,8 @@ pub enum PopSide {
 | 
			
		||||
impl Server {
 | 
			
		||||
    pub async fn new(option: options::DBOption) -> Self {
 | 
			
		||||
        Server {
 | 
			
		||||
            db_cache: Arc::new(std::sync::RwLock::new(HashMap::new())),
 | 
			
		||||
            db_cache: Arc::new(RwLock::new(HashMap::new())),
 | 
			
		||||
            search_indexes: Arc::new(RwLock::new(HashMap::new())),
 | 
			
		||||
            option,
 | 
			
		||||
            client_name: None,
 | 
			
		||||
            selected_db: 0,
 | 
			
		||||
@@ -100,6 +104,11 @@ impl Server {
 | 
			
		||||
        // DB 0-9 are non-encrypted, DB 10+ are encrypted
 | 
			
		||||
        self.option.encrypt && db_index >= 10
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Add method to get search index path
 | 
			
		||||
    pub fn search_index_path(&self) -> std::path::PathBuf {
 | 
			
		||||
        std::path::PathBuf::from(&self.option.dir).join("search_indexes")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ----- BLPOP waiter helpers -----
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -228,7 +228,7 @@ impl TantivySearch {
 | 
			
		||||
        let tokenizer_manager = TokenizerManager::default();
 | 
			
		||||
        index.set_tokenizers(tokenizer_manager);
 | 
			
		||||
 | 
			
		||||
        let writer = index.writer(50_000_000)
 | 
			
		||||
        let writer = index.writer(1_000_000)
 | 
			
		||||
            .map_err(|e| DBError(format!("Failed to create index writer: {}", e)))?;
 | 
			
		||||
 | 
			
		||||
        let reader = index
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								herodb/test_tantivy_integration.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										101
									
								
								herodb/test_tantivy_integration.sh
									
									
									
									
									
										Executable file
									
								
							@@ -0,0 +1,101 @@
 | 
			
		||||
#!/bin/bash
 | 
			
		||||
 | 
			
		||||
# Simple Tantivy Search Integration Test for HeroDB
 | 
			
		||||
# This script tests the full-text search functionality we just integrated
 | 
			
		||||
 | 
			
		||||
set -e
 | 
			
		||||
 | 
			
		||||
echo "🔍 Testing Tantivy Search Integration..."
 | 
			
		||||
 | 
			
		||||
# Build the project first
 | 
			
		||||
echo "📦 Building HeroDB..."
 | 
			
		||||
cargo build --release
 | 
			
		||||
 | 
			
		||||
# Start the server in the background
 | 
			
		||||
echo "🚀 Starting HeroDB server on port 6379..."
 | 
			
		||||
cargo run --release -- --port 6379 --dir ./test_data &
 | 
			
		||||
SERVER_PID=$!
 | 
			
		||||
 | 
			
		||||
# Wait for server to start
 | 
			
		||||
sleep 3
 | 
			
		||||
 | 
			
		||||
# Function to cleanup on exit
 | 
			
		||||
cleanup() {
 | 
			
		||||
    echo "🧹 Cleaning up..."
 | 
			
		||||
    kill $SERVER_PID 2>/dev/null || true
 | 
			
		||||
    rm -rf ./test_data
 | 
			
		||||
    exit
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Set trap for cleanup
 | 
			
		||||
trap cleanup EXIT INT TERM
 | 
			
		||||
 | 
			
		||||
# Function to execute Redis command
 | 
			
		||||
execute_cmd() {
 | 
			
		||||
    local cmd="$1"
 | 
			
		||||
    local description="$2"
 | 
			
		||||
    
 | 
			
		||||
    echo "📝 $description"
 | 
			
		||||
    echo "   Command: $cmd"
 | 
			
		||||
    
 | 
			
		||||
    if result=$(redis-cli -p 6379 $cmd 2>&1); then
 | 
			
		||||
        echo "   ✅ Result: $result"
 | 
			
		||||
        echo
 | 
			
		||||
        return 0
 | 
			
		||||
    else
 | 
			
		||||
        echo "   ❌ Failed: $result"
 | 
			
		||||
        echo
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
echo "🧪 Running Tantivy Search Tests..."
 | 
			
		||||
echo
 | 
			
		||||
 | 
			
		||||
# Test 1: Create a search index
 | 
			
		||||
execute_cmd "ft.create books SCHEMA title TEXT description TEXT author TEXT category TAG price NUMERIC" \
 | 
			
		||||
           "Creating search index 'books'"
 | 
			
		||||
 | 
			
		||||
# Test 2: Add documents to the index
 | 
			
		||||
execute_cmd "ft.add books book1 1.0 title \"The Great Gatsby\" description \"A classic American novel about the Jazz Age\" author \"F. Scott Fitzgerald\" category \"fiction,classic\" price \"12.99\"" \
 | 
			
		||||
           "Adding first book"
 | 
			
		||||
 | 
			
		||||
execute_cmd "ft.add books book2 1.0 title \"To Kill a Mockingbird\" description \"A novel about racial injustice in the American South\" author \"Harper Lee\" category \"fiction,classic\" price \"14.99\"" \
 | 
			
		||||
           "Adding second book"
 | 
			
		||||
 | 
			
		||||
execute_cmd "ft.add books book3 1.0 title \"Programming Rust\" description \"A comprehensive guide to Rust programming language\" author \"Jim Blandy\" category \"programming,technical\" price \"49.99\"" \
 | 
			
		||||
           "Adding third book"
 | 
			
		||||
 | 
			
		||||
execute_cmd "ft.add books book4 1.0 title \"The Rust Programming Language\" description \"The official book on Rust programming\" author \"Steve Klabnik\" category \"programming,technical\" price \"39.99\"" \
 | 
			
		||||
           "Adding fourth book"
 | 
			
		||||
 | 
			
		||||
# Test 3: Basic search
 | 
			
		||||
execute_cmd "ft.search books Rust" \
 | 
			
		||||
           "Searching for 'Rust'"
 | 
			
		||||
 | 
			
		||||
# Test 4: Search with filters
 | 
			
		||||
execute_cmd "ft.search books programming FILTER category programming" \
 | 
			
		||||
           "Searching for 'programming' with category filter"
 | 
			
		||||
 | 
			
		||||
# Test 5: Search with limit
 | 
			
		||||
execute_cmd "ft.search books \"*\" LIMIT 0 2" \
 | 
			
		||||
           "Getting first 2 documents"
 | 
			
		||||
 | 
			
		||||
# Test 6: Get index info
 | 
			
		||||
execute_cmd "ft.info books" \
 | 
			
		||||
           "Getting index information"
 | 
			
		||||
 | 
			
		||||
# Test 7: Delete a document
 | 
			
		||||
execute_cmd "ft.del books book1" \
 | 
			
		||||
           "Deleting book1"
 | 
			
		||||
 | 
			
		||||
# Test 8: Search again to verify deletion
 | 
			
		||||
execute_cmd "ft.search books Gatsby" \
 | 
			
		||||
           "Searching for deleted book"
 | 
			
		||||
 | 
			
		||||
# Test 9: Drop the index
 | 
			
		||||
execute_cmd "ft.drop books" \
 | 
			
		||||
           "Dropping the index"
 | 
			
		||||
 | 
			
		||||
echo "🎉 All tests completed successfully!"
 | 
			
		||||
echo "✅ Tantivy search integration is working correctly"
 | 
			
		||||
		Reference in New Issue
	
	Block a user