quick_oxibooks_sql_macro/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{
4    Ident, Token,
5    parse::{Parse, ParseStream},
6};
7
8use crate::query::SqlQuery;
9
10mod condition;
11mod limit;
12mod orderby;
13mod query;
14
15/// Builds a type-safe `QuickBooks` Online query at compile time.
16///
17/// This macro parses SQL-like syntax and generates a `Query<T>` struct that can be used to query
18/// the `QuickBooks` Online API. Field names are automatically validated at compile time and converted
19/// from `snake_case` to CamelCase to match `QuickBooks` naming conventions.
20///
21/// # Syntax
22///
23/// ```text
24/// qb_sql!(
25///     select [* | field1, field2, ...]
26///     from EntityType
27///     [where condition [and condition ...]]
28///     [order by field [asc|desc] [, field [asc|desc] ...]]
29///     [limit number [offset number]]
30/// )
31/// ```
32///
33/// # Supported Operators
34///
35/// - `=` - Equality comparison
36/// - `>`, `<`, `>=`, `<=` - Numeric comparisons
37/// - `like` - Pattern matching (use `%` as wildcard)
38/// - `in` - Match against multiple values: `field in (val1, val2, ...)` or `field in (iterator)`
39///
40/// # Examples
41///
42/// Basic query with field selection:
43/// ```ignore
44/// use quick_oxibooks_sql::qb_sql;
45/// use quickbooks_types::Customer;
46///
47/// let query = qb_sql!(
48///     select display_name, balance from Customer
49///     where balance >= 1000.0
50///     order by display_name asc
51///     limit 10
52/// );
53/// ```
54///
55/// Using Rust variables in conditions:
56/// ```ignore
57/// let min_balance = 500.0;
58/// let name_pattern = "Acme%";
59///
60/// let query = qb_sql!(
61///     select * from Customer
62///     where balance >= min_balance
63///     and display_name like name_pattern
64/// );
65/// ```
66///
67/// Using the `in` operator with a tuple or iterator:
68/// ```ignore
69/// // With literal values
70/// let query = qb_sql!(
71///     select * from Customer
72///     where id in (1, 2, 3)
73/// );
74///
75/// // With an iterator (single expression)
76/// let ids = vec!["1", "2", "3"];
77/// let query = qb_sql!(
78///     select * from Customer
79///     where id in (ids)
80/// );
81/// ```
82///
83/// Executing a query (requires the `api` feature):
84/// ```ignore
85/// use quick_oxibooks::{Environment, QBContext};
86/// use ureq::Agent;
87///
88/// let client = Agent::new();
89/// let qb = QBContext::new(Environment::SANDBOX, "company_id".into(), "token".into(), &client)?;
90///
91/// let results = query.execute(&qb, &client)?;
92/// ```
93///
94/// # Notes
95///
96/// - Field names are automatically converted from `snake_case` to CamelCase (e.g., `display_name` → `DisplayName`)
97/// - All field names are validated at compile time against the entity type
98/// - The generated query can be converted to a string with `.query_string()` or by displaying it
99/// - For the `in` operator, use a tuple for literals or a single iterator expression
100#[proc_macro]
101pub fn qb_sql(input: TokenStream) -> TokenStream {
102    let query = syn::parse_macro_input!(input as SqlQuery);
103    let expanded = query.expand();
104    TokenStream::from(expanded)
105}
106
107/// Represents a `ExprField` that is type checked with options for nested fields
108///
109/// At least one field is required, and additional fields can be chained using dot notation.
110/// For example: `field1.field2.field3`
111struct OptionField(Vec<Ident>);
112
113impl OptionField {
114    fn to_camel_case_string(&self) -> String {
115        let camel_parts: Vec<String> = self
116            .0
117            .iter()
118            .map(|ident| snake_to_camel_case(&ident.to_string()))
119            .collect();
120        camel_parts.join(".")
121    }
122}
123
124impl Parse for OptionField {
125    fn parse(input: ParseStream) -> syn::Result<Self> {
126        let mut idents = Vec::new();
127
128        // Parse the first identifier
129        idents.push(input.parse::<Ident>()?);
130
131        // Parse any additional identifiers separated by dots
132        while input.peek(Token![.]) {
133            input.parse::<Token![.]>()?;
134            idents.push(input.parse::<Ident>()?);
135        }
136
137        Ok(OptionField(idents))
138    }
139}
140
141fn snake_to_camel_case(s: &str) -> String {
142    s.split('_')
143        .map(|word| {
144            let mut c = word.chars();
145            match c.next() {
146                None => String::new(),
147                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
148            }
149        })
150        .collect()
151}
152
153impl std::fmt::Display for OptionField {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "{}", self.to_camel_case_string())
156    }
157}
158
159// Type Check for OptionExprField
160impl quote::ToTokens for OptionField {
161    fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
162        if self.0.is_empty() {
163            return;
164        }
165
166        let fields = &self.0;
167        let first = &fields[0];
168
169        // Start with the first field, cloned and wrapped
170        // Cloning prevents move errors when multiple fields are accessed in check_fields
171        // Wrapping ensures we always start with a Vec for chaining
172        let mut combined = quote! { #first.clone()._qb_wrap() };
173
174        // Chain subsequent fields using _qb_access helper
175        for i in 1..fields.len() {
176            let item = &fields[i];
177
178            combined = quote! { #combined._qb_access(|v| v.#item.clone()._qb_wrap()) };
179        }
180
181        tokens.extend(combined);
182    }
183}
184
185// Custom keywords
186mod kw {
187    syn::custom_keyword!(select);
188    syn::custom_keyword!(from);
189    syn::custom_keyword!(and);
190    syn::custom_keyword!(order);
191    syn::custom_keyword!(by);
192    syn::custom_keyword!(limit);
193    syn::custom_keyword!(offset);
194    syn::custom_keyword!(asc);
195    syn::custom_keyword!(desc);
196    syn::custom_keyword!(like);
197}