feat: add support for simple enums in stores

This commit is contained in:
Greg Johnston 2024-08-21 20:39:23 -04:00
parent 1ad3249764
commit 0ce0184e8c
5 changed files with 323 additions and 133 deletions

View File

@ -1,7 +1,12 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use leptos::prelude::*;
use reactive_stores::{Field, Store, StoreFieldIterator};
use reactive_stores_macro::Store;
// ID starts higher than 0 because we have a few starting todos by default
static NEXT_ID: AtomicUsize = AtomicUsize::new(3);
#[derive(Debug, Store)]
struct Todos {
user: String,
@ -10,15 +15,35 @@ struct Todos {
#[derive(Debug, Store)]
struct Todo {
id: usize,
label: String,
completed: bool,
status: Status,
}
#[derive(Debug, Default, Clone, Store)]
enum Status {
#[default]
Pending,
Scheduled,
Done,
}
impl Status {
pub fn next_step(&mut self) {
*self = match self {
Status::Pending => Status::Scheduled,
Status::Scheduled => Status::Done,
Status::Done => Status::Done,
};
}
}
impl Todo {
pub fn new(label: impl ToString) -> Self {
Self {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
label: label.to_string(),
completed: false,
status: Status::Pending,
}
}
}
@ -28,16 +53,19 @@ fn data() -> Todos {
user: "Bob".to_string(),
todos: vec![
Todo {
id: 0,
label: "Create reactive store".to_string(),
completed: true,
status: Status::Pending,
},
Todo {
id: 1,
label: "???".to_string(),
completed: false,
status: Status::Pending,
},
Todo {
id: 2,
label: "Profit".to_string(),
completed: false,
status: Status::Pending,
},
],
}
@ -49,15 +77,6 @@ pub fn App() -> impl IntoView {
let input_ref = NodeRef::new();
let rows = move || {
store
.todos()
.iter()
.enumerate()
.map(|(idx, todo)| view! { <TodoRow store idx todo/> })
.collect_view()
};
view! {
<p>"Hello, " {move || store.user().get()}</p>
<form on:submit=move |ev| {
@ -67,7 +86,12 @@ pub fn App() -> impl IntoView {
<label>"Add a Todo" <input type="text" node_ref=input_ref/></label>
<input type="submit"/>
</form>
<ol>{rows}</ol>
<ol>
<For each=move || store.todos().iter() key=|row| row.id().get() let:todo>
<TodoRow store todo/>
</For>
</ol>
<div style="display: flex"></div>
}
}
@ -75,22 +99,18 @@ pub fn App() -> impl IntoView {
#[component]
fn TodoRow(
store: Store<Todos>,
idx: usize,
#[prop(into)] todo: Field<Todo>,
) -> impl IntoView {
let completed = todo.completed();
let status = todo.status();
let title = todo.label();
let editing = RwSignal::new(false);
view! {
<li
style:text-decoration=move || {
completed.get().then_some("line-through").unwrap_or_default()
}
<li style:text-decoration=move || {
status.done().then_some("line-through").unwrap_or_default()
}>
class:foo=move || completed.get()
>
<p
class:hidden=move || editing.get()
on:click=move |_| {
@ -112,17 +132,26 @@ fn TodoRow(
on:blur=move |_| editing.set(false)
autofocus
/>
<input
type="checkbox"
prop:checked=move || completed.get()
on:click=move |_| { completed.update(|n| *n = !*n) }
/>
<button on:click=move |_| {
status.write().next_step()
}>
{move || {
if todo.status().done() {
"Done"
} else if status.scheduled() {
"Scheduled"
} else {
"Pending"
}
}}
</button>
<button on:click=move |_| {
store
.todos()
.update(|todos| {
todos.remove(idx);
todos.remove(todo.id().get());
});
}>"X"</button>
</li>

View File

@ -28,7 +28,7 @@ pub use iter::*;
pub use option::*;
pub use patch::*;
use path::StorePath;
pub use store_field::StoreField;
pub use store_field::{StoreField, Then};
pub use subfield::Subfield;
#[derive(Debug, Default)]

View File

@ -139,6 +139,26 @@ where
defined_at: &'static Location<'static>,
}
impl<T, S> Then<T, S>
where
S: StoreField,
{
#[track_caller]
pub fn new(
inner: S,
map_fn: fn(&S::Value) -> &T,
map_fn_mut: fn(&mut S::Value) -> &mut T,
) -> Self {
Self {
inner,
map_fn,
map_fn_mut,
#[cfg(debug_assertions)]
defined_at: Location::caller(),
}
}
}
impl<T, S> StoreField for Then<T, S>
where
S: StoreField,

View File

@ -8,6 +8,7 @@ edition.workspace = true
proc-macro = true
[dependencies]
convert_case = "0.6"
proc-macro-error = "1.0"
proc-macro2 = "1.0"
quote = "1.0"

View File

@ -1,12 +1,13 @@
use proc_macro2::Span;
use convert_case::{Case, Casing};
use proc_macro2::{Span, TokenStream};
use proc_macro_error::{abort, abort_call_site, proc_macro_error};
use quote::{quote, ToTokens};
use syn::{
parse::{Parse, ParseStream, Parser},
punctuated::Punctuated,
token::Comma,
Field, Generics, Ident, Index, Meta, Result, Token, Type, Visibility,
WhereClause,
Field, Fields, Generics, Ident, Index, Meta, Result, Token, Type, Variant,
Visibility, WhereClause,
};
#[proc_macro_error]
@ -26,20 +27,23 @@ pub fn derive_patch(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
}
struct Model {
pub vis: Visibility,
pub struct_name: Ident,
pub generics: Generics,
pub fields: Vec<Field>,
vis: Visibility,
name: Ident,
generics: Generics,
ty: ModelTy,
}
enum ModelTy {
Struct { fields: Vec<Field> },
Enum { variants: Vec<Variant> },
}
impl Parse for Model {
fn parse(input: ParseStream) -> Result<Self> {
let input = syn::DeriveInput::parse(input)?;
let syn::Data::Struct(s) = input.data else {
abort_call_site!("only structs can be used with `Store`");
};
let ty = match input.data {
syn::Data::Struct(s) => {
let fields = match s.fields {
syn::Fields::Unit => {
abort!(s.semi_token, "unit structs are not supported");
@ -52,11 +56,23 @@ impl Parse for Model {
}
};
ModelTy::Struct { fields }
}
syn::Data::Enum(e) => ModelTy::Enum {
variants: e.variants.into_iter().collect(),
},
_ => {
abort_call_site!(
"only structs and enums can be used with `Store`"
);
}
};
Ok(Self {
vis: input.vis,
struct_name: input.ident,
generics: input.generics,
fields,
name: input.ident,
ty,
})
}
}
@ -81,6 +97,164 @@ impl Parse for SubfieldMode {
}
}
impl ToTokens for Model {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let library_path = quote! { reactive_stores };
let Model {
vis,
name,
generics,
ty,
} = &self;
let any_store_field = Ident::new("AnyStoreField", Span::call_site());
let trait_name = Ident::new(&format!("{name}StoreFields"), name.span());
let generics_with_orig = {
let params = &generics.params;
quote! { <#any_store_field, #params> }
};
let where_with_orig = {
generics
.where_clause
.as_ref()
.map(|w| {
let WhereClause {
where_token,
predicates,
} = &w;
quote! {
#where_token
#any_store_field: #library_path::StoreField<Value = #name #generics>,
#predicates
}
})
.unwrap_or_else(|| quote! { where #any_store_field: #library_path::StoreField<Value = #name #generics> })
};
// define an extension trait that matches this struct
// and implement that trait for all StoreFields
let (trait_fields, read_fields): (Vec<_>, Vec<_>) =
ty.to_field_data(&library_path, generics, &any_store_field, name);
// read access
tokens.extend(quote! {
#vis trait #trait_name <AnyStoreField>
#where_with_orig
{
#(#trait_fields)*
}
impl #generics_with_orig #trait_name <AnyStoreField> for AnyStoreField
#where_with_orig
{
#(#read_fields)*
}
});
}
}
impl ModelTy {
fn to_field_data(
&self,
library_path: &TokenStream,
generics: &Generics,
any_store_field: &Ident,
name: &Ident,
) -> (Vec<TokenStream>, Vec<TokenStream>) {
match self {
ModelTy::Struct { fields } => fields
.iter()
.enumerate()
.map(|(idx, field)| {
let Field {
ident, ty, attrs, ..
} = &field;
let modes = attrs
.iter()
.find_map(|attr| {
attr.meta.path().is_ident("store").then(|| {
match &attr.meta {
Meta::List(list) => {
match Punctuated::<
SubfieldMode,
Comma,
>::parse_terminated
.parse2(list.tokens.clone())
{
Ok(modes) => Some(
modes
.iter()
.cloned()
.collect::<Vec<_>>(),
),
Err(e) => abort!(list, e),
}
}
_ => None,
}
})
})
.flatten();
(
field_to_tokens(
idx,
false,
modes.as_deref(),
library_path,
ident.as_ref(),
generics,
any_store_field,
name,
ty,
),
field_to_tokens(
idx,
true,
modes.as_deref(),
library_path,
ident.as_ref(),
generics,
any_store_field,
name,
ty,
),
)
})
.unzip(),
ModelTy::Enum { variants } => variants
.iter()
.enumerate()
.map(|(idx, variant)| {
let Variant { ident, fields, .. } = variant;
(
variant_to_tokens(
idx,
false,
library_path,
ident,
generics,
any_store_field,
name,
fields,
),
variant_to_tokens(
idx,
true,
library_path,
ident,
generics,
any_store_field,
name,
fields,
),
)
})
.unzip(),
}
}
}
#[allow(clippy::too_many_arguments)]
fn field_to_tokens(
idx: usize,
@ -90,7 +264,7 @@ fn field_to_tokens(
orig_ident: Option<&Ident>,
generics: &Generics,
any_store_field: &Ident,
struct_name: &Ident,
name: &Ident,
ty: &Type,
) -> proc_macro2::TokenStream {
let ident = if orig_ident.is_none() {
@ -113,7 +287,7 @@ fn field_to_tokens(
// TODO keyed_by
let SubfieldMode::Keyed(_keyed_by, key_ty) = mode;
let signature = quote! {
fn #ident(self) -> #library_path::KeyedField<#any_store_field, #struct_name #generics, #ty, #key_ty>
fn #ident(self) -> #library_path::KeyedField<#any_store_field, #name #generics, #ty, #key_ty>
};
return if include_body {
quote! {
@ -137,7 +311,7 @@ fn field_to_tokens(
// default subfield
if include_body {
quote! {
fn #ident(self) -> #library_path::Subfield<#any_store_field, #struct_name #generics, #ty> {
fn #ident(self) -> #library_path::Subfield<#any_store_field, #name #generics, #ty> {
#library_path::Subfield::new(
self,
#idx.into(),
@ -148,93 +322,59 @@ fn field_to_tokens(
}
} else {
quote! {
fn #ident(self) -> #library_path::Subfield<#any_store_field, #struct_name #generics, #ty>;
fn #ident(self) -> #library_path::Subfield<#any_store_field, #name #generics, #ty>;
}
}
}
impl ToTokens for Model {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let library_path = quote! { reactive_stores };
let Model {
vis,
struct_name,
generics,
fields,
} = &self;
let any_store_field = Ident::new("AnyStoreField", Span::call_site());
let trait_name = Ident::new(
&format!("{struct_name}StoreFields"),
struct_name.span(),
);
let generics_with_orig = {
let params = &generics.params;
quote! { <#any_store_field, #params> }
};
let where_with_orig = {
generics
.where_clause
.as_ref()
.map(|w| {
let WhereClause {
where_token,
predicates,
} = &w;
#[allow(clippy::too_many_arguments)]
fn variant_to_tokens(
idx: usize,
include_body: bool,
library_path: &proc_macro2::TokenStream,
ident: &Ident,
generics: &Generics,
any_store_field: &Ident,
name: &Ident,
fields: &Fields,
) -> proc_macro2::TokenStream {
// the method name will always be the snake_cased ident
let orig_ident = &ident;
let ident =
Ident::new(&ident.to_string().to_case(Case::Snake), ident.span());
match fields {
// For unit enum fields, we will just return a `bool` subfield, which is
// true when this field matches
Fields::Unit => {
// default subfield
if include_body {
quote! {
#where_token
#any_store_field: #library_path::StoreField<Value = #struct_name #generics>,
#predicates
}
})
.unwrap_or_else(|| quote! { where #any_store_field: #library_path::StoreField<Value = #struct_name #generics> })
};
// define an extension trait that matches this struct
let all_field_data = fields.iter().enumerate().map(|(idx, field)| {
let Field { ident, ty, attrs, .. } = &field;
let modes = attrs.iter().find_map(|attr| {
attr.meta.path().is_ident("store").then(|| {
match &attr.meta {
Meta::List(list) => {
match Punctuated::<SubfieldMode, Comma>::parse_terminated.parse2(list.tokens.clone()) {
Ok(modes) => Some(modes.iter().cloned().collect::<Vec<_>>()),
Err(e) => abort!(list, e)
}
fn #ident(&self) -> bool {
match #library_path::StoreField::reader(self) {
Some(reader) => {
let path = #library_path::StoreField::path(self).into_iter().collect();
let trigger = #library_path::StoreField::get_trigger(self, path);
trigger.track();
matches!(&*reader, #name::#orig_ident)
},
_ => None
None => false
}
})
}).flatten();
(
field_to_tokens(idx, false, modes.as_deref(), &library_path, ident.as_ref(), generics, &any_store_field, struct_name, ty),
field_to_tokens(idx, true, modes.as_deref(), &library_path, ident.as_ref(), generics, &any_store_field, struct_name, ty),
)
});
// implement that trait for all StoreFields
let (trait_fields, read_fields): (Vec<_>, Vec<_>) =
all_field_data.unzip();
// read access
tokens.extend(quote! {
#vis trait #trait_name <AnyStoreField>
#where_with_orig
{
#(#trait_fields)*
}
impl #generics_with_orig #trait_name <AnyStoreField> for AnyStoreField
#where_with_orig
{
#(#read_fields)*
}
});
} else {
quote! {
fn #ident(&self) -> bool;
}
}
}
Fields::Named(_) => todo!(),
Fields::Unnamed(_) => todo!(),
}
}
struct PatchModel {
pub struct_name: Ident,
pub name: Ident,
pub generics: Generics,
pub fields: Vec<Field>,
}
@ -260,7 +400,7 @@ impl Parse for PatchModel {
};
Ok(Self {
struct_name: input.ident,
name: input.ident,
generics: input.generics,
fields,
})
@ -271,7 +411,7 @@ impl ToTokens for PatchModel {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let library_path = quote! { reactive_stores };
let PatchModel {
struct_name,
name,
generics,
fields,
} = &self;
@ -294,7 +434,7 @@ impl ToTokens for PatchModel {
// read access
tokens.extend(quote! {
impl #library_path::PatchField for #struct_name #generics
impl #library_path::PatchField for #name #generics
{
fn patch_field(
&mut self,