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 == &param_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}