include_sql/lib.rs
1#![cfg_attr(docsrs, doc = include_str!("../README.md"))]
2
3use std::{fs, path::PathBuf};
4
5use proc_macro;
6use proc_macro2::{Delimiter, Group, Ident, Literal, Punct, Spacing, Span, TokenStream};
7use quote::{ToTokens, TokenStreamExt};
8use syn::{
9 self,
10 parse::{Parse, ParseStream},
11 LitStr, Token,
12};
13
14mod conv;
15mod err;
16mod gen;
17mod sql;
18
19/**
20Reads and parses the specified SQL file, and generates `impl_sql` macro call.
21
22For example, if the SQL file "library.sql" has these 2 statements:
23
24```sql
25-- name: get_loaned_books?
26-- Returns the list of books loaned to a patron
27-- # Parameters
28-- param: user_id: &str - user ID
29SELECT book_title FROM library WHERE loaned_to = :user_id ORDER BY 1
30/
31
32-- name: loan_books!
33-- Updates the book records to reflect loan to a patron
34-- # Parameters
35-- param: book_ids: usize - book IDs
36-- param: user_id: &str - user ID
37UPDATE library SET loaned_to = :user_id, loaned_on = current_timestamp WHERE book_id IN ( :book_ids )
38/
39```
40
41This method would generate:
42
43```rust,no_run
44# macro_rules! impl_sql { ($($t:tt)+) => {}; }
45impl_sql!{ LibrarySql =
46 {
47 ? get_loaned_books (: user_id (&str))
48 " Returns the list of books loaned to a patron\n # Parameters\n * `user_id` - user ID"
49 $ "SELECT book_title FROM library WHERE loaned_to = " : user_id "ORDER BY 1"
50 },
51 {
52 ! loan_books (# book_ids (usize) : user_id (&str))
53 " Updates the book records to reflect loan to a patron\n # Parameters\n * `user_id` - user ID\n * `book_ids` - book IDs"
54 $ "UPDATE library SET loaned_to = " : user_id ", loaned_on = current_timestamp WHERE book_id IN ( " # book_ids " )"
55 }
56}
57```
58
59Where:
60* `LibrarySql` is a camel-cased `ident` derived from the SQL file name. It might be used by `impl_sql` to generate a trait (like [include-postgres-sql][1] and [include-sqlite-sql][2] do).
61* `?` or `!` is a statement variant selector
62* `get_loaned_books` and `loan_books` are `ident`s created from the statement names that can be used to name generated methods
63* `user_id` and `book_ids` are `ident`s that represent parameter names.
64* `:` and `#` in front of the parameter names are parameter variant tags:
65 - `:` indicates that the following parameter is a scalar
66 - `#` tags IN-list parameters.
67* The following `(&str)` and `(usize)` are Rust parameter types as declared in the SQL.
68* `$` is a helper token that could be used to generate repetitions if generated artifacts are macros.
69
70> **Note** that `param:` types are passed as parenthesized types. This is done to allow `impl_sql` match them as token trees. If a parameter type is not defined in SQL, `_` will be used in its place (this `_` drives the need to match parameter types as token trees) for which `impl_sql` is expected to generate an appropriate generic type.
71
72> **Note** also that parameter order is defined by the `param` declarations. SQL parameters that are present in the SQL code, but that are not declared as one of the `param`s, will be follow the `param` parameters in the order they are found in the SQL code.
73
74## Async
75
76When include-sql is built with the `async` feature, `impl_sql` macro will be generated with additional lifetimes for reference parameters.
77For example, the above `LibrarySql` example will look like this:
78
79```rust,no_run
80# macro_rules! impl_sql { ($($t:tt)+) => {}; }
81impl_sql!{ LibrarySql =
82 {
83 ? get_loaned_books (: user_id ('user_id &str))
84 " Returns the list of books loaned to a patron\n # Parameters\n * `user_id` - user ID"
85 $ "SELECT book_title FROM library WHERE loaned_to = " : user_id "ORDER BY 1"
86 },
87 {
88 ! loan_books (# book_ids ('book_ids usize) : user_id ('user_id &str))
89 " Updates the book records to reflect loan to a patron\n # Parameters\n * `user_id` - user ID\n * `book_ids` - book IDs"
90 $ "UPDATE library SET loaned_to = " : user_id ", loaned_on = current_timestamp WHERE book_id IN ( " # book_ids " )"
91 }
92}
93```
94
95**Note** that for IN list parameters where the list item is a reference itself additional lifetime that covers list items is also generated.
96For example, for this query:
97
98```sql
99-- name: get_users_who_loaned_books?
100-- Returns names patrons that at one time or another have loaned specified books
101-- # Parameters
102-- param: book_titles: &str - book titles
103SELECT DISTINCT first_name, last_name
104 FROM patrons
105 JOIN library ON library.loaned_to = patrons.user_id
106 WHERE book_title IN (:book_titles);
107```
108
109include-sql will generate:
110
111```rust,no_run
112# macro_rules! impl_sql { ($($t:tt)+) => {}; }
113impl_sql!{ LibrarySql =
114 {
115 ? get_users_who_loaned_books (# book_titles ('book_titles 'book_titles_item &str))
116 " Returns names patrons that at one time or another have loaned specified books\n # Parameters\n * `book_titles` - book titles"
117 $ "SELECT DISTINCT first_name, last_name FROM patrons JOIN library ON library.loaned_to = patrons.user_id WHERE book_title IN (" # book_titles ")"
118 }
119}
120```
121
122[1]: https://crates.io/crates/include-postgres-sql
123[2]: https://crates.io/crates/include-sqlite-sql
124*/
125#[proc_macro]
126pub fn include_sql(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
127 let inc_file_lit = syn::parse_macro_input!(input as syn::LitStr);
128 let inc_file_path = local_file(&inc_file_lit);
129
130 match read_and_parse_sql_file(&inc_file_path) {
131 Ok(included_sql) => {
132 let mut tokens = TokenStream::new();
133 output_include_bytes(&inc_file_path, &mut tokens);
134 if !included_sql.stmt_list.is_empty() {
135 included_sql.to_tokens(&mut tokens);
136 }
137 tokens.into()
138 }
139 Err(err) => syn::Error::new(inc_file_lit.span(), err.to_string())
140 .to_compile_error()
141 .into(),
142 }
143}
144
145/// Returns path to the included SQL file on the local file system.
146///
147/// If the path given to the `include_sql` macro is relative, i.e. it does not start with `/`,
148/// then `include_sql` considers the provided path to be relative to the file that included it.
149///
150/// If the path given to the `include_sql` macro starts with '/', then `include_sql`
151/// considers it to be relative to the "root" of the project, i.e. relative to the
152/// `CARGO_MANIFEST_DIR` directory.
153fn local_file(inc_file_lit_path: &LitStr) -> PathBuf {
154 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
155 let mut path = PathBuf::from(&manifest_dir);
156
157 let inc_file_path = inc_file_lit_path.value();
158 if inc_file_path.starts_with('/') {
159 // Explicit absolute path
160 path.push(&inc_file_path[1..]);
161 } else if inc_file_path.starts_with("./") {
162 // Explicit relative path
163 let inc_mod = inc_file_lit_path.span().unwrap().file();
164 if !inc_mod.is_empty() {
165 path.push(inc_mod);
166 path.pop();
167 }
168 path.push(&inc_file_path[2..]);
169 } else {
170 // Implicit absolute path
171 path.push(inc_file_path);
172 }
173 path
174}
175
176/// Reads the content of the file at the `path` and parses its content.
177fn read_and_parse_sql_file(file_path: &PathBuf) -> err::Result<sql::IncludedSql> {
178 // let text = fs::read_to_string(&file_path)?;
179 let text = match fs::read_to_string(&file_path) {
180 Ok(text) => text,
181 Err(_) => return Err(err::Error::IO(std::io::Error::other(file_path.to_str().unwrap())))
182 };
183 let file_name = file_path
184 .file_stem()
185 .unwrap_or_default()
186 .to_str()
187 .unwrap_or_default()
188 .replace('-', "_");
189 sql::parse(&text, &file_name)
190}
191
192/// Writes a phantom call to `include_bytes` to make compiler aware of the external dependency.
193fn output_include_bytes(file_path: &PathBuf, tokens: &mut TokenStream) {
194 tokens.append(Ident::new("const", Span::call_site()));
195 tokens.append(Ident::new("_", Span::call_site()));
196 tokens.append(Punct::new(':', Spacing::Alone));
197 tokens.append(Punct::new('&', Spacing::Alone));
198
199 let mut type_tokens = TokenStream::new();
200 type_tokens.append(Ident::new("u8", Span::call_site()));
201 tokens.append(Group::new(Delimiter::Bracket, type_tokens));
202
203 tokens.append(Punct::new('=', Spacing::Alone));
204 tokens.append(Ident::new("include_bytes", Span::call_site()));
205 tokens.append(Punct::new('!', Spacing::Alone));
206
207 let mut macro_tokens = TokenStream::new();
208 macro_tokens.append(Literal::string(file_path.to_str().unwrap()));
209 tokens.append(Group::new(Delimiter::Parenthesis, macro_tokens));
210
211 tokens.append(Punct::new(';', Spacing::Alone));
212}
213
214/**
215Finds the specified item (`ident`) in a list (of `idents`).
216
217Returns the item's index offset by the number after `+`.
218
219```
220let idx = include_sql::index_of!(id in [name, flag, id] + 1);
221assert_eq!(idx, 3);
222```
223*/
224#[proc_macro]
225pub fn index_of(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
226 let IndexOfArgs {
227 param_name,
228 start_index,
229 stmt_params,
230 } = syn::parse_macro_input!(input as IndexOfArgs);
231
232 let param_lookup = stmt_params.iter().position(|param| param == ¶m_name);
233 if let Some(pos) = param_lookup {
234 let mut tokens = TokenStream::new();
235 tokens.append(Literal::usize_unsuffixed(start_index + pos));
236 tokens.into()
237 } else {
238 syn::Error::new(param_name.span(), "no such parameter")
239 .to_compile_error()
240 .into()
241 }
242}
243
244struct IndexOfArgs {
245 param_name: syn::Ident,
246 stmt_params: syn::punctuated::Punctuated<syn::Ident, Token![,]>,
247 start_index: usize,
248}
249
250impl Parse for IndexOfArgs {
251 fn parse(input: ParseStream) -> syn::Result<Self> {
252 let param_name = input.parse()?;
253 input.parse::<Token![in]>()?;
254 let param_list;
255 syn::bracketed!(param_list in input);
256 input.parse::<Token![+]>()?;
257 let start_index: syn::LitInt = input.parse()?;
258 let start_index = start_index.base10_parse()?;
259 let stmt_params = param_list.parse_terminated(syn::Ident::parse, Token![,])?;
260 Ok(Self {
261 param_name,
262 stmt_params,
263 start_index,
264 })
265 }
266}