include_sql/
lib.rs

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