Skip to main content

amql_engine/
query_options.rs

1//! Result-set options: sort, limit, and offset for query and select operations.
2//!
3//! Sort field syntax: `"field"` (ascending) or `"-field"` (descending).
4//! Valid fields for `unified_query` results: `line`, `name`, `file`, `attr:<name>`.
5//! Valid fields for `store::select` results: `tag`, `file`, `binding`, `attr:<name>`.
6//!
7//! Agentic or LLM-based comparison belongs in `QueryMiddleware` — the consumer
8//! injects a middleware that re-orders results after the engine produces them.
9
10#[cfg(all(feature = "resolver", feature = "fs"))]
11use crate::query::QueryResult;
12use crate::store::Annotation;
13use serde::{Deserialize, Serialize};
14
15/// Options applied to query and select result sets after matching.
16#[non_exhaustive]
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
19pub struct QueryOptions {
20    /// Return at most this many results.
21    pub limit: Option<usize>,
22    /// Skip this many results before applying `limit`.
23    pub offset: Option<usize>,
24    /// Sort field. Prefix with `-` for descending.
25    /// For `query`: `line`, `name`, `file`, `attr:<name>`.
26    /// For `select`: `tag`, `file`, `binding`, `attr:<name>`.
27    pub sort_by: Option<String>,
28}
29
30impl QueryOptions {
31    /// Create options with explicit limit, offset, and sort_by.
32    pub fn new(limit: Option<usize>, offset: Option<usize>, sort_by: Option<String>) -> Self {
33        Self {
34            limit,
35            offset,
36            sort_by,
37        }
38    }
39}
40
41/// Apply `QueryOptions` to query results (sort → skip → take).
42#[cfg(all(feature = "resolver", feature = "fs"))]
43pub fn apply_to_query_results(
44    mut results: Vec<QueryResult>,
45    opts: &QueryOptions,
46) -> Vec<QueryResult> {
47    if let Some(ref field) = opts.sort_by {
48        sort_query_results(&mut results, field);
49    }
50    let offset = opts.offset.unwrap_or(0);
51    results
52        .into_iter()
53        .skip(offset)
54        .take(opts.limit.unwrap_or(usize::MAX))
55        .collect()
56}
57
58/// Apply `QueryOptions` to annotations (sort → skip → take).
59pub fn apply_to_annotations(mut results: Vec<Annotation>, opts: &QueryOptions) -> Vec<Annotation> {
60    if let Some(ref field) = opts.sort_by {
61        sort_annotations(&mut results, field);
62    }
63    let offset = opts.offset.unwrap_or(0);
64    results
65        .into_iter()
66        .skip(offset)
67        .take(opts.limit.unwrap_or(usize::MAX))
68        .collect()
69}
70
71#[cfg(all(feature = "resolver", feature = "fs"))]
72fn sort_query_results(results: &mut [QueryResult], field: &str) {
73    let (field, desc) = parse_field(field);
74    results.sort_by(|a, b| {
75        let ord = match field {
76            "line" => a.code_element.source.line.cmp(&b.code_element.source.line),
77            "name" => {
78                let an: &str = &a.code_element.name;
79                let bn: &str = &b.code_element.name;
80                an.cmp(bn)
81            }
82            "file" => {
83                let af: &str = &a.code_element.file;
84                let bf: &str = &b.code_element.file;
85                af.cmp(bf)
86            }
87            f if f.starts_with("attr:") => {
88                let attr = &f["attr:".len()..];
89                let attr_key = crate::types::AttrName::from(attr);
90                let av = a
91                    .code_element
92                    .attrs
93                    .get(&attr_key)
94                    .map(attr_sort_key)
95                    .unwrap_or_default();
96                let bv = b
97                    .code_element
98                    .attrs
99                    .get(&attr_key)
100                    .map(attr_sort_key)
101                    .unwrap_or_default();
102                av.cmp(&bv)
103            }
104            _ => std::cmp::Ordering::Equal,
105        };
106        if desc {
107            ord.reverse()
108        } else {
109            ord
110        }
111    });
112}
113
114fn sort_annotations(results: &mut [Annotation], field: &str) {
115    let (field, desc) = parse_field(field);
116    results.sort_by(|a, b| {
117        let ord = match field {
118            "tag" => {
119                let at: &str = &a.tag;
120                let bt: &str = &b.tag;
121                at.cmp(bt)
122            }
123            "file" => {
124                let af: &str = &a.file;
125                let bf: &str = &b.file;
126                af.cmp(bf)
127            }
128            "binding" => {
129                let ab: &str = &a.binding;
130                let bb: &str = &b.binding;
131                ab.cmp(bb)
132            }
133            f if f.starts_with("attr:") => {
134                let attr = &f["attr:".len()..];
135                let attr_key = crate::types::AttrName::from(attr);
136                let av = a
137                    .attrs
138                    .get(&attr_key)
139                    .map(attr_sort_key)
140                    .unwrap_or_default();
141                let bv = b
142                    .attrs
143                    .get(&attr_key)
144                    .map(attr_sort_key)
145                    .unwrap_or_default();
146                av.cmp(&bv)
147            }
148            _ => std::cmp::Ordering::Equal,
149        };
150        if desc {
151            ord.reverse()
152        } else {
153            ord
154        }
155    });
156}
157
158/// Parse a sort field string: strip leading `-` for descending. Returns `(field, is_desc)`.
159fn parse_field(s: &str) -> (&str, bool) {
160    if let Some(f) = s.strip_prefix('-') {
161        (f, true)
162    } else {
163        (s, false)
164    }
165}
166
167/// Derive a stable string sort key from a JSON attribute value.
168fn attr_sort_key(v: &serde_json::Value) -> String {
169    match v {
170        serde_json::Value::String(s) => s.clone(),
171        serde_json::Value::Number(n) => {
172            // Zero-pad numbers for correct lexicographic ordering (up to 20 digits).
173            if let Some(i) = n.as_u64() {
174                format!("{i:020}")
175            } else if let Some(i) = n.as_i64() {
176                // Negative: shift by i64::MIN to make unsigned-comparable.
177                format!("{:020}", i.wrapping_sub(i64::MIN))
178            } else {
179                n.to_string()
180            }
181        }
182        serde_json::Value::Bool(b) => b.to_string(),
183        serde_json::Value::Null => String::new(),
184        other => other.to_string(),
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::store::Annotation;
192    use crate::types::{Binding, RelativePath, TagName};
193    use rustc_hash::FxHashMap;
194
195    fn make_ann(tag: &str, file: &str) -> Annotation {
196        Annotation {
197            tag: TagName::from(tag),
198            attrs: FxHashMap::default(),
199            binding: Binding::from(""),
200            file: RelativePath::from(file),
201            children: vec![],
202        }
203    }
204
205    #[test]
206    fn sorts_annotations_by_tag() {
207        // Arrange
208        let results = vec![
209            make_ann("route", "b.ts"),
210            make_ann("controller", "a.ts"),
211            make_ann("middleware", "c.ts"),
212        ];
213        let opts = QueryOptions {
214            sort_by: Some("tag".into()),
215            ..Default::default()
216        };
217
218        // Act
219        let results = apply_to_annotations(results, &opts);
220
221        // Assert
222        let tags: Vec<&str> = results.iter().map(|a| a.tag.as_ref()).collect();
223        assert_eq!(
224            tags,
225            ["controller", "middleware", "route"],
226            "should be sorted by tag asc"
227        );
228    }
229
230    #[test]
231    fn applies_limit_and_offset() {
232        // Arrange
233        let results: Vec<Annotation> = (0..5).map(|i| make_ann(&format!("t{i}"), "f.ts")).collect();
234        let opts = QueryOptions {
235            offset: Some(1),
236            limit: Some(2),
237            ..Default::default()
238        };
239
240        // Act
241        let results = apply_to_annotations(results, &opts);
242
243        // Assert
244        assert_eq!(results.len(), 2, "exactly 2 results after offset+limit");
245        let tag: &str = &results[0].tag;
246        assert_eq!(
247            tag, "t1",
248            "first result is the second element (after offset 1)"
249        );
250    }
251
252    #[test]
253    fn sorts_descending_with_minus_prefix() {
254        // Arrange
255        let results = vec![
256            make_ann("a", "f.ts"),
257            make_ann("c", "f.ts"),
258            make_ann("b", "f.ts"),
259        ];
260        let opts = QueryOptions {
261            sort_by: Some("-tag".into()),
262            ..Default::default()
263        };
264
265        // Act
266        let results = apply_to_annotations(results, &opts);
267
268        // Assert
269        let tags: Vec<&str> = results.iter().map(|a| a.tag.as_ref()).collect();
270        assert_eq!(tags, ["c", "b", "a"], "should be sorted by tag desc");
271    }
272}