diff --git a/Cargo.toml b/Cargo.toml index 241c9b3b..a8ad98b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Mubelotix "] edition = "2018" description = "Rust wrapper for the Meilisearch API. Meilisearch is a powerful, fast, open-source, easy to use and deploy search engine." license = "MIT" -readme = "README.md" +readme = "README.md" repository = "https://github.com/meilisearch/meilisearch-sdk" [workspace] @@ -20,13 +20,15 @@ serde_json = "1.0" time = { version = "0.3.7", features = ["serde-well-known", "formatting", "parsing"] } jsonwebtoken = { version = "8", default-features = false } yaup = "0.2.0" -either = { version = "1.8.0" , features = ["serde"] } +either = { version = "1.8.0", features = ["serde"] } thiserror = "1.0.37" +meilisearch-index-setting-macro = { path = "meilisearch-index-setting-macro" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] futures = "0.3" isahc = { version = "1.0", features = ["http2", "text-decoding"], default_features = false } -uuid = { version = "1.1.2", features = ["v4"] } +uuid = { version = "1.1.2", features = ["v4"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.47" diff --git a/meilisearch-index-setting-macro/Cargo.toml b/meilisearch-index-setting-macro/Cargo.toml new file mode 100644 index 00000000..271bfce2 --- /dev/null +++ b/meilisearch-index-setting-macro/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "meilisearch-index-setting-macro" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "1.0.102", features = ["extra-traits"] } +quote = "1.0.21" +proc-macro2 = "1.0.46" +convert_case = "0.6.0" + +[dev-dependencies] +meilisearch-sdk = "0.20.1" + diff --git a/meilisearch-index-setting-macro/src/lib.rs b/meilisearch-index-setting-macro/src/lib.rs new file mode 100644 index 00000000..819b5e33 --- /dev/null +++ b/meilisearch-index-setting-macro/src/lib.rs @@ -0,0 +1,280 @@ +use std::collections::HashSet; + +use convert_case::{Case, Casing}; +use proc_macro2::TokenTree; +use quote::quote; +use syn::parse_macro_input; +use syn::spanned::Spanned; + +#[proc_macro_derive(Document, attributes(document))] +pub fn generate_index_settings(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let ast = parse_macro_input!(input as syn::DeriveInput); + + let fields: &syn::Fields = match ast.data { + syn::Data::Struct(ref data) => &data.fields, + _ => { + return proc_macro::TokenStream::from( + syn::Error::new(ast.ident.span(), "Applicable only to struct").to_compile_error(), + ); + } + }; + + let struct_ident = &ast.ident; + + let document_implementation = get_document_implementation(struct_ident, fields); + proc_macro::TokenStream::from(quote! { + #document_implementation + }) +} + +fn get_document_implementation( + struct_ident: &syn::Ident, + fields: &syn::Fields, +) -> proc_macro2::TokenStream { + let mut attribute_set: std::collections::HashSet = std::collections::HashSet::new(); + let mut primary_key_attribute: String = "".to_string(); + let mut distinct_key_attribute: String = "".to_string(); + let mut displayed_attributes: Vec = vec![]; + let mut searchable_attributes: Vec = vec![]; + let mut filterable_attributes: Vec = vec![]; + let mut sortable_attributes: Vec = vec![]; + let valid_attribute_names = std::collections::HashSet::from([ + "displayed", + "searchable", + "filterable", + "sortable", + "primary_key", + "distinct", + ]); + + let index_name = struct_ident + .to_string() + .from_case(Case::UpperCamel) + .to_case(Case::Snake); + + for field in fields { + let attribute_list_result = + extract_all_attr_values(&field.attrs, &mut attribute_set, &valid_attribute_names); + + match attribute_list_result { + Ok(attribute_list) => { + for attribute in attribute_list { + match attribute.as_str() { + "displayed" => { + displayed_attributes.push(field.ident.clone().unwrap().to_string()) + } + "searchable" => { + searchable_attributes.push(field.ident.clone().unwrap().to_string()) + } + "filterable" => { + filterable_attributes.push(field.ident.clone().unwrap().to_string()) + } + "sortable" => { + sortable_attributes.push(field.ident.clone().unwrap().to_string()) + } + "primary_key" => { + primary_key_attribute = field.ident.clone().unwrap().to_string() + } + "distinct" => { + distinct_key_attribute = field.ident.clone().unwrap().to_string() + } + _ => {} + } + } + } + Err(e) => { + return e; + } + } + } + + let primary_key_token: proc_macro2::TokenStream = if primary_key_attribute.is_empty() { + quote! { + ::std::option::Option::None + } + } else { + quote! { + ::std::option::Option::Some(#primary_key_attribute) + } + }; + + let display_attr_tokens = + get_settings_token_for_list(&displayed_attributes, "with_displayed_attributes"); + let sortable_attr_tokens = + get_settings_token_for_list(&sortable_attributes, "with_sortable_attributes"); + let filterable_attr_tokens = + get_settings_token_for_list(&filterable_attributes, "with_filterable_attributes"); + let searchable_attr_tokens = + get_settings_token_for_list(&searchable_attributes, "with_searchable_attributes"); + let distinct_attr_token = + get_settings_token_for_string(&distinct_key_attribute, "with_distinct_attribute"); + + quote! { + #[::meilisearch_sdk::macro_helper::async_trait] + impl ::meilisearch_sdk::documents::Document for #struct_ident { + fn generate_settings() -> ::meilisearch_sdk::settings::Settings { + ::meilisearch_sdk::settings::Settings::new() + #display_attr_tokens + #sortable_attr_tokens + #filterable_attr_tokens + #searchable_attr_tokens + #distinct_attr_token + } + + async fn generate_index(client: &::meilisearch_sdk::client::Client) -> std::result::Result<::meilisearch_sdk::indexes::Index, ::meilisearch_sdk::tasks::Task> { + return client.create_index(#index_name, #primary_key_token) + .await.unwrap() + .wait_for_completion(&client, ::std::option::Option::None, ::std::option::Option::None) + .await.unwrap() + .try_make_index(&client); + } + } + } +} + +fn extract_all_attr_values( + attrs: &[syn::Attribute], + attribute_set: &mut std::collections::HashSet, + valid_attribute_names: &std::collections::HashSet<&str>, +) -> std::result::Result, proc_macro2::TokenStream> { + let mut attribute_names: Vec = vec![]; + let mut local_attribute_set: std::collections::HashSet = HashSet::new(); + for attr in attrs { + match attr.parse_meta() { + std::result::Result::Ok(syn::Meta::List(list)) => { + if !list.path.is_ident("document") { + continue; + } + for token_stream in attr.tokens.clone().into_iter() { + if let TokenTree::Group(group) = token_stream { + for token in group.stream() { + match token { + TokenTree::Punct(punct) => validate_punct(&punct)?, + TokenTree::Ident(ident) => { + if ident == "primary_key" + && attribute_set.contains("primary_key") + { + return std::result::Result::Err( + syn::Error::new( + ident.span(), + "`primary_key` already exists", + ) + .to_compile_error(), + ); + } + if ident == "distinct" && attribute_set.contains("distinct") { + return std::result::Result::Err( + syn::Error::new( + ident.span(), + "`distinct` already exists", + ) + .to_compile_error(), + ); + } + + if local_attribute_set.contains(ident.to_string().as_str()) { + return std::result::Result::Err( + syn::Error::new( + ident.span(), + format!( + "`{}` already exists for this field", + ident + ), + ) + .to_compile_error(), + ); + } + + if !valid_attribute_names.contains(ident.to_string().as_str()) { + return std::result::Result::Err( + syn::Error::new( + ident.span(), + format!( + "Property `{}` does not exist for type `document`", + ident + ), + ) + .to_compile_error(), + ); + } + attribute_names.push(ident.to_string()); + attribute_set.insert(ident.to_string()); + local_attribute_set.insert(ident.to_string()); + } + _ => { + return std::result::Result::Err( + syn::Error::new(attr.span(), "Invalid parsing".to_string()) + .to_compile_error(), + ); + } + } + } + } + } + } + std::result::Result::Err(e) => { + println!("{:#?}", attr); + for token_stream in attr.tokens.clone().into_iter() { + if let TokenTree::Group(group) = token_stream { + for token in group.stream() { + if let TokenTree::Punct(punct) = token { + validate_punct(&punct)? + } + } + } + } + return std::result::Result::Err( + syn::Error::new(attr.span(), e.to_string()).to_compile_error(), + ); + } + _ => {} + } + } + std::result::Result::Ok(attribute_names) +} + +fn validate_punct(punct: &proc_macro2::Punct) -> std::result::Result<(), proc_macro2::TokenStream> { + if punct.as_char() == ',' && punct.spacing() == proc_macro2::Spacing::Alone { + return std::result::Result::Ok(()); + } + std::result::Result::Err( + syn::Error::new(punct.span(), "`,` expected".to_string()).to_compile_error(), + ) +} + +fn get_settings_token_for_list( + field_name_list: &Vec, + method_name: &str, +) -> proc_macro2::TokenStream { + let string_attributes = field_name_list.iter().map(|attr| { + quote! { + #attr + } + }); + let method_ident = syn::Ident::new(method_name, proc_macro2::Span::call_site()); + + if !field_name_list.is_empty() { + quote! { + .#method_ident([#(#string_attributes),*]) + } + } else { + quote! { + .#method_ident(::std::iter::empty::<&str>()) + } + } +} + +fn get_settings_token_for_string( + field_name: &String, + method_name: &str, +) -> proc_macro2::TokenStream { + let method_ident = syn::Ident::new(method_name, proc_macro2::Span::call_site()); + + if !field_name.is_empty() { + quote! { + .#method_ident(#field_name) + } + } else { + proc_macro2::TokenStream::new() + } +} diff --git a/src/documents.rs b/src/documents.rs index b2fe1028..0c695939 100644 --- a/src/documents.rs +++ b/src/documents.rs @@ -1,6 +1,62 @@ -use crate::{errors::Error, indexes::Index}; +use async_trait::async_trait; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +/// Derive the [`Document`](crate::documents::Document) trait. +/// +/// ## Field attribute +/// Use the `#[document(..)]` field attribute to generate the correct settings +/// for each field. The available parameters are: +/// - `primary_key` (can only be used once) +/// - `distinct` (can only be used once) +/// - `searchable` +/// - `displayed` +/// - `filterable` +/// - `sortable` +/// +/// ## Index name +/// The name of the index will be the name of the struct converted to snake case. +/// +/// ## Sample usage: +/// ``` +/// use serde::{Serialize, Deserialize}; +/// use meilisearch_sdk::documents::Document; +/// use meilisearch_sdk::settings::Settings; +/// use meilisearch_sdk::indexes::Index; +/// use meilisearch_sdk::client::Client; +/// +/// #[derive(Serialize, Deserialize, Document)] +/// struct Movie { +/// #[document(primary_key)] +/// movie_id: u64, +/// #[document(displayed, searchable)] +/// title: String, +/// #[document(displayed)] +/// description: String, +/// #[document(filterable, sortable, displayed)] +/// release_date: String, +/// #[document(filterable, displayed)] +/// genres: Vec, +/// } +/// +/// async fn usage(client: Client) { +/// // Default settings with the distinct, searchable, displayed, filterable, and sortable fields set correctly. +/// let settings: Settings = Movie::generate_settings(); +/// // Index created with the name `movie` and the primary key set to `movie_id` +/// let index: Index = Movie::generate_index(&client).await.unwrap(); +/// } +/// ``` +pub use meilisearch_index_setting_macro::Document; + +use crate::settings::Settings; +use crate::tasks::Task; +use crate::{errors::Error, indexes::Index}; + +#[async_trait] +pub trait Document { + fn generate_settings() -> Settings; + async fn generate_index(client: &crate::client::Client) -> Result; +} + #[derive(Debug, Clone, Deserialize)] pub struct DocumentsResults { pub results: Vec, @@ -240,6 +296,7 @@ impl<'a> DocumentsQuery<'a> { mod tests { use super::*; use crate::{client::*, indexes::*}; + use ::meilisearch_sdk::documents::Document; use meilisearch_test_macro::meilisearch_test; use serde::{Deserialize, Serialize}; @@ -249,6 +306,29 @@ mod tests { kind: String, } + #[allow(unused)] + #[derive(Document)] + struct MovieClips { + #[document(primary_key)] + movie_id: u64, + #[document(distinct)] + owner: String, + #[document(displayed, searchable)] + title: String, + #[document(displayed)] + description: String, + #[document(filterable, sortable, displayed)] + release_date: String, + #[document(filterable, displayed)] + genres: Vec, + } + + #[allow(unused)] + #[derive(Document)] + struct VideoClips { + video_id: u64, + } + async fn setup_test_index(client: &Client, index: &Index) -> Result<(), Error> { let t0 = index .add_documents( @@ -297,6 +377,7 @@ mod tests { Ok(()) } + #[meilisearch_test] async fn test_get_documents_with_only_one_param( client: Client, @@ -316,4 +397,63 @@ mod tests { Ok(()) } + + #[meilisearch_test] + async fn test_settings_generated_by_macro(client: Client, index: Index) -> Result<(), Error> { + setup_test_index(&client, &index).await?; + + let movie_settings: Settings = MovieClips::generate_settings(); + let video_settings: Settings = VideoClips::generate_settings(); + + assert_eq!(movie_settings.searchable_attributes.unwrap(), ["title"]); + assert!(video_settings.searchable_attributes.unwrap().is_empty()); + + assert_eq!( + movie_settings.displayed_attributes.unwrap(), + ["title", "description", "release_date", "genres"] + ); + assert!(video_settings.displayed_attributes.unwrap().is_empty()); + + assert_eq!( + movie_settings.filterable_attributes.unwrap(), + ["release_date", "genres"] + ); + assert!(video_settings.filterable_attributes.unwrap().is_empty()); + + assert_eq!( + movie_settings.sortable_attributes.unwrap(), + ["release_date"] + ); + assert!(video_settings.sortable_attributes.unwrap().is_empty()); + + Ok(()) + } + + #[meilisearch_test] + async fn test_generate_index(client: Client) -> Result<(), Error> { + let index: Index = MovieClips::generate_index(&client).await.unwrap(); + + assert_eq!(index.uid.to_string(), "movie_clips"); + + index + .delete() + .await? + .wait_for_completion(&client, None, None) + .await?; + + Ok(()) + } + #[derive(Serialize, Deserialize, Document)] + struct Movie { + #[document(primary_key)] + movie_id: u64, + #[document(displayed, searchable)] + title: String, + #[document(displayed)] + description: String, + #[document(filterable, sortable, displayed)] + release_date: String, + #[document(filterable, displayed)] + genres: Vec, + } } diff --git a/src/lib.rs b/src/lib.rs index 9606fc4f..1f1bfd01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -250,3 +250,12 @@ mod tenant_tokens; mod utils; pub use client::*; + +#[cfg(test)] +/// Support for the `Document` derive proc macro in the crate's tests +extern crate self as meilisearch_sdk; +/// Can't assume that the user of proc_macro will have access to `async_trait` crate. So exporting the `async-trait` crate from `meilisearch_sdk` in a hidden module. +#[doc(hidden)] +pub mod macro_helper { + pub use async_trait::async_trait; +}