From da8d4416c5364f68f0ec959b14eefd341f7127b5 Mon Sep 17 00:00:00 2001 From: Callum Cunha Date: Sun, 10 Mar 2024 15:50:17 +0000 Subject: [PATCH] majority rewrite with new arguments --- Cargo.toml | 22 +- readme.md | 180 +++++++++------- src/clone.rs | 105 ---------- src/lib.rs | 268 +----------------------- src/logic/args.rs | 283 +++++++++++++++++++++++++ src/logic/mod.rs | 268 ++++++++++++++++++++++++ src/logic/tests.rs | 48 +++++ src/patch.rs | 344 +++++++++++-------------------- src/view.rs | 278 ++++++++++++------------- tests/clone.rs | 22 +- tests/feature_testing/builder.rs | 38 ---- tests/feature_testing/openapi.rs | 30 ++- tests/feature_testing/welds.rs | 48 ----- tests/mod.rs | 2 +- tests/patch.rs | 45 +++- tests/view.rs | 39 ++++ 16 files changed, 1081 insertions(+), 939 deletions(-) delete mode 100644 src/clone.rs create mode 100644 src/logic/args.rs create mode 100644 src/logic/mod.rs create mode 100644 src/logic/tests.rs delete mode 100644 tests/feature_testing/builder.rs delete mode 100644 tests/feature_testing/welds.rs diff --git a/Cargo.toml b/Cargo.toml index b2d4184..15f19d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,26 +14,26 @@ license-file = "license.txt" proc-macro = true [features] -default = ["builder", "openapi"] -openapi = ["dep:poem-openapi"] -builder = ["dep:typed-builder"] -welds = ["dep:welds"] +default = ["openapi"] +openapi = [] +# builder = ["dep:typed-builder"] +# welds = ["dep:welds"] [dependencies] proc-macro2 = "1.0" quote = "1.0" syn = { version = "2.0", features = ["parsing"] } +proc-macro-error = "1.0.4" -# Supported Crates -poem-openapi = { version = "3.0", features = [ +[dev-dependencies] +# # Supported Crates +poem-openapi = { version = "4.0", features = [ "uuid", "chrono", -], optional = true } - -typed-builder = { version = "0.18.0", optional = true } -welds = { version = "0.2.1", default-features = false, optional = true} -proc-macro-error = "1.0.4" +] } +# typed-builder = { version = "0.18.0" } +# welds = { version = "0.3", default-features = false} [package.metadata.docs.rs] all-features = true diff --git a/readme.md b/readme.md index baa869d..610bd28 100644 --- a/readme.md +++ b/readme.md @@ -34,8 +34,6 @@ New features planned are available [here](https://github.com/NexRX/restructed/is # Usage -This library requires the `nightly` channel. - Add `restructed` to your projects `Cargo.toml`: ```toml @@ -51,8 +49,6 @@ cargo add restructed Add the import and derive it on the target struct ```rust -use restructed::Models; - #[derive(restructed::Models)] struct User { id: i32, @@ -65,112 +61,142 @@ And then add attributes for each model you want to create. ```rust #[derive(restructed::Models)] -#[patch(UserUpdatables, omit(id))] // <-- Wraps all fields in a new struct with Option -#[view(UserId, fields(id))] // <-- Selectively includes fields in a new struct +#[view(UserId, fields(id))] // <-- Simple subset of the deriving structs field +#[patch(UserUpdatables, omit(id))] // <-- Wraps all fields with a Option type inside a new struct struct User { id: i32, username: String, } ``` -Continue reading for the available models and their breakdown. +Continue reading for the available models and their breakdown and arguments in general. + -Now anywhere that requires a `T: OrderStoreFilter` will also accept an `&T` or `Arc` where `T` implements `OrderStoreFilter`. +## Common Arguments +These are arguments that can be applied to all models. -## Models +- `name` - The name of the struct the generate [**Required**, **Must be first** e.g. `MyStruct`] +- `fields` [**Only One**] + - `fields` - A _list_ of field names in the original structure to carry over [e.g. `fields(field1, field2, ...)`] + - `omit` - A _list_ of field names in the original structure to omit [e.g. `omit(field1, field2, ...)`] +- `derive` - A _list_ of derivables to derive on the generated struct just like you would normally [e.g. `derive(Clone, Debug, thiserror::Error)`] +- `preset` - A _string literal_ of the preset to use, presets are a set of defaults to apply to a model. *Below is a list of what arguments are composed in a preset.* [e.g. `preset = "none"`] + - **none** - **Default**, does nothing and is the default behaviour + - **write** *['openapi' Feature Flag]* - Designed to only show properties that can be written to. + - `omit` - Applied as a base, any fields with `#[oai(read_only)]` attribute are removed, your fields/omit is applied after + - `option` **patch only** - Arg defaults to `MaybeUndefined` -Each model is defined using an attribute after deriving `Models` and multiple models (of the same kind) can be had with multiple attributes. + - **read** *['openapi' Feature Flag]* - Designed to only show properties that can always be read. + - `omit` - Applied as a base, any fields with `#[oai(write_only)]` attribute are removed, your fields/omit is applied after + - `option` **patch only** - arg defaults to `MaybeUndefined` -### view +- `attributes_with` - A _string literal_ of the attributes to inherit at both struct & field level. *Below is a list of values.* [e.g. `attributes_with = "none"`] + - **none** - Does not consider any attributes [**Default**] + - **oai** *['openapi' Feature Flag]* - Composes all Poem's OpenAPI attributes + - **deriveless** - Composes all attributes but omits the derive attributes + - **all** - Composes all attributes -A selective subset of fields from the original model of the same types. Useful for generating views of a database table, etc. Supports both struct and enums +
-**Arguements:** +# Models +Derivable models via the struct attributes. -- `name` - The name of the struct the generate (**Required**, **Must be first** e.g. `MyStruct`) -- `fields` - A _list_ of field names in the original structure to carry over (**Required**, e.g. `fields(field1, field2, ...)`) -- `derive` - A _list_ of derivables (in scope) to derive on the generated struct (e.g. `derive(Clone, Debug, thiserror::Error)`) -- `default_derives` - A _bool_, if `true` _(default)_ then the a list of derives will be additionally derived. Otherwise, `false` to avoid this (e.g. `default_derives = false`) +## **Patch** +A subset model where every field's type is an option in some way. It's called patch because it reflect a REST / DB patch of the original struct. -**Example:** +#### **Unique Args** +- `option` - A _Identifer_ that allows a different option implementation from supported crates [e.g. `option = MaybeUndefined` (from poem-openapi)] + - **Option** - The standard implementation of Option [**Default**] + - **MaybeUndefined** *['openapi' Feature Flag]* - Use Poem's OpenAPI crate and it's Option implmentation +#### **Example** ```rust - // Original - #[derive(restructed::Models)] - #[view(UserProfile, fields(display_name, bio), derive(Clone), default_derives = false)] - struct User { - id: i32, - display_name: String, - bio: String, - password: String, - } +#[derive(restructed::Models)] +#[patch(UserUpdate, omit(id), option = Option)] +struct User { + id: i32, + display_name: String, + bio: String, + extra: Option, + password: String, +} ``` -Generates: - +will expand into something like this: ```rust - #[derive(Clone)] - struct UserProfile { - display_name: String, - bio: String, - } +struct UserUpdate { + display_name: Option, + bio: Option, + extra: Option>, + password: Option, +} ``` -### patch - -A complete subset of fields of the original model wrapped in `Option` with the ability to omit instead select fields.
-I want to note that patch currently doesn't support enums as I don't see the use-case for it. If someone can, feel free to submit a feature request +## **View** +A simple subset of the deriving model/struct. -**Arguements:** - -- `name` - The name of the struct the generate (**Required**, **Must be first** e.g. `MyStruct`) -- `omit` - A _list_ of field names in the original structure to omit (e.g. `fields(field1, field2, ...)`) -- `derive` - A _list_ of derivables (in scope) to derive on the generated struct (e.g. `derive(Clone, Debug, thiserror::Error)`) -- `default_derives` - A _bool_, if `true` _(default)_ then the a list of derives will be additionally derived. Otherwise, `false` to avoid this (e.g. `default_derives = false`) - -**Example:** +#### **Unique Args** +- N/A +#### **Example** ```rust - // Original - #[derive(restructed::Models)] - #[patch(UserUpdate, omit(id))] - struct User { - id: i32, - display_name: String, - bio: String, - password: String, - } +#[derive(restructed::Models)] +#[view(UserPublic, fields(display_name, bio))] +struct User { + id: i32, + display_name: String, + bio: String, + extra: Option, + password: String, +} ``` -Generates: - +will expand into something like this: ```rust - #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)] // <-- Default derives (when *not* disabled) - struct UserUpdate { - display_name: Option, - bio: Option, // MaybeUndefined with feature 'openapi' - password: Option, - } +struct UserPublic { + display_name: String, + bio: String, +} ``` -## Crate Features - -Links are to other crates GitHub page that are related to the features.
-Only `builder` is enabled by default. - -### `openapi` +
-Wraps `Option` from the source struct with `MaybeUndefined` from the [poem-openapi](https://github.com/poem-web/poem/tree/master/poem-openapi) crate in `patch` models. All `oai(...)` attributes are also copied over to the generated struct meaning you keep all validators, etc.. +# Complex Example +Just to demonstrate the versitility of this crate, here is an example using all the possible arguments at once using all features. -### `builder` +## Poem OpenAPI +Each attribute is copied over so all your validations are kept -Uses the [typed-builder](https://github.com/idanarye/rust-typed-builder) crate to derive a builder for add a type safe builder for all generated models. +```rust +use restructed::Models; -### `welds` +#[cfg(test)] // For rust_doc test purposes +#[derive(poem::Object, Models)] +#[oai(skip_serializing_if_is_none, rename_all = "camelCase")] +#[patch(UserUpdate, preset = "write", derive(Object))] +#[view(UserProfile, preset = "view", derive(Object))] +#[view(UserNames, preset = "view", derive(Object))] +pub struct User { + #[oai(read_only)] + pub id: u32, + // profile + #[oai(validator(min_length = 3, max_length = 16, pattern = r"^[a-zA-Z0-9_]*$"))] + pub username: String, + #[oai(validator(min_length = 5, max_length = 1024), write_only)] + pub password: String, + #[oai(validator(min_length = 2, max_length = 16, pattern = r"^[a-zA-Z\s]*$"))] + pub name: Option, + #[oai(validator(min_length = 2, max_length = 16, pattern = r"^[a-zA-Z\s]*$"))] + pub surname: Option, + #[oai(read_only)] + pub joined: u64, +} +``` -Generates a function to merge changes for returning a `DbState` from the [welds](https://github.com/weldsorm/welds) crate. +
-## Contributions & Bugs +# Crate Features +Links are to other crates GitHub page that are related to the features.
-This is my first self publish proc macro so any feedback and feature request, changes, pull requests are all welcome!
-If you find any bugs do submit a github issue with any relavent information and I'll try to fix it. +## Poem OpenAPI +Enables wrapping `Option` from the source struct with `MaybeUndefined` from the [poem-openapi](https://github.com/poem-web/poem/tree/master/poem-openapi) crate in `patch` models. All `oai(...)` attributes can also be explictly copied over to the generated struct meaning you keep all validators, etc.. \ No newline at end of file diff --git a/src/clone.rs b/src/clone.rs deleted file mode 100644 index 8564fef..0000000 --- a/src/clone.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::*; -use proc_macro2::{Ident, TokenStream, TokenTree}; -use quote::quote; -use syn::{self, Attribute, DeriveInput}; - -struct CloneModelArgs { - name: Ident, - derives: Vec, -} - -pub fn impl_clone_model(ast: &DeriveInput, attr: &Attribute) -> TokenStream { - let CloneModelArgs { name, derives } = parse_clone_attributes(attr); - - let attrs: Vec<_> = ast - .attrs - .iter() - .filter(|v| is_attribute(v, "view")) - .filter(|v| is_attribute(v, "patch")) - .filter(|v| is_attribute(v, "clone")) - .collect(); - let vis = &ast.vis; - #[allow(unused_assignments)] - let mut data_type = TokenStream::default(); - let data = match &ast.data { - syn::Data::Struct(v) => { - data_type = quote!(struct); - let fields = &v.fields; - quote!(#fields) - } - syn::Data::Enum(v) => { - data_type = quote!(enum); - let fields = &v.variants; - quote!(#fields) - } - syn::Data::Union(v) => { - data_type = quote!(v.union_token.clone()); - let fields = &v.fields; - quote!(#fields) - } - }; - - quote! { - #[derive(#(#derives),*)] - #( #attrs )* - #vis #data_type #name - #data - } -} - -// TODO: Impl this for struct, union, and enum -fn impl_from_trait( - original_name: &Ident, - name: &Ident, - field_from_mapping: Vec, - is_struct: bool, -) -> proc_macro2::TokenStream { - if is_struct { - quote! { - impl ::core::convert::From<#original_name> for #name { - fn from(value: #original_name) -> Self { - Self { - #(#field_from_mapping),* - } - } - } - } - } else { - quote! { - impl ::core::convert::From<#name> for #original_name { - fn from(value: #name) -> Self { - match value { - #(#field_from_mapping),* - } - } - } - } - } -} - -fn parse_clone_attributes(attr: &Attribute) -> CloneModelArgs { - let tks: Vec = attr - .meta - .require_list() - .unwrap() - .to_owned() - .tokens - .into_iter() - .collect::>(); - - let name = match &tks[0] { - TokenTree::Ident(v) => v.clone(), - x => { - abort!( - x, - "First argument must be an identifier (name) of the struct for the clone" - ) - } - }; - - let mut args_slice = tks[2..].to_vec(); - let derives = parse_derives(&mut args_slice); - abort_unexpected_args(vec!["derive"], &args_slice); - - CloneModelArgs { name, derives } -} diff --git a/src/lib.rs b/src/lib.rs index 28e8d08..48990d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,293 +1,37 @@ #![doc = include_str!("../readme.md")] +mod logic; + mod patch; mod view; -mod clone; +use crate::logic::is_attribute; use proc_macro::TokenStream; -use proc_macro2::{Group, Ident, TokenTree}; -use proc_macro_error::{abort, proc_macro_error}; -use quote::quote; -use syn::Attribute; +use proc_macro_error::proc_macro_error; -/// Derives any number of models that are a subset of the struct deriving this macro. There are two types of models possible.
-/// # view -///A selective subset of fields from the original model of the same types. -/// -///**Arguements:** -///- `name` - The name of the struct the generate (**Required**, **Must be first** e.g. `MyStruct`) -///- `fields` - A *list* of field names in the original structure to carry over (**Required**, e.g. `fields(field1, field2, ...)`) -///- `derive` - A *list* of derivables (in scope) to derive on the generated struct (e.g. `derive(Clone, Debug, thiserror::Error)`) -///- `default_derives` - A *bool*, if `true` *(default)* then the a list of derives will be additionally derived. Otherwise, `false` to avoid this (e.g. `default_derives = false`) -/// -///**Example:** -///```rust -/// // Original -/// #[derive(restructed::Models)] -/// #[view(UserProfile, fields(display_name, bio), derive(Clone), default_derives = false)] -/// struct User { -/// id: i32, -/// display_name: String, -/// bio: String, -/// password: String, -/// } -///``` -///Generates: -///```rust -/// #[derive(Clone)] -/// struct UserProfile { -/// display_name: String, -/// bio: String, -/// } -///``` -/// -///# patch -///A complete subset of fields of the original model wrapped in `Option` with the ability to omit instead select fields. -/// -///**Arguements:** -///- `name` - The name of the struct the generate (**Required**, **Must be first** e.g. `MyStruct`) -///- `omit` - A *list* of field names in the original structure to omit (**Required**, e.g. `fields(field1, field2, ...)`) -///- `derive` - A *list* of derivables (in scope) to derive on the generated struct (e.g. `derive(Clone, Debug, thiserror::Error)`) -///- `default_derives` - A *bool*, if `true` *(default)* then the a list of derives will be additionally derived. Otherwise, `false` to avoid this (e.g. `default_derives = false`) -/// -///**Example:** -///```rust -/// // Original -/// #[derive(restructed::Models)] -/// #[patch(UserUpdate, omit(id))] -/// struct User { -/// id: i32, -/// display_name: String, -/// bio: String, -/// password: String, -/// } -///``` -/// -///Generates: -///```rust -/// #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)] // <-- Default derives (when *not* disabled) -/// struct UserUpdate { -/// display_name: Option, -/// bio: Option, // MaybeUndefined with feature 'openapi' -/// password: Option, -/// } -///``` -/// -/// For more information, read the crate level documentation. #[proc_macro_error] #[proc_macro_derive(Models, attributes(view, patch, clone))] pub fn models(input: TokenStream) -> TokenStream { let ast = syn::parse_macro_input!(input as syn::DeriveInput); - let oai_attr = get_oai_attributes(&ast.attrs); - let views: Vec = ast .attrs .iter() .filter(|v| is_attribute(v, "view")) - .map(|a| view::impl_view_model(&ast, a, &oai_attr)) + .map(|a| view::impl_view_model(&ast, a)) .collect(); let patches: Vec = ast .attrs .iter() .filter(|v| is_attribute(v, "patch")) - .map(|a| patch::impl_patch_model(&ast, a, &oai_attr)) + .map(|a| patch::impl_patch_model(&ast, a)) .collect(); - // let clones: Vec = ast - // .attrs - // .iter() - // .filter(|v| is_attribute(v, "clone")) - // .map(|a| clone::impl_clone_model(&ast, a)) - // .collect(); - let gen = quote::quote!( #(#views)* #(#patches)* - // #(#clones)* ); gen.into() } - -// TODO: this stuff slaps. i should make it public - -// Supporting Functions - -fn get_oai_attributes(attrs: &[Attribute]) -> Vec<&Attribute> { - attrs - .iter() - .filter(|attr| is_attribute(attr, "oai")) - .collect() -} - -/// Check the first segment of an attribute to see if it matches the given name -fn is_attribute(attr: &Attribute, name: &str) -> bool { - get_attribute_name(attr).map_or(false, |v| v == name) -} - -/// Gets the first segment of the attribute which should typically the name of the attribute -fn get_attribute_name(attr: &Attribute) -> Option<&Ident> { - attr.meta.path().segments.first().map(|v| &v.ident) -} - -// "Invalid or missing `{name}` argument, expected a group of args, e.g. `{name}(...)`" - -/// Extract a group for a given identifier, e.g. `name(...)`. The `(...)` part is returned. -fn take_ident_group(name: &str, args: &mut Vec) -> Option { - for (i, tk) in args.iter().enumerate() { - match tk { - TokenTree::Ident(v) if *v == name => match args.get(i + 1) { - Some(TokenTree::Group(g)) => { - let g = g.to_owned(); - args.remove(i); // Remove Ident - args.remove(i); // Remove Group - if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { - args.remove(i); // Remove leading Comma - } - return Some(g); - }, - e => abort!(e, "Invalid or missing `{name}` argument, expected a group of args, e.g. `{name}(...)`"), - }, - _ => {} - } - } - None -} - -/// Extract a literal for a given identifier, e.g. `name = "..."`. The `"..."` part is returned. -fn take_ident_bool(name: &str, args: &mut Vec) -> Option { - for (i, tk) in args.iter().enumerate() { - match tk { - TokenTree::Ident(v) if *v == name => match args.get(i + 1) { - Some(TokenTree::Punct(p)) if p.as_char() == '=' => { - let value = args - .get(i + 2) - .map(|v| v.to_string()) - .unwrap_or("Nothing".to_string()); - let b = match value == "true" || value == "false" { - true => value == "true", - false => abort!( - tk, "Invalid or missing `{name}` argument, expected a bool, e.g. `{name} = true`" - ) - }; - - args.remove(i); // Remove Ident - args.remove(i); // Remove Punct - args.remove(i); // Remove Literal (Bool) - if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { - args.remove(i); // Remove leading Comma - } - return Some(b); - } - _ => abort!( - tk, - "Invalid or missing `{name}` argument, expected a bool, e.g. `{name} = true`" - ), - }, - _ => {} - } - } - None -} - -/// Parse the fields argument into a TokenStream, skipping checking for commas coz lazy -fn extract_idents(group: Group) -> Vec { - group - .stream() - .into_iter() - .filter_map(|tt| match tt { - TokenTree::Ident(ident) => Some(ident), - TokenTree::Punct(v) if v.as_char() == ',' => None, - tt => abort!(tt, "Invalid syntax, expected a field identifier, got {tt}`"), - }) - .collect() -} - -fn extract_oai_f_attributes(attrs: &[syn::Attribute]) -> Vec<&syn::Attribute> { - attrs - .iter() - .filter(|attr| { - attr.meta - .path() - .segments - .first() - .map_or(false, |seg| seg.ident == "oai") - }) - .collect() -} - -fn is_doc(v: &&Attribute) -> bool { - v.meta.require_name_value().map_or(false, |v| { - v.path.segments.first().map_or(false, |v| v.ident == "doc") - }) -} - -fn extract_docs(attrs: &[Attribute]) -> proc_macro2::TokenStream { - let docs: Vec<_> = attrs.iter().filter(is_doc).collect(); - quote!(#(#docs)*) -} - -/// Aborts on unexpected args to show that they arent valid -fn abort_unexpected_args(names: Vec<&str>, args: &[TokenTree]) { - for tk in args.iter() { - match tk { - TokenTree::Ident(v) if names.contains(&v.to_string().as_str()) => {} - TokenTree::Ident(v) => { - abort!( - v, - "Unknown argument `{}`, all known arguments are {:?}", - v, - names - ) - } - _ => {} - } - } -} -/// Parse a list of identifiers we want to derive. Will be empty if none are found. -fn parse_derives(args: &mut Vec) -> Vec { - // Extract the fields args and ensuring it is a key-value pair of Ident and Group - let fields: Group = match take_ident_group("derive", args) { - Some(g) => g, - None => return vec![], - }; - - extract_idents(fields) -} - -fn parse_default_derives(args: &mut Vec) -> bool { - // Extract the fields args and ensuring it is a key-value pair of Ident and Group - take_ident_bool("default_derives", args).unwrap_or(true) -} - -fn get_derive( - defaults: bool, - from_args: Vec<&Ident>, - for_struct: bool, -) -> proc_macro2::TokenStream { - let mut derives: Vec = vec![]; - - if defaults { - derives.push(quote!(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)); - if for_struct { - derives.push(quote!(Default)); - #[cfg(feature = "openapi")] - { - derives.push(quote!(::poem_openapi::Object)); - } - #[cfg(feature = "builder")] - { - derives.push(quote!(::typed_builder::TypedBuilder)); - } - } - } - if !from_args.is_empty() { - derives.push(quote!(#(#from_args),*)); - } - - quote!( - #[derive(#(#derives),*)] - ) -} diff --git a/src/logic/args.rs b/src/logic/args.rs new file mode 100644 index 0000000..ce1241f --- /dev/null +++ b/src/logic/args.rs @@ -0,0 +1,283 @@ +use super::{ + abort_unexpected_args, extract_idents, has_oai_attribute, take_ident_group, take_ident_ident, take_ident_literal, take_path_group +}; +use proc_macro2::{Ident, TokenTree}; +use proc_macro_error::abort; +use syn::{Attribute, Field}; + +#[derive(Clone)] +pub(crate) struct AttrArgs { + pub name: Ident, + pub fields: FieldsArg, + pub derive: Option>, + pub preset: Preset, + pub attributes_with: AttributesWith, +} + +impl AttrArgs { + /// Conditional aborts on unexpected args to show that they arent valid + pub(crate) fn abort_unexpected(args: &[TokenTree], ignore: &[&str]) { + const EXPECTED: &[&str; 5] = &["fields", "omit", "derive", "attributes_with", "preset"]; + let mut expect = EXPECTED.to_vec(); + expect.extend(ignore); + abort_unexpected_args(expect, args) + } + + /// Parses the attribute and returns the parsed arguments (0) and any remaining (1) + pub(crate) fn parse(attr: &Attribute, abort_unexpected: bool) -> (Self, Vec) { + let tks: Vec = attr + .meta + .require_list() + .unwrap() + .to_owned() + .tokens + .into_iter() + .collect::>(); + + let name = match &tks[0] { + TokenTree::Ident(v) => v.clone(), + x => { + abort!( + x, + "First argument must be an identifier (name) of the struct for the view" + ) + } + }; + + if tks.len() < 3 { + abort!(attr, "Invalid syntax, expected at least one argument"); + } + + let mut args = tks[2..].to_vec(); + let args_mr = &mut args; + + // Parse Expected Macro Args + let fields = FieldsArg::parse(args_mr, attr); + let derive = take_path_group("derive", args_mr); + let preset = Preset::parse(args_mr).unwrap_or_default(); + let attributes_with = AttributesWith::parse(args_mr).unwrap_or_else(|| preset.attr_with()); + + if abort_unexpected { + Self::abort_unexpected(&args, &[]) + } + + ( + Self { + name, + fields, + derive, + preset, + attributes_with + }, + args, + ) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum FieldsArg { + /// Fields to be included in the model (A whitelist) + Fields(Vec), + /// Fields to be omitted from the model (A blacklist) + Omit(Vec), +} + +impl FieldsArg { + pub(crate) fn parse(args: &mut Vec, attr_spanned: &Attribute) -> Self { + // Extract the fields args and ensuring it is a key-value pair of Ident and Group + + let field_arg = take_ident_group("fields", args); + let omit_args = take_ident_group("omit", args); + + if field_arg.is_some() && omit_args.is_some() { + abort!( + attr_spanned, + "Cannot have both `fields` and `omit` arguments" + ) + } + + // Parse the fields argument into a TokenStream, skip checking for commas coz lazy + match (field_arg, omit_args) { + (Some(g), None) => Self::Fields(extract_idents(g)), + (None, Some(g)) => Self::Omit(extract_idents(g)), + (None, None) => Self::Omit(vec![]), + (Some(_), Some(_)) => abort!( + attr_spanned, + "Cannot have both `fields` and `omit` arguments" + ), + } + } + + pub(crate) fn predicate(&self, field: &Ident) -> bool { + match self { + Self::Fields(fields) => fields.contains(field), + Self::Omit(fields) => !fields.contains(field), + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) enum AttributesWith { + #[default] + None, + #[cfg(feature = "openapi")] + Oai, + Deriveless, + All, +} + +impl AttributesWith { + pub(crate) fn parse(args: &mut Vec) -> Option { + let ident = take_ident_literal("attributes_with", args)?; + + let value = ident.to_string(); + Some(match &value[1..value.chars().count() - 1] { + "none" => Self::None, + #[cfg(feature = "openapi")] "oai" => Self::Oai, + "deriveless" => Self::Deriveless, + "all" => Self::All, + #[cfg(feature = "openapi")] + v => abort!( + ident, + "Invalid value, expected `none`, `oai` (from poem_openapi crate), `deriveless`, or `all` but got `{}`", v + ), + #[cfg(not(feature = "openapi"))] + v => abort!( + ident, + "Invalid value, expected `none`, `deriveless`, or `all` but got `{}`", v + ), + }) + } + + pub(crate) fn gen_top_attributes<'a>(&self, ast: &'a syn::DeriveInput) -> Vec<&'a Attribute> { + match self { + Self::All => ast + .attrs + .iter() + .filter(|attr| { + attr.path().segments.first().map_or(true, |seg| { + !matches!(seg.ident.to_string().as_str(), "view" | "patch") + }) // update if we add more + }) + .collect(), + Self::Deriveless => ast + .attrs + .iter() + .filter(|attr| { + attr.path().segments.first().map_or(false, |seg| { + !matches!(seg.ident.to_string().as_str(), "view" | "patch" | "derive") + }) // update if we add more + }) + .collect(), + #[cfg(feature = "openapi")] + Self::Oai => ast + .attrs + .iter() + .filter(|attr| attr.meta.path().is_ident("oai")) + .collect(), + Self::None => vec![], + } + } + + pub(crate) fn gen_field_attributes(&self, attrs: Vec) -> Vec { + match self { + #[cfg(feature = "openapi")] + Self::Oai => attrs + .into_iter() + .filter(|attr| { + attr.meta + .path() + .segments + .first() + .map_or(false, |seg| seg.ident == "oai") + }) + .collect::>(), + Self::All | AttributesWith::Deriveless => attrs.into_iter().collect::>(), // change if we ever add field level attributes to this crate + Self::None => vec![], + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) enum OptionType { + #[default] + Option, + MaybeUndefined, +} + +impl OptionType { + pub(crate) fn parse(args: &mut Vec) -> Option { + let ident = take_ident_ident("option", args)?; + + Some(match ident.to_string().as_str() { + "Option" => OptionType::Option, + "MaybeUndefined" => OptionType::MaybeUndefined, + _ => abort!( + ident, + "Invalid type, expected `Option` or `MaybeUndefined` (from poem_openapi crate)" + ), + }) + } + +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) enum Preset { + #[default] + None, + #[cfg(feature = "openapi")] + Read, + #[cfg(feature = "openapi")] + Write, +} + +impl Preset { + pub(crate) fn parse(args: &mut Vec) -> Option { + let ident = take_ident_literal("preset", args)?; + + let value = ident.to_string(); + Some(match &value[1..value.chars().count() - 1] { + "none" => Self::None, + #[cfg(feature = "openapi")] + "read" => Self::Read, + #[cfg(feature = "openapi")] + "write" => Self::Write, + #[cfg(feature = "openapi")] + v => abort!( + ident, + "Invalid value, expected `none` or `read`/`write` (with `openapi` feature) but got `{}`", v + ), + #[cfg(not(feature = "openapi"))] + v => abort!( + ident, + "Invalid value, expected `none` but got `{}`", v + ), + }) + } + + pub(crate) fn predicate(&self, field: &Field) -> bool { + match self { + Self::None => true, + #[cfg(feature = "openapi")] + Self::Read => !has_oai_attribute(&field.attrs, Some("write_only")), + #[cfg(feature = "openapi")] + Self::Write => !has_oai_attribute(&field.attrs, Some("read_only")), + } + } + + pub(crate) fn option(&self) -> OptionType { + match self { + Self::None => OptionType::Option, + #[cfg(feature = "openapi")] + Self::Read | Self::Write => OptionType::MaybeUndefined, + } + } + + pub(crate) fn attr_with(&self) -> AttributesWith { + match self { + Self::None => AttributesWith::None, + #[cfg(feature = "openapi")] + Self::Read | Self::Write => AttributesWith::Oai, + } + } +} diff --git a/src/logic/mod.rs b/src/logic/mod.rs new file mode 100644 index 0000000..cf42517 --- /dev/null +++ b/src/logic/mod.rs @@ -0,0 +1,268 @@ +use proc_macro2::{Group, Ident, Literal, TokenTree}; +use proc_macro_error::abort; +use quote::quote; +use syn::{parse2, Attribute}; + +#[cfg(test)] +mod tests; + +pub(crate) mod args; + +/// Check the first segment of an attribute to see if it matches the given name +pub(crate) fn is_attribute(attr: &Attribute, name: &str) -> bool { + get_attribute_name(attr).map_or(false, |v| v == name) +} + +/// Gets the first segment of the attribute which should typically the name of the attribute +pub(crate) fn get_attribute_name(attr: &Attribute) -> Option<&Ident> { + attr.meta.path().segments.first().map(|v| &v.ident) +} + +// "Invalid or missing `{name}` argument, expected a group of args, e.g. `{name}(...)`" + +/// Extract a group for a given identifier, e.g. `name(...)`. The `(...)` part is returned. +pub(crate) fn take_ident_group(name: &str, args: &mut Vec) -> Option { + for (i, tk) in args.iter().enumerate() { + match tk { + TokenTree::Ident(v) if *v == name => match args.get(i + 1) { + Some(TokenTree::Group(g)) => { + let g = g.to_owned(); + args.remove(i); // Remove Ident + args.remove(i); // Remove Group + if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { + args.remove(i); // Remove leading Comma + } + return Some(g); + }, + e => abort!(e, "Invalid or missing `{name}` argument, expected a group of args, e.g. `{name}(...)`"), + }, + _ => {} + } + } + None +} + +/// Extract a literal for a given identifier, e.g. `name = "..."`. The `"..."` part is returned. +#[allow(dead_code)] +pub(crate) fn take_ident_bool(name: &str, args: &mut Vec) -> Option { + for (i, tk) in args.iter().enumerate() { + match tk { + TokenTree::Ident(v) if *v == name => match args.get(i + 1) { + Some(TokenTree::Punct(p)) if p.as_char() == '=' => { + let value = args + .get(i + 2) + .map(|v| v.to_string()) + .unwrap_or("Nothing".to_string()); + let b = match value == "true" || value == "false" { + true => value == "true", + false => abort!( + tk, "Invalid or missing `{name}` argument, expected a bool, e.g. `{name} = true`" + ) + }; + + args.remove(i); // Remove Ident + args.remove(i); // Remove Punct + args.remove(i); // Remove Literal (Bool) + if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { + args.remove(i); // Remove leading Comma + } + return Some(b); + } + _ => abort!( + tk, + "Invalid or missing `{name}` argument, expected a bool, e.g. `{name} = true`" + ), + }, + _ => {} + } + } + None +} + +pub(crate) fn take_ident_ident(name: &str, args: &mut Vec) -> Option { + for (i, tk) in args.iter().enumerate() { + match tk { + TokenTree::Ident(v) if *v == name => match args.get(i + 1) { + Some(TokenTree::Punct(p)) if p.as_char() == '=' => { + let value = match args + .get(i + 2) { + Some(proc_macro2::TokenTree::Ident(v)) => v.to_owned(), + Some(v) => abort!(v, "Invalid arguement value, expected a identifier, e.g. `{} = *StructIdentifer*` but got {}", name, v), + None => abort!(v, "Missing `{name}` argument, expected an identifier, e.g. `{} = MyStruct`", name), + }; + + args.remove(i); // Remove Ident {name} + args.remove(i); // Remove Punct = + args.remove(i); // Remove Ident {value} + if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { + args.remove(i); // Remove leading Comma + } + return Some(value); + } + _ => abort!( + tk, + "Invalid or missing `{name}` argument, expected an identifier, e.g. `{name} = MyStruct`" + ), + }, + _ => {} + } + } + None +} + +pub(crate) fn take_ident_literal(name: &str, args: &mut Vec) -> Option { + for (i, tk) in args.iter().enumerate() { + match tk { + TokenTree::Ident(v) if *v == name => match args.get(i + 1) { + Some(TokenTree::Punct(p)) if p.as_char() == '=' => { + let value = match args + .get(i + 2) { + Some(proc_macro2::TokenTree::Literal(v)) => v.to_owned(), + Some(v) => abort!(v, "Invalid arguement value, expected a identifier, e.g. `{} = *StructIdentifer*` but got {}", name, v), + None => abort!(v, "Missing `{name}` argument, expected an identifier, e.g. `{} = MyStruct`", name), + }; + + args.remove(i); // Remove Ident {name} + args.remove(i); // Remove Punct = + args.remove(i); // Remove Literal {value} + if matches!(args.get(i), Some(TokenTree::Punct(v)) if v.as_char() == ',') { + args.remove(i); // Remove leading Comma + } + return Some(value); + } + _ => abort!( + tk, + "Invalid or missing `{name}` argument, expected an identifier, e.g. `{name} = MyStruct`" + ), + }, + _ => {} + } + } + None +} + +/// Extract a group for a given identifier, e.g. `name(...)`. The `(...)` part is returned. (Returns a group of syn::Path) +/// This function creates a stream/iter does 3 main things +/// 1. Filter for indices of all commas +/// 2. Create a range between indices (this will be the range of all paths) +/// 3. Parse the ranges into `syn::Path` +pub(crate) fn take_path_group(name: &str, args: &mut Vec) -> Option> { + let g = take_ident_group(name, args)?; + let paths: Vec = g + .stream() + .into_iter() + .enumerate() // Step 1 (Collect all comma indices) + .filter_map(|(i, v)| match v { + TokenTree::Punct(p) if p.as_char() == ',' => Some(i), + _ => None, + }) // Step 2 (Collect all ranges) + .chain(std::iter::once(g.stream().into_iter().count())) + .scan(0, |state, next| { + let start = match state { + 0 => 0, + _ => *state + 1, + }; + + let range = start..next; + *state = next; + + match start == next { + true => None, + false => Some(range), + } + }) + .map(|range| { + // Step 3 (Parse all paths) + let path: proc_macro2::TokenStream = g + .stream() + .into_iter() + .skip(range.start) + .take(range.end - range.start) + .collect(); + match parse2(path) { + Ok(v) => v, + Err(_) => abort!(g, "Invalid, not a valid derive Path (e.g. `core::fmt::Debug`) or Identifier (e.g. `Debug`)") + } + }) + .collect(); + match paths.is_empty() { + true => None, + false => Some(paths), + } +} + +/// Parse the fields argument into a TokenStream, skipping checking for commas coz lazy +pub(crate) fn extract_idents(group: Group) -> Vec { + group + .stream() + .into_iter() + .filter_map(|tt| match tt { + TokenTree::Ident(ident) => Some(ident), + TokenTree::Punct(v) if v.as_char() == ',' => None, + tt => abort!(tt, "Invalid syntax, expected a field identifier, got {tt}`"), + }) + .collect() +} + + +pub(crate) fn is_doc(v: &&Attribute) -> bool { + v.meta.require_name_value().map_or(false, |v| { + v.path.segments.first().map_or(false, |v| v.ident == "doc") + }) +} + +pub(crate) fn extract_docs(attrs: &[Attribute]) -> proc_macro2::TokenStream { + let docs: Vec<_> = attrs.iter().filter(is_doc).collect(); + quote!(#(#docs)*) +} + +/// Aborts on unexpected args to show that they arent valid +pub(crate) fn abort_unexpected_args(names: Vec<&str>, args: &[TokenTree]) { + for tk in args.iter() { + match tk { + TokenTree::Ident(v) if names.contains(&v.to_string().as_str()) => {} + TokenTree::Ident(v) => { + abort!( + v, + "Unknown argument `{}`, all known arguments are {:?}", + v, + names + ) + } + _ => {} + } + } +} + +pub(crate) fn gen_derive( + from_args: Option<&Vec> +) -> proc_macro2::TokenStream { + let mut derives: Vec = vec![]; + + match from_args { + Some(from_args) if !from_args.is_empty() => derives.push(quote!(#(#from_args),*)), + _ => {} + } + + quote!( + #[derive(#(#derives),*)] + ) +} + +#[cfg(feature = "openapi")] +pub(crate) fn has_oai_attribute(attrs: &[Attribute], containing: Option<&str>) -> bool { + attrs.iter() + .filter(|a| a.path().is_ident("oai")) + .filter(|a| + match containing { + Some(name) => + a.meta.require_list() + .expect("oai attribute usually has a list") + .tokens + .to_string() + .contains(name), + None => true + } + ) + .count() > 0 +} \ No newline at end of file diff --git a/src/logic/tests.rs b/src/logic/tests.rs new file mode 100644 index 0000000..a1b1bb1 --- /dev/null +++ b/src/logic/tests.rs @@ -0,0 +1,48 @@ +use super::*; +use quote::{quote, ToTokens}; +use syn::{parse2, DeriveInput}; + +fn gen_test_case_2() -> DeriveInput { + parse2(quote! { + #[derive(Models, Clone)] + #[view(UserUpdatables, omit(id), derives(Clone, core::fmt::Debug))] + struct User { + id: i32, + display_name: String, + bio: String, + password: String, + } + }) + .unwrap() +} + +fn get_view_args(attrs: Vec, index: usize) -> Vec { + let attr: Attribute = attrs + .iter() + .filter(|v| is_attribute(v, "view")) + .collect::>()[index] + .clone(); + + attr.meta + .require_list() + .unwrap() + .to_owned() + .tokens + .into_iter() + .collect::>()[2..] + .to_vec() +} + +#[test] +pub fn should_extract_derives() { + let ast = gen_test_case_2(); + let mut attr_args = get_view_args(ast.attrs, 0); + + let derives = take_path_group("derives", &mut attr_args).expect("Should of found derives"); + assert_eq!(derives[0].to_token_stream().to_string(), "Clone"); + assert_eq!( + derives[1].to_token_stream().to_string(), + "core :: fmt :: Debug" + ); + assert_eq!(derives.len(), 2); +} diff --git a/src/patch.rs b/src/patch.rs index 7b1d47b..e51d3bd 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,28 +1,26 @@ -use crate::*; - -use proc_macro2::{Group, Ident, TokenStream, TokenTree}; +use crate::logic::{ + args::{AttrArgs, OptionType}, + *, +}; +use proc_macro2::{Ident, TokenStream}; use proc_macro_error::abort; use quote::quote; use syn::{Attribute, DeriveInput, Type}; -struct PatchModelArgs { - name: Ident, - omit: Vec, - derives: Vec, - default_derives: bool, -} - -pub fn impl_patch_model( - ast: &DeriveInput, - attr: &Attribute, - oai_attr: &Vec<&Attribute>, -) -> TokenStream { - let PatchModelArgs { +pub fn impl_patch_model(ast: &DeriveInput, attr: &Attribute) -> TokenStream { + // Argument and Variable Initialization and Prep + let (args, mut remainder) = AttrArgs::parse(attr, false); + let AttrArgs { name, - omit, - derives, - default_derives, - } = parse_patch_arg(attr); + fields: _, + derive, + preset, + attributes_with, + } = args.clone(); + + let option = OptionType::parse(&mut remainder).unwrap_or_else(|| preset.option()); + + AttrArgs::abort_unexpected(&remainder, &["option"]); let original_name = &ast.ident; @@ -30,53 +28,42 @@ pub fn impl_patch_model( let mut fields_and_is_option: Vec<(&Ident, bool)> = vec![]; let mut fields: Vec<_> = vec![]; match &ast.data { - syn::Data::Struct(data) => data.fields.iter().for_each(|field| { - let field_attributes: &Vec = &field.attrs; - let field_name = &field.ident.as_ref().unwrap(); - - // Omit - if omit.contains(field_name) || has_attribute(field_attributes, "oai", "read_only") { - return; - } - - // Add - let docs = extract_docs(&field.attrs); - let oai_f_attributes: Vec<_> = field_attributes - .iter() - .filter(|attr| { - attr.meta - .path() - .segments - .first() - .map_or(false, |seg| seg.ident == "oai") - }) - .collect(); - let field_ty = &field.ty; - let option_ty = extract_type_from_option(field_ty); - - fields_and_is_option.push((field_name, option_ty.is_some())); - fields.push(impl_struct_fields( - field_name, - field_ty, - option_ty, - &docs, - &oai_f_attributes, - )); - }), + syn::Data::Struct(data) => data + .fields + .iter() + .filter(|f| { + preset.predicate(f) + && args + .fields + .predicate(f.ident.as_ref().expect("Field must be named")) + }) + .for_each(|field| { + let field_name = &field.ident.as_ref().unwrap(); + + // Add + let docs = extract_docs(&field.attrs); + let field_ty = &field.ty; + let option_ty = extract_type_from_option(field_ty); + + fields_and_is_option.push((field_name, option_ty.is_some())); + fields.push(impl_struct_fields( + field_name, field_ty, option_ty, &docs, option, + )); + }), _ => abort!(attr, "Patch Models can only be derived for structs"), }; - let derives = get_derive(default_derives, derives.iter().collect(), true); - let impl_from_derived = impl_from_derived(&fields_and_is_option); - let impl_merge = impl_merge(&fields_and_is_option); - let impl_weld_merge = impl_weld_merge(&impl_merge, original_name); + let attributes = attributes_with.gen_top_attributes(ast); + let derives = gen_derive(derive.as_ref()); + let impl_from_derived = impl_from_derived(&fields_and_is_option, option); + let impl_merge = impl_merge(&fields_and_is_option, option); // Generate the implementation of the PatchModel trait quote! { /// Generated patch model of [`#original_name`] #derives - #(#oai_attr)* + #(#attributes)* pub struct #name { #(#fields),* } @@ -101,9 +88,6 @@ pub fn impl_patch_model( pub fn merge_mut(self, mut value: &mut #original_name) { #impl_merge } - - #impl_weld_merge - } @@ -115,103 +99,79 @@ pub fn impl_patch_model( } } -fn impl_merge(fields: &[(&Ident, bool)]) -> TokenStream { - #[cfg(feature = "openapi")] - { - let option_field: Vec<_> = fields - .iter() - .filter_map(|(ident, is_option)| is_option.then_some(ident)) - .collect(); +fn impl_merge(fields: &[(&Ident, bool)], option: OptionType) -> TokenStream { + match option { + OptionType::MaybeUndefined => { + let option_field: Vec<_> = fields + .iter() + .filter_map(|(ident, is_option)| is_option.then_some(ident)) + .collect(); - let required_field: Vec<_> = fields - .iter() - .filter_map(|(ident, is_option)| (!is_option).then_some(ident)) - .collect(); + let required_field: Vec<_> = fields + .iter() + .filter_map(|(ident, is_option)| (!is_option).then_some(ident)) + .collect(); - quote! { - #( - match self.#required_field { - ::core::option::Option::Some(v) => value.#required_field = v, - ::core::option::Option::None => {}, - } - )* - #( - match self.#option_field { - ::poem_openapi::types::MaybeUndefined::Value(v) => value.#option_field = ::core::option::Option::Some(v), - ::poem_openapi::types::MaybeUndefined::Null => value.#option_field = ::core::option::Option::None, - ::poem_openapi::types::MaybeUndefined::Undefined => {}, - } - )* + quote! { + #( + match self.#required_field { + ::core::option::Option::Some(v) => value.#required_field = v, + ::core::option::Option::None => {}, + } + )* + #( + match self.#option_field { + ::poem_openapi::types::MaybeUndefined::Value(v) => value.#option_field = ::core::option::Option::Some(v), + ::poem_openapi::types::MaybeUndefined::Null => value.#option_field = ::core::option::Option::None, + ::poem_openapi::types::MaybeUndefined::Undefined => {}, + } + )* + } } - } - #[cfg(not(feature = "openapi"))] - { - let field: Vec<_> = fields.iter().map(|v| v.0).collect(); - quote! { - #( - match self.#field { - ::core::option::Option::Some(v) => value.#field = v, - ::core::option::Option::None => {}, - } - )* + OptionType::Option => { + let field: Vec<_> = fields.iter().map(|v| v.0).collect(); + quote! { + #( + match self.#field { + ::core::option::Option::Some(v) => value.#field = v, + ::core::option::Option::None => {}, + } + )* + } } } } -fn impl_from_derived(fields: &[(&Ident, bool)]) -> TokenStream { - #[cfg(feature = "openapi")] - { - let option_field: Vec<_> = fields - .iter() - .filter_map(|(ident, is_option)| is_option.then_some(ident)) - .collect(); - - let required_field: Vec<_> = fields - .iter() - .filter_map(|(ident, is_option)| (!is_option).then_some(ident)) - .collect(); - - quote! { - #( - #option_field: ::poem_openapi::types::MaybeUndefined::from_opt_undefined(value.#option_field), - )* - #( - #required_field: ::core::option::Option::Some(value.#required_field), - )* - } - } - #[cfg(not(feature = "openapi"))] - { - let field: Vec<_> = fields.iter().map(|v| v.0).collect(); - quote! { - #( - #field: ::core::option::Option::Some(value.#field), - )* - } - } -} +fn impl_from_derived(fields: &[(&Ident, bool)], option: OptionType) -> TokenStream { + match option { + OptionType::MaybeUndefined => { + let option_field: Vec<_> = fields + .iter() + .filter_map(|(ident, is_option)| is_option.then_some(ident)) + .collect(); -#[allow(unused_variables)] -fn impl_weld_merge(impl_merge: &TokenStream, original_name: &Ident) -> TokenStream { - #[cfg(feature = "welds")] - { - quote! { - /// Creates a new [`DbState`] from the given value, merging the updates into the given value, returning the updated value - pub fn merge_weld(self, mut value: ::welds::state::DbState<#original_name>) -> ::welds::state::DbState<#original_name> { - self.merge_weld_mut(&mut value); - value + let required_field: Vec<_> = fields + .iter() + .filter_map(|(ident, is_option)| (!is_option).then_some(ident)) + .collect(); + quote! { + #( + #option_field: ::poem_openapi::types::MaybeUndefined::from_opt_undefined(value.#option_field), + )* + #( + #required_field: ::core::option::Option::Some(value.#required_field), + )* } - - /// Mutable reference version of [`Self::merge_weld`] - pub fn merge_weld_mut(self, mut value: &mut ::welds::state::DbState<#original_name>) { - #impl_merge + } + OptionType::Option => { + let field: Vec<_> = fields.iter().map(|v| v.0).collect(); + quote! { + #( + #field: ::core::option::Option::Some(value.#field), + )* } } } - #[cfg(not(feature = "welds"))] - { - quote!() - } } fn impl_struct_fields( @@ -219,90 +179,30 @@ fn impl_struct_fields( field_ty: &Type, #[allow(unused_variables)] option_ty: Option<&Type>, docs: &TokenStream, - oai_f_attr: &Vec<&Attribute>, + option: OptionType, ) -> TokenStream { - #[cfg(feature = "openapi")] - { - match option_ty { + match option { + OptionType::MaybeUndefined => match option_ty { Some(t) => { quote! { #docs - #(#oai_f_attr)* pub #field_name: ::poem_openapi::types::MaybeUndefined<#t> } } _ => { quote! { #docs - #(#oai_f_attr)* pub #field_name: core::option::Option<#field_ty> } } + }, + OptionType::Option => { + quote! { + #docs + pub #field_name: core::option::Option<#field_ty> + } } } - #[cfg(not(feature = "openapi"))] - { - quote! { - #docs - #(#oai_f_attr)* - pub #field_name: core::option::Option<#field_ty> - } - } -} - -fn parse_patch_arg(attr: &Attribute) -> PatchModelArgs { - let tks = attr - .meta - .require_list() - .unwrap() - .to_owned() - .tokens - .into_iter() - .collect::>(); - - let name = match &tks[0] { - TokenTree::Ident(v) => v.clone(), - x => { - abort!( - x, - "First argument must be an identifier (name) of the struct for the view" - ) - } - }; - - if tks.len() < 3 { - return PatchModelArgs { - name, - omit: vec![], - derives: vec![], - default_derives: true, - }; - } - - let mut args_slice = tks[2..].to_vec(); - - let omit = parse_omit(&mut args_slice); - let derives = parse_derives(&mut args_slice); - let default_derives = parse_default_derives(&mut args_slice); - abort_unexpected_args(vec!["fields", "derive", "default_derives"], &args_slice); - - PatchModelArgs { - name, - omit, - derives, - default_derives, - } -} - -fn parse_omit(args: &mut Vec) -> Vec { - // Extract the fields args and ensuring it is a key-value pair of Ident and Group - let fields: Group = match take_ident_group("omit", args) { - Some(g) => g, - None => return vec![], - }; - - // Parse the fields argument into a TokenStream, skip checking for commas coz lazy - extract_idents(fields) } fn extract_type_from_option(ty: &Type) -> Option<&Type> { @@ -342,17 +242,3 @@ fn extract_type_from_option(ty: &Type) -> Option<&Type> { _ => None, }) } - -fn has_attribute(attrs: &[syn::Attribute], name: &str, value: &str) -> bool { - attrs.iter().any(|attr| { - let path = attr.path(); - let segments = &path.segments; - - segments.len() == 1 - && segments[0].ident == name - && attr - .meta - .require_list() - .map_or(false, |v| v.tokens.to_string() == value) - }) -} diff --git a/src/view.rs b/src/view.rs index 40a61ba..65be667 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,129 +1,51 @@ -use crate::*; -use proc_macro2::{Ident, TokenStream, TokenTree}; +use crate::logic::{args::AttrArgs, *}; +use proc_macro2::{Ident, TokenStream}; +use proc_macro_error::abort; use quote::quote; -use syn::{self, Attribute, DeriveInput}; - -struct ViewModelArgs { - name: Ident, - fields: Vec, - derives: Vec, - default_derives: bool, -} +use syn::{self, Attribute, DataEnum, DataStruct, DeriveInput}; pub fn impl_view_model( ast: &DeriveInput, - attr: &Attribute, - oai_attr: &Vec<&Attribute>, + attr: &Attribute ) -> TokenStream { - let ViewModelArgs { + // Argument and Variable Initialization and Prep + let (args, _) = AttrArgs::parse(attr, true); + let AttrArgs { name, - fields, - derives, - default_derives, - } = parse_view_attributes(attr); + fields: _, + derive, + preset: _, + attributes_with + } = args.clone(); - // // Filter attr to only copy the 'oai' ones over let original_name = &ast.ident; - let mut is_struct = true; - let mut field_from_mapping: Vec = vec![]; + let is_struct = matches!(&ast.data, syn::Data::Struct(_)); + let mut field_mapping: Vec = vec![]; // Will contain each fields `From` trait impl + // Generate Implementation let field_tokens: Vec<_> = match &ast.data { - syn::Data::Struct(data) => data - .fields - .iter() - .filter(|f| fields.contains(f.ident.as_ref().expect("Field(s) must be named"))) - .map(|field| { - let field_attr: &Vec = &field.attrs; - - let vis = &field.vis; - let docs = extract_docs(&field.attrs); - let oai_f_attributes: Vec<_> = extract_oai_f_attributes(field_attr); - let field_name = &field.ident.as_ref().unwrap(); - let field_ty = &field.ty; - - field_from_mapping.push(quote!(#field_name: value.#field_name)); - quote! { - #docs - #(#oai_f_attributes)* - #vis #field_name: #field_ty - } - }) - .collect(), - syn::Data::Enum(data) => { - is_struct = false; - data.variants - .iter() - .filter(|v| fields.contains(&v.ident)) - .map(|field| { - let oai_f_attr = get_oai_attributes(&field.attrs); - - let mut field_without_attrs = field.clone(); - field_without_attrs.attrs = vec![]; - - let docs = extract_docs(&field.attrs); - let field_name = &field.ident; - - match &field.fields { - syn::Fields::Unit => { - field_from_mapping.push(quote!{ - #name::#field_name => #original_name::#field_name - }); - }, - syn::Fields::Unnamed(_) => { - let variant_args: Vec<_> = field - .fields - .iter() - .enumerate() - .map(|(i, _)| { - let c = (i as u8 + b'a') as char; - // convert c to ident so it wont be quoted - let c= syn::Ident::new(&c.to_string(), proc_macro2::Span::call_site()); - - quote!(#c) - }) - .collect(); - field_from_mapping.push(quote!{ - #name::#field_name(#(#variant_args),*) => #original_name::#field_name(#(#variant_args),*) - }); - } - syn::Fields::Named(n) => { - let variant_args: Vec<_> = n - .named - .iter() - .map(|f| { - let arg = f.ident.as_ref(); - quote!(#arg) - }) - .collect(); - field_from_mapping.push(quote!{ - #name::#field_name{#(#variant_args),*} => #original_name::#field_name{#(#variant_args),*} - }); - }, - }; - - quote! { - #docs - #(#oai_f_attr)* - #field_without_attrs - } - }) - .collect() - } - _ => abort!(attr, "Patch Model can only be derived for structs & enums"), + syn::Data::Struct(data) => impl_for_struct(data, &mut field_mapping, &args), + syn::Data::Enum(data) => impl_for_enum(data, &mut field_mapping, &args, original_name), + syn::Data::Union(_) => abort!(attr, "Patch Model can only be derived for `struct` & `enum`, NOT `union`"), }; - let data_type = match is_struct { + let structure = match is_struct { true => quote!(struct), false => quote!(enum), }; - let derives = get_derive(default_derives, derives.iter().collect(), is_struct); - let impl_from = impl_from_trait(original_name, &name, field_from_mapping, is_struct); + + let attributes = attributes_with.gen_top_attributes(ast); + let derives = gen_derive(derive.as_ref()); + + let impl_from = impl_from_trait(original_name, &name, field_mapping, is_struct); + + let doc_string = format!("This is a restructured (View) model of ['{original_name}']. Refer to the original model for more structual documentation."); quote! { - /// A generated view of the original struct with only the specified fields + #[doc= #doc_string] #derives - #(#oai_attr)* - pub #data_type #name { + #(#attributes)* + pub #structure #name { #(#field_tokens),* } @@ -160,51 +82,105 @@ fn impl_from_trait( } } -fn parse_view_attributes(attr: &Attribute) -> ViewModelArgs { - let tks: Vec = attr - .meta - .require_list() - .unwrap() - .to_owned() - .tokens - .into_iter() - .collect::>(); - - let name = match &tks[0] { - TokenTree::Ident(v) => v.clone(), - x => { - abort!( - x, - "First argument must be an identifier (name) of the struct for the view" - ) - } - }; - if tks.len() < 3 { - abort!(attr, "Invalid syntax, expected at least one argument"); - } - let mut args_slice = tks[2..].to_vec(); - - let fields = parse_fields(&mut args_slice, attr); - let derives = parse_derives(&mut args_slice); - let default_derives = parse_default_derives(&mut args_slice); - abort_unexpected_args(vec!["fields", "derive", "default_derives"], &args_slice); - ViewModelArgs { - name, +fn impl_for_struct(data: &DataStruct, field_mapping: &mut Vec, args: &AttrArgs) -> Vec { + let AttrArgs { + name: _, fields, - derives, - default_derives, - } -} + derive: _, + preset, + attributes_with + } = args; -/// Parse a list of identifiers equal to fields we want in the model. Aborts if none are found. -fn parse_fields(args: &mut Vec, attr_spanned: &Attribute) -> Vec { - // Extract the fields args and ensuring it is a key-value pair of Ident and Group - let fields: Group = match take_ident_group("fields", args) { - Some(g) => g, - None => abort!(attr_spanned, "Missing args, expected `fields(...)"), - }; - // Parse the fields argument into a TokenStream, skip checking for commas coz lazy - extract_idents(fields) + data + .fields + .iter() + .filter(|f| { + preset.predicate(f) && fields.predicate(f.ident.as_ref().expect("Field must be named")) + }) + .map(|field| { + let vis = &field.vis; + let docs = extract_docs(&field.attrs); + let field_name = &field.ident.as_ref().unwrap(); + let field_ty = &field.ty; + + let field_attr = attributes_with.gen_field_attributes(field.attrs.clone()); + + field_mapping.push(quote!(#field_name: value.#field_name)); + quote! { + #docs + #(#field_attr)* + #vis #field_name: #field_ty + } + }) + .collect() } + +fn impl_for_enum(data: &DataEnum, field_mapping: &mut Vec, args: &AttrArgs, original_name: &Ident) -> Vec { + let AttrArgs { + name, + fields, + derive: _, + preset, + attributes_with + } = args; + + data.variants + .iter() + .filter(|v| fields.predicate(&v.ident)) + .map(|field| { + let mut field_impl = field.clone(); + field_impl.attrs = attributes_with.gen_field_attributes(field_impl.attrs); + + let docs = extract_docs(&field.attrs); + let field_name = &field.ident; + + match &field.fields { + syn::Fields::Unit => { + field_mapping.push(quote!{ + #name::#field_name => #original_name::#field_name + }); + }, + syn::Fields::Unnamed(_) => { + let variant_args: Vec<_> = field + .fields + .iter() + .filter(|f| preset.predicate(f)) + .enumerate() + .map(|(i, _)| { + let c = (i as u8 + b'a') as char; + let c= syn::Ident::new(&c.to_string(), proc_macro2::Span::call_site()); // convert char to ident so it wont be "quoted" + + quote!(#c) + }) + .collect(); + field_mapping.push(quote!{ + #name::#field_name(#(#variant_args),*) => #original_name::#field_name(#(#variant_args),*) + }); + } + syn::Fields::Named(n) => { + let variant_args: Vec<_> = n + .named + .iter() + .filter(|f| preset.predicate(f)) + .map(|f| { + + let arg = f.ident.as_ref(); + quote!(#arg) + }) + .collect(); + field_mapping.push(quote!{ + #name::#field_name{#(#variant_args),*} => #original_name::#field_name{#(#variant_args),*} + }); + }, + }; + + quote! { + #docs + #field_impl + } + }) + .collect() + +} \ No newline at end of file diff --git a/tests/clone.rs b/tests/clone.rs index 75cb847..ba0b549 100644 --- a/tests/clone.rs +++ b/tests/clone.rs @@ -1,12 +1,12 @@ -use restructed::Models; +// use restructed::Models; -#[derive(Models)] -#[clone(UserClone, derive(Clone))] -struct User { - /// This should be omitted - id: i32, - /// This shouldn't be omitted - display_name: String, - bio: String, - password: String, -} +// #[derive(Models)] +// #[clone(UserClone, derive(Clone))] +// struct User { +// /// This should be omitted +// id: i32, +// /// This shouldn't be omitted +// display_name: String, +// bio: String, +// password: String, +// } diff --git a/tests/feature_testing/builder.rs b/tests/feature_testing/builder.rs deleted file mode 100644 index 8a433e3..0000000 --- a/tests/feature_testing/builder.rs +++ /dev/null @@ -1,38 +0,0 @@ -use restructed::Models; - -#[derive(Models, Clone)] -#[patch(UserUpdatables, omit(id), default_derives = true)] -#[view(UserId, fields(id))] -struct User { - id: i32, - display_name: String, - bio: String, - password: String, -} - -impl User { - pub fn new() -> Self { - User { - id: 123, - display_name: "Cool Doode".to_string(), - bio: "I'm a cool doode, what can I say?".to_string(), - password: "Pls don't hack me".to_string(), - } - } -} - -#[test] -fn can_build() { - let user = User::new(); - - let id = UserId::builder() - .id(user.id) - .build(); - assert_eq!(&id.id, &user.id); - - UserUpdatables::builder() - .display_name(Some("Cooler doode".to_string())) - .bio(None) - .password(Some("Can't hack 'dis".to_string())) - .build(); -} \ No newline at end of file diff --git a/tests/feature_testing/openapi.rs b/tests/feature_testing/openapi.rs index 50f9165..62280f0 100644 --- a/tests/feature_testing/openapi.rs +++ b/tests/feature_testing/openapi.rs @@ -1,11 +1,14 @@ -use poem_openapi::{payload::Json, types::MaybeUndefined, ApiResponse}; +#![allow(dead_code)] + +use poem_openapi::{payload::Json, types::MaybeUndefined, ApiResponse, Object}; use restructed::Models; //---------- Structs // Should makes it impl poem's Object for structs -#[derive(Clone, Models)] -#[patch(UserUpdates, omit(id))] +#[derive(Clone, Object, Models)] +#[oai(skip_serializing_if_is_none, rename_all = "camelCase")] +#[patch(UserUpdates, omit(id), attributes_with = "oai", option = MaybeUndefined, derive(Object))] struct User { id: i32, display_name: Option, @@ -48,6 +51,25 @@ fn mapping_struct() { assert_eq!(updated_user.password, user.password); } +//---------- Structs - Preset Read/Write + +// Should makes it impl poem's Object for structs +#[derive(Clone, Debug, Object, Models, PartialEq, Eq)] +#[oai(skip_serializing_if_is_none, rename_all = "camelCase")] +#[patch(UserUpdatable, preset = "write", derive(Object))] +#[patch(UserViewables, preset = "read", derive(Object))] +#[view(UserUpdated, preset = "write", derive(Object))] +#[view(UserRecord, preset = "read", derive(Object))] +struct UserPre { + #[oai(read_only)] + id: i32, + display_name: Option, + bio: String, + extra: Option, + #[oai(write_only)] + password: Option, +} + //---------- Enums // There are many derives for enums in poem_openapi. @@ -56,7 +78,7 @@ fn mapping_struct() { #[view( ApiErrorReads, fields(NotFound, InternalServerError), - default_derives = false, // poem_openapi types don't implement stuff like PartialEq, this avoids errors + attributes_with = "oai", derive(ApiResponse, Clone) )] pub enum ApiError { diff --git a/tests/feature_testing/welds.rs b/tests/feature_testing/welds.rs deleted file mode 100644 index 7535fcc..0000000 --- a/tests/feature_testing/welds.rs +++ /dev/null @@ -1,48 +0,0 @@ -use restructed::Models; -use welds::state::DbState; - -#[derive(Models, Clone, PartialEq, Eq, Debug)] -#[patch(UserUpdatables, omit(id), default_derives = true)] -#[view(UserId, fields(id))] -struct User { - id: i32, - display_name: String, - bio: String, - password: String, -} - -impl User { - pub fn new() -> Self { - User { - id: 123, - display_name: "Cool Doode".to_string(), - bio: "I'm a cool doode, what can I say?".to_string(), - password: "Pls don't hack me".to_string(), - } - } -} - -#[test] -fn can_build() { - let user = User::new(); - - let id = UserId::builder() - .id(user.id) - .build(); - assert_eq!(&id.id, &user.id); - - let patch = UserUpdatables::builder() - .display_name(Some("Cooler doode".to_string())) - .bio(None) - .password(Some("Can't hack 'dis".to_string())) - .build(); - - let mut state = DbState::new_uncreated(user.clone()); - patch.merge_weld_mut(&mut state); - - - assert_ne!(*state, user); - assert_eq!(*state.display_name, "Cooler doode".to_string()); - assert_eq!(*state.bio, "I'm a cool doode, what can I say?".to_string()); - assert_eq!(*state.password, "Can't hack 'dis".to_string()); -} \ No newline at end of file diff --git a/tests/mod.rs b/tests/mod.rs index e246d1b..0cbf280 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,3 +1,3 @@ //! Entry point for integration tests. -mod feature_testing; +mod feature_testing; \ No newline at end of file diff --git a/tests/patch.rs b/tests/patch.rs index 11796d7..ed9d9e0 100644 --- a/tests/patch.rs +++ b/tests/patch.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] extern crate restructed; +use poem_openapi::types::MaybeUndefined; use restructed::Models; #[derive(Models, Clone)] @@ -8,7 +9,7 @@ use restructed::Models; struct User { id: i32, display_name: String, - bio: String, + bio: Option, password: String, } @@ -17,7 +18,7 @@ impl User { User { id: 123, display_name: "Cool Doode".to_string(), - bio: "I'm a cool doode, what can I say?".to_string(), + bio: Some("I'm a cool doode, what can I say?".to_string()), password: "Pls don't hack me".to_string(), } } @@ -39,3 +40,43 @@ fn omitted_only() { assert_eq!(user.bio, updated_user.bio); assert_ne!(user.password, updated_user.password); } + +//------------------ Structs - MaybeUndefined + + +#[derive(Models, Clone)] +#[patch(UserMaybes, omit(id), option = MaybeUndefined)] +struct UserAlt { + id: i32, + display_name: String, + bio: Option, + password: String, +} + +impl UserAlt { + pub fn new() -> Self { + UserAlt { + id: 123, + display_name: "Cool Doode".to_string(), + bio: Some("I'm a cool doode, what can I say?".to_string()), + password: "Pls don't hack me".to_string(), + } + } +} + +#[test] +fn alt_omitted_only() { + let user = UserAlt::new(); + + let maybes = UserMaybes { + display_name: Some("Cooler doode".to_string()), + bio: MaybeUndefined::Null, + password: Some("Can't hack 'dis".to_string()), + }; + + let updated_user = maybes.merge(user.clone()); + + assert_ne!(user.display_name, updated_user.display_name); + assert_ne!(user.bio, updated_user.bio); + assert_ne!(user.password, updated_user.password); +} diff --git a/tests/view.rs b/tests/view.rs index 1c10d5f..f10485e 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -50,6 +50,45 @@ fn only_fields() { assert_eq!(profile.bio, profile.bio); } +//------------------ Structs -- attributes_with = "all" + +#[derive(Models)] +#[view(UserProfileAll, fields(display_name, bio), attributes_with = "all")] +struct UserAttrAll { + /// This should be omitted + id: i32, + /// This shouldn't be omitted + display_name: String, + bio: String, + password: String, +} + +//------------------ Structs -- attributes_with = "deriveless" + +#[derive(Models)] +#[view(UserProfileDeriveless, fields(display_name, bio), attributes_with = "deriveless")] +struct UserAttrDeriveless { + /// This should be omitted + id: i32, + /// This shouldn't be omitted + display_name: String, + bio: String, + password: String, +} + +//------------------ Structs -- attributes_with = "none" + +#[derive(Models)] +#[view(UserProfileNone, fields(display_name, bio), attributes_with = "none")] +struct UserAttrNone{ + /// This should be omitted + id: i32, + /// This shouldn't be omitted + display_name: String, + bio: String, + password: String, +} + //------------------ Enums #[derive(Debug, Clone, Models)]