Skip to main content

shaperail_runtime/db/
search.rs

1/// Full-text search parameter using PostgreSQL `to_tsvector`.
2///
3/// When `?search=term` is provided and the endpoint declares `search: [field1, field2]`,
4/// a `WHERE to_tsvector('english', field1 || ' ' || field2) @@ plainto_tsquery('english', $N)`
5/// clause is appended.
6#[derive(Debug, Clone)]
7pub struct SearchParam {
8    /// The search term from the query string.
9    pub term: String,
10    /// The fields to search across (from endpoint spec `search` list).
11    pub fields: Vec<String>,
12}
13
14impl SearchParam {
15    /// Creates a new search parameter if both term and fields are non-empty.
16    pub fn new(term: &str, fields: &[String]) -> Option<Self> {
17        let term = term.trim();
18        if term.is_empty() || fields.is_empty() {
19            return None;
20        }
21        Some(SearchParam {
22            term: term.to_string(),
23            fields: fields.to_vec(),
24        })
25    }
26
27    /// Appends a full-text search WHERE clause to the SQL string.
28    ///
29    /// Uses `to_tsvector('english', ...)` and `plainto_tsquery('english', $N)`.
30    /// `param_offset` is the `$N` parameter index for the search term.
31    /// Returns the new parameter offset after appending.
32    pub fn apply_to_sql(&self, sql: &mut String, has_where: bool, param_offset: usize) -> usize {
33        if has_where {
34            sql.push_str(" AND ");
35        } else {
36            sql.push_str(" WHERE ");
37        }
38
39        // Build the concatenated text vector: coalesce(field1,'') || ' ' || coalesce(field2,'')
40        let tsvector_expr = self
41            .fields
42            .iter()
43            .enumerate()
44            .map(|(i, f)| {
45                if i == 0 {
46                    format!("COALESCE(\"{f}\", '')")
47                } else {
48                    format!(" || ' ' || COALESCE(\"{f}\", '')")
49                }
50            })
51            .collect::<String>();
52
53        sql.push_str(&format!(
54            "to_tsvector('english', {tsvector_expr}) @@ plainto_tsquery('english', ${param_offset})"
55        ));
56
57        param_offset + 1
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn search_param_new_valid() {
67        let fields = vec!["name".to_string(), "email".to_string()];
68        let sp = SearchParam::new("john", &fields);
69        assert!(sp.is_some());
70        let sp = sp.unwrap();
71        assert_eq!(sp.term, "john");
72        assert_eq!(sp.fields.len(), 2);
73    }
74
75    #[test]
76    fn search_param_new_empty_term() {
77        let fields = vec!["name".to_string()];
78        assert!(SearchParam::new("", &fields).is_none());
79        assert!(SearchParam::new("  ", &fields).is_none());
80    }
81
82    #[test]
83    fn search_param_new_empty_fields() {
84        assert!(SearchParam::new("john", &[]).is_none());
85    }
86
87    #[test]
88    fn apply_search_single_field() {
89        let sp = SearchParam {
90            term: "john".to_string(),
91            fields: vec!["name".to_string()],
92        };
93
94        let mut sql = "SELECT * FROM users".to_string();
95        let offset = sp.apply_to_sql(&mut sql, false, 1);
96
97        assert_eq!(
98            sql,
99            "SELECT * FROM users WHERE to_tsvector('english', COALESCE(\"name\", '')) @@ plainto_tsquery('english', $1)"
100        );
101        assert_eq!(offset, 2);
102    }
103
104    #[test]
105    fn apply_search_multiple_fields() {
106        let sp = SearchParam {
107            term: "john".to_string(),
108            fields: vec!["name".to_string(), "email".to_string()],
109        };
110
111        let mut sql = "SELECT * FROM users".to_string();
112        let offset = sp.apply_to_sql(&mut sql, false, 1);
113
114        assert_eq!(
115            sql,
116            "SELECT * FROM users WHERE to_tsvector('english', COALESCE(\"name\", '') || ' ' || COALESCE(\"email\", '')) @@ plainto_tsquery('english', $1)"
117        );
118        assert_eq!(offset, 2);
119    }
120
121    #[test]
122    fn apply_search_with_existing_where() {
123        let sp = SearchParam {
124            term: "john".to_string(),
125            fields: vec!["name".to_string()],
126        };
127
128        let mut sql = "SELECT * FROM users WHERE \"role\" = $1".to_string();
129        let offset = sp.apply_to_sql(&mut sql, true, 2);
130
131        assert!(sql.contains("AND to_tsvector"));
132        assert_eq!(offset, 3);
133    }
134}