pubmed_client/pubmed/client/
egquery.rs

1//! EGQuery API operations for querying across all NCBI databases
2
3use crate::error::{PubMedError, Result};
4use crate::pubmed::models::{DatabaseCount, GlobalQueryResults};
5use tracing::{debug, info, instrument};
6
7use super::PubMedClient;
8
9impl PubMedClient {
10    /// Query all NCBI databases for record counts using the EGQuery API
11    ///
12    /// Returns the number of records matching the query in each Entrez database.
13    /// Useful for exploratory searches and understanding data distribution across databases.
14    ///
15    /// # Arguments
16    ///
17    /// * `term` - Search query string
18    ///
19    /// # Returns
20    ///
21    /// Returns a `Result<GlobalQueryResults>` containing counts per database
22    ///
23    /// # Example
24    ///
25    /// ```no_run
26    /// use pubmed_client::PubMedClient;
27    ///
28    /// #[tokio::main]
29    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
30    ///     let client = PubMedClient::new();
31    ///     let results = client.global_query("asthma").await?;
32    ///     println!("Query: {}", results.term);
33    ///     for db in results.non_zero() {
34    ///         println!("  {}: {} records", db.menu_name, db.count);
35    ///     }
36    ///     Ok(())
37    /// }
38    /// ```
39    #[instrument(skip(self))]
40    pub async fn global_query(&self, term: &str) -> Result<GlobalQueryResults> {
41        let term = term.trim();
42        if term.is_empty() {
43            return Err(PubMedError::InvalidQuery(
44                "Search term cannot be empty".to_string(),
45            ));
46        }
47
48        debug!(term = %term, "Making EGQuery API request");
49        let response = self.get_eutils("egquery.fcgi", &[("term", term)]).await?;
50        let xml_text = response.text().await?;
51
52        // Parse XML response using string-based extraction (consistent with existing xml_utils)
53        let results = Self::parse_egquery_response(&xml_text, term)?;
54
55        info!(
56            term = %term,
57            database_count = results.results.len(),
58            non_zero_count = results.non_zero().len(),
59            "EGQuery completed"
60        );
61
62        Ok(results)
63    }
64
65    /// Parse EGQuery XML response into GlobalQueryResults
66    pub(crate) fn parse_egquery_response(
67        xml: &str,
68        query_term: &str,
69    ) -> Result<GlobalQueryResults> {
70        use crate::pubmed::client::xml_text::{extract_all_text_between, extract_text_between};
71
72        // Extract the term from response, fallback to the query term
73        let term = extract_text_between(xml, "<Term>", "</Term>")
74            .unwrap_or_else(|| query_term.to_string());
75
76        // Extract all ResultItem blocks
77        let result_items = extract_all_text_between(xml, "<ResultItem>", "</ResultItem>");
78
79        let mut results = Vec::new();
80        for item in &result_items {
81            let db_name = extract_text_between(item, "<DbName>", "</DbName>").unwrap_or_default();
82            let menu_name =
83                extract_text_between(item, "<MenuName>", "</MenuName>").unwrap_or_default();
84            let count_str = extract_text_between(item, "<Count>", "</Count>").unwrap_or_default();
85            let status = extract_text_between(item, "<Status>", "</Status>").unwrap_or_default();
86
87            let count = count_str.parse::<u64>().unwrap_or(0);
88
89            results.push(DatabaseCount {
90                db_name,
91                menu_name,
92                count,
93                status,
94            });
95        }
96
97        Ok(GlobalQueryResults { term, results })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_parse_egquery_response() {
107        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
108<Result>
109  <Term>asthma</Term>
110  <eGQueryResult>
111    <ResultItem>
112      <DbName>pubmed</DbName>
113      <MenuName>PubMed</MenuName>
114      <Count>234567</Count>
115      <Status>Ok</Status>
116    </ResultItem>
117    <ResultItem>
118      <DbName>pmc</DbName>
119      <MenuName>PMC</MenuName>
120      <Count>89012</Count>
121      <Status>Ok</Status>
122    </ResultItem>
123    <ResultItem>
124      <DbName>mesh</DbName>
125      <MenuName>MeSH</MenuName>
126      <Count>0</Count>
127      <Status>Ok</Status>
128    </ResultItem>
129  </eGQueryResult>
130</Result>"#;
131
132        let result = PubMedClient::parse_egquery_response(xml, "asthma").unwrap();
133        assert_eq!(result.term, "asthma");
134        assert_eq!(result.results.len(), 3);
135
136        assert_eq!(result.results[0].db_name, "pubmed");
137        assert_eq!(result.results[0].menu_name, "PubMed");
138        assert_eq!(result.results[0].count, 234567);
139        assert_eq!(result.results[0].status, "Ok");
140
141        assert_eq!(result.results[1].db_name, "pmc");
142        assert_eq!(result.results[1].count, 89012);
143
144        // Test helper methods
145        let non_zero = result.non_zero();
146        assert_eq!(non_zero.len(), 2); // pubmed and pmc, not mesh
147
148        assert_eq!(result.count_for("pubmed"), Some(234567));
149        assert_eq!(result.count_for("pmc"), Some(89012));
150        assert_eq!(result.count_for("mesh"), Some(0));
151        assert_eq!(result.count_for("nonexistent"), None);
152    }
153
154    #[test]
155    fn test_parse_egquery_response_empty() {
156        let xml = r#"<Result><Term>test</Term><eGQueryResult></eGQueryResult></Result>"#;
157        let result = PubMedClient::parse_egquery_response(xml, "test").unwrap();
158        assert_eq!(result.term, "test");
159        assert!(result.results.is_empty());
160    }
161
162    #[test]
163    fn test_parse_egquery_response_error_status() {
164        let xml = r#"<Result>
165  <Term>test</Term>
166  <eGQueryResult>
167    <ResultItem>
168      <DbName>pubmed</DbName>
169      <MenuName>PubMed</MenuName>
170      <Count>100</Count>
171      <Status>Ok</Status>
172    </ResultItem>
173    <ResultItem>
174      <DbName>snp</DbName>
175      <MenuName>SNP</MenuName>
176      <Count>0</Count>
177      <Status>Term or Database is not found</Status>
178    </ResultItem>
179  </eGQueryResult>
180</Result>"#;
181        let result = PubMedClient::parse_egquery_response(xml, "test").unwrap();
182        assert_eq!(result.results.len(), 2);
183        assert_eq!(result.results[1].status, "Term or Database is not found");
184    }
185
186    #[test]
187    fn test_global_query_empty_term() {
188        use tokio_test;
189        let client = PubMedClient::new();
190        let result = tokio_test::block_on(client.global_query(""));
191        assert!(result.is_err());
192    }
193
194    #[test]
195    fn test_global_query_whitespace_term() {
196        use tokio_test;
197        let client = PubMedClient::new();
198        let result = tokio_test::block_on(client.global_query("   "));
199        assert!(result.is_err());
200    }
201}