Skip to main content

tmprl_core/
query.rs

1//! Visibility query strings.
2//!
3//! The raw query is the interface. tmprl never holds a structured filter that it renders
4//! down to a string the user cannot see, every filter the interface offers compiles *into*
5//! this text, and the text stays editable. A lossy abstraction over the query is the thing
6//! that makes the web UI's filter bar frustrating, and it is not worth reproducing.
7//!
8//! So the only query manipulation here is what the RPCs actually require: adapting a
9//! user-authored filter for `CountWorkflowExecutions`, which does not accept `ORDER BY` and
10//! needs its own `GROUP BY`.
11
12/// The clause the header counts group on.
13const GROUP_BY_STATUS: &str = "GROUP BY ExecutionStatus";
14
15/// Turn a user's list query into the one that produces the header counts.
16///
17/// `CountWorkflowExecutions` rejects `ORDER BY`, so it is stripped; any `GROUP BY` the user
18/// wrote is replaced, because the header renders per-status counts and nothing else.
19pub fn count_query(filter: &str) -> String {
20    let filter = strip_clause(filter, "group by");
21    let filter = strip_clause(&filter, "order by");
22    let filter = filter.trim();
23    if filter.is_empty() {
24        GROUP_BY_STATUS.to_string()
25    } else {
26        format!("{filter} {GROUP_BY_STATUS}")
27    }
28}
29
30/// Remove a trailing `<keyword> ...` clause, if the query has one.
31///
32/// Matching is case-insensitive and skips anything inside single quotes, so a workflow id
33/// like `'daily order by region'` is not mistaken for a clause. Only the last occurrence is
34/// cut, which is what a trailing clause is.
35fn strip_clause(query: &str, keyword: &str) -> String {
36    match find_clause(query, keyword) {
37        Some(at) => query[..at].trim_end().to_string(),
38        None => query.trim_end().to_string(),
39    }
40}
41
42/// Byte offset of the last unquoted occurrence of `keyword`, which the caller passes in
43/// lowercase. Whitespace inside the keyword is matched flexibly so that `ORDER   BY` and
44/// `order by` both count.
45fn find_clause(query: &str, keyword: &str) -> Option<usize> {
46    let words: Vec<&str> = keyword.split_whitespace().collect();
47    let bytes = query.as_bytes();
48    let mut in_quote = false;
49    let mut found = None;
50    let mut i = 0;
51
52    while i < bytes.len() {
53        if bytes[i] == b'\'' {
54            in_quote = !in_quote;
55            i += 1;
56            continue;
57        }
58        if in_quote {
59            i += 1;
60            continue;
61        }
62        // A clause keyword must start at a word boundary, or `reorder by` would match.
63        let at_boundary = i == 0 || !is_word_byte(bytes[i - 1]);
64        if at_boundary && let Some(end) = match_words(bytes, i, &words) {
65            found = Some(i);
66            i = end;
67            continue;
68        }
69        i += 1;
70    }
71    found
72}
73
74/// If `words` match at `start` separated by whitespace, the offset just past them.
75fn match_words(bytes: &[u8], start: usize, words: &[&str]) -> Option<usize> {
76    let mut i = start;
77    for (n, word) in words.iter().enumerate() {
78        if n > 0 {
79            let ws = i;
80            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
81                i += 1;
82            }
83            if i == ws {
84                return None;
85            }
86        }
87        let end = i + word.len();
88        if end > bytes.len() || !bytes[i..end].eq_ignore_ascii_case(word.as_bytes()) {
89            return None;
90        }
91        i = end;
92    }
93    // The keyword must end at a word boundary too.
94    if i < bytes.len() && is_word_byte(bytes[i]) {
95        return None;
96    }
97    Some(i)
98}
99
100fn is_word_byte(b: u8) -> bool {
101    b.is_ascii_alphanumeric() || b == b'_'
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn an_empty_filter_still_groups() {
110        assert_eq!(count_query(""), "GROUP BY ExecutionStatus");
111        assert_eq!(count_query("   "), "GROUP BY ExecutionStatus");
112    }
113
114    #[test]
115    fn a_filter_is_kept_and_grouped() {
116        assert_eq!(
117            count_query("WorkflowType = 'Foo'"),
118            "WorkflowType = 'Foo' GROUP BY ExecutionStatus"
119        );
120    }
121
122    #[test]
123    fn order_by_is_stripped_because_count_rejects_it() {
124        assert_eq!(
125            count_query("WorkflowType = 'Foo' ORDER BY StartTime DESC"),
126            "WorkflowType = 'Foo' GROUP BY ExecutionStatus"
127        );
128        // Case and spacing vary in hand-written queries.
129        assert_eq!(
130            count_query("WorkflowType = 'Foo' order   by StartTime"),
131            "WorkflowType = 'Foo' GROUP BY ExecutionStatus"
132        );
133    }
134
135    #[test]
136    fn an_existing_group_by_is_replaced_not_appended() {
137        assert_eq!(
138            count_query("WorkflowType = 'Foo' GROUP BY WorkflowType"),
139            "WorkflowType = 'Foo' GROUP BY ExecutionStatus"
140        );
141    }
142
143    #[test]
144    fn both_clauses_are_stripped_together() {
145        assert_eq!(
146            count_query("A = 1 GROUP BY B ORDER BY C"),
147            "A = 1 GROUP BY ExecutionStatus"
148        );
149    }
150
151    #[test]
152    fn a_quoted_value_is_not_mistaken_for_a_clause() {
153        // This is the bug a naive `find("order by")` would ship: a workflow id that
154        // happens to contain the words would truncate the user's filter.
155        assert_eq!(
156            count_query("WorkflowId = 'daily order by region'"),
157            "WorkflowId = 'daily order by region' GROUP BY ExecutionStatus"
158        );
159    }
160
161    #[test]
162    fn a_keyword_inside_an_identifier_is_not_a_clause() {
163        assert_eq!(
164            count_query("MyOrder By_Field = 1"),
165            "MyOrder By_Field = 1 GROUP BY ExecutionStatus"
166        );
167    }
168}