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 mut iter = self.0.iter();
167 let first = iter.next().unwrap();
168
169 let combined = iter.fold(quote! { #first }, |acc, ident| {
170 quote! { #acc .unwrap(). #ident }
171 });
172
173 tokens.extend(combined);
174 }
175}
176
177// Custom keywords
178mod kw {
179 syn::custom_keyword!(select);
180 syn::custom_keyword!(from);
181 syn::custom_keyword!(and);
182 syn::custom_keyword!(order);
183 syn::custom_keyword!(by);
184 syn::custom_keyword!(limit);
185 syn::custom_keyword!(offset);
186 syn::custom_keyword!(asc);
187 syn::custom_keyword!(desc);
188 syn::custom_keyword!(like);
189}