Skip to main content

panproto_protocols/database/
redis.rs

1//! Redis RediSearch protocol definition.
2//!
3//! Uses Group C theory: simple graph + flat.
4
5use std::collections::HashMap;
6use std::hash::BuildHasher;
7
8use panproto_gat::Theory;
9use panproto_schema::{EdgeRule, Protocol, Schema, SchemaBuilder};
10
11use crate::emit::{IndentWriter, children_by_edge, find_roots};
12use crate::error::ProtocolError;
13use crate::theories;
14
15/// Returns the Redis protocol definition.
16#[must_use]
17pub fn protocol() -> Protocol {
18    Protocol {
19        name: "redis".into(),
20        schema_theory: "ThRedisSchema".into(),
21        instance_theory: "ThRedisInstance".into(),
22        edge_rules: edge_rules(),
23        obj_kinds: vec![
24            "index".into(),
25            "field".into(),
26            "text".into(),
27            "tag".into(),
28            "numeric".into(),
29            "geo".into(),
30            "vector".into(),
31        ],
32        constraint_sorts: vec![],
33        has_order: true,
34        nominal_identity: true,
35        ..Protocol::default()
36    }
37}
38
39/// Register the component GATs for Redis.
40pub fn register_theories<S: BuildHasher>(registry: &mut HashMap<String, Theory, S>) {
41    theories::register_simple_graph_flat(registry, "ThRedisSchema", "ThRedisInstance");
42}
43
44/// Parse FT.CREATE syntax into a [`Schema`].
45///
46/// Expects syntax like:
47/// ```text
48/// FT.CREATE idx ON HASH PREFIX 1 doc: SCHEMA title TEXT name TAG age NUMERIC
49/// ```
50///
51/// # Errors
52///
53/// Returns [`ProtocolError`] if parsing fails.
54pub fn parse_redis_schema(input: &str) -> Result<Schema, ProtocolError> {
55    let proto = protocol();
56    let mut builder = SchemaBuilder::new(&proto);
57
58    for line in input.lines() {
59        let trimmed = line.trim();
60        if trimmed.is_empty() || trimmed.starts_with('#') {
61            continue;
62        }
63
64        if trimmed.starts_with("FT.CREATE") {
65            builder = parse_ft_create(builder, trimmed)?;
66        }
67    }
68
69    let schema = builder.build()?;
70    Ok(schema)
71}
72
73/// Parse a single FT.CREATE statement.
74fn parse_ft_create(mut builder: SchemaBuilder, line: &str) -> Result<SchemaBuilder, ProtocolError> {
75    let parts: Vec<&str> = line.split_whitespace().collect();
76    if parts.len() < 2 {
77        return Err(ProtocolError::Parse("invalid FT.CREATE".into()));
78    }
79
80    let index_name = parts[1];
81    builder = builder.vertex(index_name, "index", None)?;
82
83    // Find SCHEMA keyword and parse field definitions after it.
84    let schema_idx = parts.iter().position(|p| p.eq_ignore_ascii_case("SCHEMA"));
85    if let Some(idx) = schema_idx {
86        let mut i = idx + 1;
87        while i < parts.len() {
88            let field_name = parts[i];
89            let field_type = parts.get(i + 1).copied().unwrap_or("TEXT");
90            let field_id = format!("{index_name}.{field_name}");
91            let kind = redis_type_to_kind(field_type);
92            builder = builder.vertex(&field_id, kind, None)?;
93            builder = builder.edge(index_name, &field_id, "prop", Some(field_name))?;
94            i += 2;
95        }
96    }
97
98    Ok(builder)
99}
100
101/// Map Redis field type to vertex kind.
102fn redis_type_to_kind(type_str: &str) -> &'static str {
103    match type_str.to_uppercase().as_str() {
104        "TEXT" => "text",
105        "TAG" => "tag",
106        "NUMERIC" => "numeric",
107        "GEO" => "geo",
108        "VECTOR" => "vector",
109        _ => "field",
110    }
111}
112
113/// Map vertex kind to Redis field type.
114fn kind_to_redis_type(kind: &str) -> &'static str {
115    match kind {
116        "text" => "TEXT",
117        "tag" => "TAG",
118        "numeric" => "NUMERIC",
119        "geo" => "GEO",
120        "vector" => "VECTOR",
121        _ => "TEXT",
122    }
123}
124
125/// Emit a [`Schema`] as FT.CREATE syntax.
126///
127/// # Errors
128///
129/// Returns [`ProtocolError`] if emission fails.
130pub fn emit_redis_schema(schema: &Schema) -> Result<String, ProtocolError> {
131    let structural = &["prop"];
132    let roots = find_roots(schema, structural);
133
134    let mut w = IndentWriter::new("  ");
135
136    for root in &roots {
137        if root.kind != "index" {
138            continue;
139        }
140        let fields = children_by_edge(schema, &root.id, "prop");
141        let field_strs: Vec<String> = fields
142            .iter()
143            .map(|(edge, child)| {
144                let name = edge.name.as_deref().unwrap_or(&child.id);
145                let type_str = kind_to_redis_type(&child.kind);
146                format!("{name} {type_str}")
147            })
148            .collect();
149        w.line(&format!(
150            "FT.CREATE {} ON HASH SCHEMA {}",
151            root.id,
152            field_strs.join(" ")
153        ));
154    }
155
156    Ok(w.finish())
157}
158
159fn edge_rules() -> Vec<EdgeRule> {
160    vec![EdgeRule {
161        edge_kind: "prop".into(),
162        src_kinds: vec!["index".into()],
163        tgt_kinds: vec![
164            "text".into(),
165            "tag".into(),
166            "numeric".into(),
167            "geo".into(),
168            "vector".into(),
169            "field".into(),
170        ],
171    }]
172}
173
174#[cfg(test)]
175#[allow(clippy::expect_used, clippy::unwrap_used)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn protocol_def() {
181        let p = protocol();
182        assert_eq!(p.name, "redis");
183    }
184
185    #[test]
186    fn register_theories_works() {
187        let mut registry = HashMap::new();
188        register_theories(&mut registry);
189        assert!(registry.contains_key("ThRedisSchema"));
190        assert!(registry.contains_key("ThRedisInstance"));
191    }
192
193    #[test]
194    fn parse_and_emit() {
195        let input = "FT.CREATE myidx ON HASH SCHEMA title TEXT name TAG age NUMERIC\n";
196        let schema = parse_redis_schema(input).expect("should parse");
197        assert!(schema.has_vertex("myidx"));
198        assert!(schema.has_vertex("myidx.title"));
199        assert!(schema.has_vertex("myidx.name"));
200        assert!(schema.has_vertex("myidx.age"));
201
202        let emitted = emit_redis_schema(&schema).expect("should emit");
203        assert!(emitted.contains("FT.CREATE myidx"));
204        assert!(emitted.contains("title TEXT"));
205    }
206
207    #[test]
208    fn roundtrip() {
209        let input = "FT.CREATE idx ON HASH SCHEMA f1 TEXT f2 NUMERIC\n";
210        let s1 = parse_redis_schema(input).expect("parse");
211        let emitted = emit_redis_schema(&s1).expect("emit");
212        let s2 = parse_redis_schema(&emitted).expect("re-parse");
213        assert_eq!(s1.vertex_count(), s2.vertex_count());
214    }
215}