From 65c315f369e3cd5b5ccc68860e78c0269485c26c Mon Sep 17 00:00:00 2001 From: Sergii Mikhtoniuk Date: Fri, 6 Dec 2024 18:56:47 -0800 Subject: [PATCH] Add Lazy spec and catalog scopes --- CHANGELOG.md | 8 +++ Cargo.lock | 1 + README.md | 5 +- dill-impl/src/lib.rs | 12 ++++ dill-impl/src/types.rs | 34 +++++++++++ dill/Cargo.toml | 13 +++++ dill/src/builder.rs | 14 ++--- dill/src/catalog.rs | 68 ++++++++++++++++++++++ dill/src/catalog_builder.rs | 2 +- dill/src/lazy.rs | 26 +++++++++ dill/src/lib.rs | 26 ++++----- dill/src/specs.rs | 36 ++++++++++++ dill/tests/tests/test_catalog.rs | 41 +++++++++++++ dill/tests/tests/test_specs.rs | 99 ++++++++++++++++++++++++++++++++ 14 files changed, 360 insertions(+), 25 deletions(-) create mode 100644 dill/src/lazy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 006f325..4766355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- `Catalog::scope` and `Catalog::current` allow setting and accessing a "current" catalog in a task-local context + - Requires new `tokio` crate feature +- `Lazy` injection spec that delays the creation of a value until it's requested + - Can be used to delay initialization of expensive values that are rarely used + - Can be used in combination with `Catalog::scope` to inject values registered dynamically + ## [0.9.3] - 2024-12-06 ### Changed - Upgraded to `thiserror v2` dependency diff --git a/Cargo.lock b/Cargo.lock index 63568a8..5f82494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,7 @@ dependencies = [ "dill-impl", "multimap", "thiserror", + "tokio", ] [[package]] diff --git a/README.md b/README.md index bdcfc5e..49291cb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ assert_eq!(inst.test(), "aimpl::bimpl"); - Injection specs: - `OneOf` - expects a single implementation of a given interface - `AllOf` - returns a collection of all implementations on a given interface - - `Maybe` - Returns `None` if inner `Spec` cannot be resolved + - `Maybe` - returns `None` if inner `Spec` cannot be resolved + - `Lazy` - injects an object that delays the creation of value until it is requested - Component scopes: - `Transient` (default) - a new instance is created for every invocation - `Singleton` - an instance is created upon first use and then reused for the rest of calls @@ -89,6 +90,7 @@ assert_eq!(inst.test(), "aimpl::bimpl"); - By value injection of `Clone` types - `Catalog` can be self-injected - Chaining of `Catalog`s allows adding values dynamically (e.g. in middleware chains like `tower`) +- `Catalog` can be scoped within a `tokio` task as "current" to override the source of `Lazy`ly injected values # Design Principles @@ -127,7 +129,6 @@ assert_eq!(inst.test(), "aimpl::bimpl"); - task - catalog? - thread safety -- lazy values - externally defined types - custom builders - error handling diff --git a/dill-impl/src/lib.rs b/dill-impl/src/lib.rs index 9a3d53b..ccf35de 100644 --- a/dill-impl/src/lib.rs +++ b/dill-impl/src/lib.rs @@ -362,6 +362,12 @@ fn implement_arg( } _ => unimplemented!("Currently only Option> is supported"), }, + InjectionType::Lazy { element } => match element.as_ref() { + InjectionType::Arc { inner } => { + quote! { ::dill::specs::Lazy::<::dill::OneOf::<#inner>>::check(cat) } + } + _ => unimplemented!("Currently only Option> is supported"), + }, InjectionType::Vec { item } => match item.as_ref() { InjectionType::Arc { inner } => quote! { ::dill::AllOf::<#inner>::check(cat) }, _ => unimplemented!("Currently only Vec> is supported"), @@ -387,6 +393,12 @@ fn implement_arg( } _ => unimplemented!("Currently only Option> is supported"), }, + InjectionType::Lazy { element } => match element.as_ref() { + InjectionType::Arc { inner } => { + quote! { ::dill::specs::Lazy::<::dill::OneOf::<#inner>>::get(cat)? } + } + _ => unimplemented!("Currently only Lazy> is supported"), + }, InjectionType::Vec { item } => match item.as_ref() { InjectionType::Arc { inner } => quote! { ::dill::AllOf::<#inner>::get(cat)? }, _ => unimplemented!("Currently only Vec> is supported"), diff --git a/dill-impl/src/types.rs b/dill-impl/src/types.rs index 9c3bf6b..234e7f4 100644 --- a/dill-impl/src/types.rs +++ b/dill-impl/src/types.rs @@ -7,6 +7,7 @@ pub(crate) enum InjectionType { Reference { inner: syn::Type }, Option { element: Box }, Vec { item: Box }, + Lazy { element: Box }, Value { typ: syn::Type }, } @@ -27,6 +28,10 @@ pub(crate) fn deduce_injection_type(typ: &syn::Type) -> InjectionType { InjectionType::Vec { item: Box::new(deduce_injection_type(&get_vec_item_type(typ))), } + } else if is_lazy(typ) { + InjectionType::Lazy { + element: Box::new(deduce_injection_type(&get_lazy_element_type(typ))), + } } else { InjectionType::Value { typ: typ.clone() } } @@ -107,6 +112,35 @@ pub(crate) fn get_option_element_type(typ: &syn::Type) -> syn::Type { ///////////////////////////////////////////////////////////////////////////////////////// +pub(crate) fn is_lazy(typ: &syn::Type) -> bool { + let syn::Type::Path(typepath) = typ else { + return false; + }; + + if typepath.qself.is_some() || typepath.path.segments.len() != 1 { + return false; + } + + &typepath.path.segments[0].ident == "Lazy" +} + +pub(crate) fn get_lazy_element_type(typ: &syn::Type) -> syn::Type { + let syn::Type::Path(typepath) = typ else { + panic!("Type is not an Option") + }; + + assert!(typepath.qself.is_none()); + assert_eq!(typepath.path.segments.len(), 1); + assert_eq!(&typepath.path.segments[0].ident, "Lazy"); + + let syn::PathArguments::AngleBracketed(args) = &typepath.path.segments[0].arguments else { + panic!("No generic type specifier found in Lazy") + }; + syn::parse2(args.args.to_token_stream()).unwrap() +} + +///////////////////////////////////////////////////////////////////////////////////////// + pub(crate) fn is_vec(typ: &syn::Type) -> bool { let syn::Type::Path(typepath) = typ else { return false; diff --git a/dill/Cargo.toml b/dill/Cargo.toml index 6e284ce..e363168 100644 --- a/dill/Cargo.toml +++ b/dill/Cargo.toml @@ -12,7 +12,20 @@ keywords = { workspace = true } include = { workspace = true } edition = { workspace = true } + +[features] +default = [] +tokio = ["dep:tokio"] + + [dependencies] dill-impl = { workspace = true } thiserror = "2" multimap = "0.10" + +# Optional +tokio = { optional = true, version = "1", default-features = false } + + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/dill/src/builder.rs b/dill/src/builder.rs index 041a600..31fd80f 100644 --- a/dill/src/builder.rs +++ b/dill/src/builder.rs @@ -235,27 +235,27 @@ where ///////////////////////////////////////////////////////////////////////////////////////// -pub(crate) struct Lazy +pub(crate) struct LazyBuilder where Fct: FnOnce() -> Impl, Impl: 'static + Send + Sync, { - state: Mutex>, + state: Mutex>, } -struct LazyState { +struct LazyBuilderState { factory: Option, instance: Option>, } -impl Lazy +impl LazyBuilder where Fct: FnOnce() -> Impl, Impl: 'static + Send + Sync, { pub fn new(factory: Fct) -> Self { Self { - state: Mutex::new(LazyState { + state: Mutex::new(LazyBuilderState { factory: Some(factory), instance: None, }), @@ -263,7 +263,7 @@ where } } -impl Builder for Lazy +impl Builder for LazyBuilder where Fct: FnOnce() -> Impl + Send + Sync, Impl: 'static + Send + Sync, @@ -289,7 +289,7 @@ where } } -impl TypedBuilder for Lazy +impl TypedBuilder for LazyBuilder where Fct: FnOnce() -> Impl + Send + Sync, Impl: 'static + Send + Sync, diff --git a/dill/src/catalog.rs b/dill/src/catalog.rs index 11b9857..38d163c 100644 --- a/dill/src/catalog.rs +++ b/dill/src/catalog.rs @@ -108,4 +108,72 @@ impl Catalog { { OneOf::::get(self) } + + /// Sets this catalog as "current" in the async task scope for the duration + /// of the provided coroutine. + /// + /// Most useful when used in combination with [`crate::lazy::Lazy`] and + /// [`Self::builder_chained()`] for dynamically registering additional + /// types. + /// + /// Scopes can be nested - at the end of the inner scope the catalog from an + /// outer scope will be restored as "current". + /// + /// ### Examples + /// + /// ``` + /// use dill::*; + /// use tokio::runtime::Runtime; + /// + /// Runtime::new().unwrap().block_on(async { + /// let cat = Catalog::builder().add_value(String::from("test")).build(); + /// + /// cat.scope(async move { + /// let val = Catalog::current().get_one::().unwrap(); + /// assert_eq!(val.as_str(), "test"); + /// }).await; + /// }) + /// ``` + #[cfg(feature = "tokio")] + pub async fn scope(&self, f: F) -> R + where + F: std::future::Future, + { + CURRENT_CATALOG.scope(self.clone(), f).await + } + + /// Allows accessing the catalog in the current [`Self::scope`]. + /// + /// Note that you should very rarely be using this method directly if at + /// all. Instead you should rely on [`crate::lazy::Lazy`] for + /// delayed injection from a current catalog. + /// + /// ### Panics + /// + /// Will panic if called from the outside of a [`Self::scope`]. + /// + /// ### Examples + /// + /// ``` + /// use dill::*; + /// use tokio::runtime::Runtime; + /// + /// Runtime::new().unwrap().block_on(async { + /// let cat = Catalog::builder().add_value(String::from("test")).build(); + /// + /// cat.scope(async move { + /// let val = Catalog::current().get_one::().unwrap(); + /// assert_eq!(val.as_str(), "test"); + /// }).await; + /// }) + /// ``` + #[cfg(feature = "tokio")] + pub fn current() -> Catalog { + CURRENT_CATALOG.get() + } +} + +#[cfg(feature = "tokio")] +tokio::task_local! { + pub(crate) static CURRENT_CATALOG: Catalog; } diff --git a/dill/src/catalog_builder.rs b/dill/src/catalog_builder.rs index 4e1cdc2..f1cd521 100644 --- a/dill/src/catalog_builder.rs +++ b/dill/src/catalog_builder.rs @@ -97,7 +97,7 @@ impl CatalogBuilder { Fct: FnOnce() -> Impl + Send + Sync + 'static, Impl: Send + Sync + 'static, { - self.add_builder(Lazy::new(factory)); + self.add_builder(LazyBuilder::new(factory)); self } diff --git a/dill/src/lazy.rs b/dill/src/lazy.rs new file mode 100644 index 0000000..43e7e8e --- /dev/null +++ b/dill/src/lazy.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use crate::InjectionError; + +#[derive(Clone)] +pub struct Lazy { + factory: Arc Result + Send + Sync>, +} + +impl std::fmt::Debug for Lazy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Lazy").finish_non_exhaustive() + } +} + +impl Lazy { + pub fn new(f: impl Fn() -> Result + Send + Sync + 'static) -> Self { + Self { + factory: Arc::new(f), + } + } + + pub fn get(&self) -> Result { + (self.factory)() + } +} diff --git a/dill/src/lib.rs b/dill/src/lib.rs index 67e759e..4d44b79 100644 --- a/dill/src/lib.rs +++ b/dill/src/lib.rs @@ -164,25 +164,21 @@ //! assert_eq!(inst.url(), "http://foo:8080"); //! ``` -pub use dill_impl::*; - mod builder; -pub use builder::*; - +mod catalog; mod catalog_builder; -pub use catalog_builder::*; +mod errors; +mod lazy; +mod scopes; +pub mod specs; +mod typecast_builder; -mod catalog; +pub use builder::*; pub use catalog::*; - -mod errors; +pub use catalog_builder::*; +pub use dill_impl::*; pub use errors::*; - -mod specs; -pub use specs::*; - -mod scopes; +pub use lazy::Lazy; pub use scopes::*; - -mod typecast_builder; +pub use specs::*; pub use typecast_builder::*; diff --git a/dill/src/specs.rs b/dill/src/specs.rs index 5136f59..2310008 100644 --- a/dill/src/specs.rs +++ b/dill/src/specs.rs @@ -130,3 +130,39 @@ impl DependencySpec for Maybe { } } } + +///////////////////////////////////////////////////////////////////////////////////////// +// Lazy +///////////////////////////////////////////////////////////////////////////////////////// + +pub struct Lazy { + _dummy: PhantomData, +} + +impl DependencySpec for Lazy { + type ReturnType = crate::lazy::Lazy; + + #[cfg(not(feature = "tokio"))] + fn get(cat: &Catalog) -> Result { + let cat = cat.clone(); + Ok(crate::lazy::Lazy::new(move || Inner::get(&cat))) + } + + #[cfg(feature = "tokio")] + fn get(cat: &Catalog) -> Result { + // Lazy will store the clone of a catalog it was initially created with + // It will however first attempt to resolve a current catalog if scope feature + // is used and only use the former as a fallback. + let fallback_cat = cat.clone(); + Ok(crate::lazy::Lazy::new(move || match crate::CURRENT_CATALOG + .try_with(|cat| Inner::get(cat)) + { + Ok(v) => v, + Err(_) => Inner::get(&fallback_cat), + })) + } + + fn check(cat: &Catalog) -> Result<(), InjectionError> { + Inner::check(cat) + } +} diff --git a/dill/tests/tests/test_catalog.rs b/dill/tests/tests/test_catalog.rs index 157ab1b..b76cb81 100644 --- a/dill/tests/tests/test_catalog.rs +++ b/dill/tests/tests/test_catalog.rs @@ -199,3 +199,44 @@ fn test_chained_catalog_binds() { let inst_later_a = cat_later.get_one::().unwrap(); assert_eq!(inst_later_a.test(), "aimpl::bimpl::foo"); } + +#[cfg(feature = "tokio")] +#[tokio::test] +async fn test_catalog_scope() { + use std::assert_matches::assert_matches; + + let cat1 = Catalog::builder().add_value(1i32).build(); + + let cat = cat1.clone(); + let proof = cat + .scope(async move { + // Get value from the current scope + let l1_before = Catalog::current().get_one::().unwrap(); + assert_eq!(*l1_before.as_ref(), 1); + + // Nested scope with and additional registered value + let cat2 = cat1.builder_chained().add_value(String::from("2")).build(); + let proof = cat2 + .scope(async move { + let l2 = Catalog::current().get_one::().unwrap(); + assert_eq!(l2.as_str(), "2"); + l2 + }) + .await; + + // Check the scope was restored to cat1 + let l1_after = Catalog::current().get_one::().unwrap(); + assert_eq!(*l1_after.as_ref(), 1); + assert_matches!( + Catalog::current().get_one::(), + Err(InjectionError::Unregistered(_)) + ); + + proof + }) + .await; + + // This check is to ensure that all lambdas were actually executed and not + // skipped + assert_eq!(proof.as_str(), "2"); +} diff --git a/dill/tests/tests/test_specs.rs b/dill/tests/tests/test_specs.rs index 419d928..294f081 100644 --- a/dill/tests/tests/test_specs.rs +++ b/dill/tests/tests/test_specs.rs @@ -282,3 +282,102 @@ fn test_maybe_derive() { assert_matches!(cat.get_one::().unwrap().maybe_a, Some(_)); } + +#[test] +fn test_lazy_simple() { + trait A: std::fmt::Debug + Send + Sync { + fn test(&self) -> String; + } + + #[component] + #[derive(Debug)] + struct AImpl; + impl A for AImpl { + fn test(&self) -> String { + "A".into() + } + } + + let cat = Catalog::builder() + .add::() + .bind::() + .build(); + + let lazy_a = cat.get::>>().unwrap(); + let a = lazy_a.get().unwrap(); + assert_eq!(a.test(), "A"); +} + +#[cfg(feature = "tokio")] +#[tokio::test] +async fn test_lazy_scoped() { + trait A: std::fmt::Debug + Send + Sync { + fn test(&self) -> String; + } + + #[component] + #[derive(Debug)] + struct AImpl; + impl A for AImpl { + fn test(&self) -> String { + "A".into() + } + } + + let cat = Catalog::builder().build(); + + let lazy_a = cat.get::>>().unwrap(); + assert_matches!(lazy_a.get(), Err(InjectionError::Unregistered(_))); + + let cat2 = cat + .builder_chained() + .add::() + .bind::() + .build(); + + let test = cat2 + .scope(async move { + let a = lazy_a.get().unwrap(); + a.test() + }) + .await; + + assert_eq!(test, "A"); +} + +#[test] +fn test_lazy_derive() { + trait A: std::fmt::Debug + Send + Sync { + fn test(&self) -> String; + } + + #[component] + #[derive(Debug)] + struct AImpl; + impl A for AImpl { + fn test(&self) -> String { + "A".into() + } + } + + #[component] + #[derive(Debug)] + struct BImpl { + lazy_a: Lazy>, + } + impl BImpl { + fn test(&self) -> String { + let a = self.lazy_a.get().unwrap(); + a.test() + } + } + + let cat = Catalog::builder() + .add::() + .bind::() + .add::() + .build(); + + let b = cat.get_one::().unwrap(); + assert_eq!(b.test(), "A"); +}