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 Parse for OptionField {
114    fn parse(input: ParseStream) -> syn::Result<Self> {
115        let mut idents = Vec::new();
116
117        // Parse the first identifier
118        idents.push(input.parse::<Ident>()?);
119
120        // Parse any additional identifiers separated by dots
121        while input.peek(Token![.]) {
122            input.parse::<Token![.]>()?;
123            idents.push(input.parse::<Ident>()?);
124        }
125
126        Ok(OptionField(idents))
127    }
128}
129
130fn snake_to_camel_case(s: &str) -> String {
131    s.split('_')
132        .map(|word| {
133            let mut c = word.chars();
134            match c.next() {
135                None => String::new(),
136                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
137            }
138        })
139        .collect()
140}
141
142impl ToString for OptionField {
143    fn to_string(&self) -> String {
144        self.0
145            .iter()
146            .map(|ident| snake_to_camel_case(&ident.to_string()))
147            .collect::<Vec<String>>()
148            .join(".")
149    }
150}
151
152// Type Check for OptionExprField
153impl quote::ToTokens for OptionField {
154    fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
155        if self.0.is_empty() {
156            return;
157        }
158
159        let mut iter = self.0.iter();
160        let first = iter.next().unwrap();
161
162        let combined = iter.fold(quote! { #first }, |acc, ident| {
163            quote! { #acc .unwrap(). #ident }
164        });
165
166        tokens.extend(combined);
167    }
168}
169
170// Custom keywords
171mod kw {
172    syn::custom_keyword!(select);
173    syn::custom_keyword!(from);
174    syn::custom_keyword!(and);
175    syn::custom_keyword!(order);
176    syn::custom_keyword!(by);
177    syn::custom_keyword!(limit);
178    syn::custom_keyword!(offset);
179    syn::custom_keyword!(asc);
180    syn::custom_keyword!(desc);
181    syn::custom_keyword!(like);
182}