From 42ef30b30a20f8a7e3ee8984e75ffee8285c4aa4 Mon Sep 17 00:00:00 2001 From: Vasily Zorin Date: Thu, 5 Feb 2026 23:45:49 +0700 Subject: [PATCH] feat(class): Class structs can now contain other class structs #182 --- crates/macros/src/lib.rs | 205 +++++++++++++++++++++++++- crates/macros/src/php_clone.rs | 39 +++++ guide/src/macros/classes.md | 114 ++++++++++++++ src/error.rs | 14 ++ src/lib.rs | 8 +- src/macros.rs | 122 +++++++++++++++ tests/src/integration/class/class.php | 52 +++++++ tests/src/integration/class/mod.rs | 41 ++++++ 8 files changed, 590 insertions(+), 5 deletions(-) create mode 100644 crates/macros/src/php_clone.rs diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index 10ab173896..6dd6cc6794 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -12,6 +12,7 @@ mod impl_interface; mod interface; mod module; mod parsing; +mod php_clone; mod syn_ext; mod zval; @@ -398,6 +399,122 @@ extern crate proc_macro; /// echo Counter::getCount(); // 2 /// ``` /// +/// ## Using Classes as Properties +/// +/// By default, `#[php_class]` types cannot be used directly as properties of +/// other `#[php_class]` types because they don't implement `FromZval`. To +/// enable this, derive `PhpClone` on any class that needs to be used as a +/// property. +/// +/// The class must implement `Clone`, and deriving `PhpClone` will implement +/// `FromZval` and `FromZendObject` for the type, allowing PHP objects to be +/// cloned into Rust values. +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// +/// // Inner class that will be used as a property +/// #[php_class] +/// #[derive(Clone, PhpClone)] // PhpClone enables use as a property +/// pub struct Address { +/// #[php(prop)] +/// pub street: String, +/// #[php(prop)] +/// pub city: String, +/// } +/// +/// #[php_impl] +/// impl Address { +/// pub fn __construct(street: String, city: String) -> Self { +/// Self { street, city } +/// } +/// } +/// +/// // Outer class containing the inner class as a property +/// #[php_class] +/// pub struct Person { +/// #[php(prop)] +/// pub name: String, +/// #[php(prop)] +/// pub address: Address, // Works because Address derives PhpClone +/// } +/// +/// #[php_impl] +/// impl Person { +/// pub fn __construct(name: String, address: Address) -> Self { +/// Self { name, address } +/// } +/// +/// pub fn get_city(&self) -> String { +/// self.address.city.clone() +/// } +/// } +/// +/// #[php_module] +/// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +/// module +/// .class::
() +/// .class::() +/// } +/// ``` +/// +/// From PHP: +/// +/// ```php +/// name; // "John Doe" +/// echo $person->address->city; // "Springfield" +/// echo $person->getCity(); // "Springfield" +/// +/// // You can also set the nested property +/// $newAddress = new Address("456 Oak Ave", "Shelbyville"); +/// $person->address = $newAddress; +/// echo $person->address->city; // "Shelbyville" +/// ``` +/// +/// ### Clone Semantics +/// +/// When reading a property that uses `PhpClone`, PHP receives a **clone** of +/// the Rust value. This has important implications: +/// +/// ```php +/// $address = new Address("123 Main St", "Springfield"); +/// $person = new Person("John Doe", $address); +/// +/// // Reading $person->address returns a CLONE +/// $addressCopy = $person->address; +/// $addressCopy->city = "Modified City"; +/// +/// // The original is unchanged because $addressCopy is a clone +/// echo $person->address->city; // Still "Springfield" +/// +/// // To modify the original, you must reassign the property +/// $person->address = $addressCopy; +/// echo $person->address->city; // Now "Modified City" +/// ``` +/// +/// ### Rc/Arc Considerations +/// +/// If your type contains `Rc`, `Arc`, or other reference-counted smart +/// pointers, cloning will create a new handle that **shares** the underlying +/// data with the original. This means mutations through the shared reference +/// will affect both the original and the clone. +/// +/// **Important notes:** +/// +/// - The inner class must derive both `Clone` and `PhpClone` +/// - When accessed from PHP, the property returns a clone of the Rust value +/// - Modifications to the returned object don't affect the original unless +/// reassigned +/// - Types with `Rc`/`Arc` will share interior data after cloning +/// +/// See [GitHub issue #182](https://github.com/extphprs/ext-php-rs/issues/182) +/// for more context. +/// /// ## Abstract Classes /// /// Abstract classes cannot be instantiated directly and may contain abstract @@ -2316,6 +2433,88 @@ fn zval_convert_derive_internal(input: TokenStream2) -> TokenStream2 { zval::parser(input).unwrap_or_else(|e| e.to_compile_error()) } +/// # `PhpClone` Derive Macro +/// +/// Derives [`FromZendObject`] and [`FromZval`] for **owned** (non-reference) +/// types that implement [`Clone`] and [`RegisteredClass`]. This enables using +/// `#[php_class]` structs as properties of other `#[php_class]` structs. +/// +/// ## Important: Clone Semantics +/// +/// This macro creates a **clone** of the PHP object's underlying Rust data when +/// reading the property. This has important implications: +/// +/// - **Reading** the property returns a cloned copy of the data +/// - **Writing** to the cloned object will NOT modify the original PHP object +/// - Each read creates a new independent clone +/// +/// If you need to modify the original object, you should use methods on the +/// parent class that directly access the inner object, rather than reading +/// the property and modifying the clone. +/// +/// ## Rc/Arc Considerations +/// +/// If your type contains [`Rc`], [`Arc`], or other reference-counted smart +/// pointers, be aware that cloning will create a new handle that shares the +/// underlying data with the original. This means: +/// +/// - Mutations through the shared reference WILL affect both the original and +/// clone +/// - The reference count will be incremented +/// - This may lead to unexpected shared state between PHP objects +/// +/// Consider using deep cloning strategies if you need complete isolation. +/// +/// [`Rc`]: std::rc::Rc +/// [`Arc`]: std::sync::Arc +/// [`FromZendObject`]: ext_php_rs::convert::FromZendObject +/// [`FromZval`]: ext_php_rs::convert::FromZval +/// [`RegisteredClass`]: ext_php_rs::class::RegisteredClass +/// +/// ## Example +/// +/// ```rust,ignore +/// use ext_php_rs::prelude::*; +/// +/// #[php_class] +/// #[derive(Clone, PhpClone)] +/// struct Bar { +/// #[php(prop)] +/// value: String, +/// } +/// +/// #[php_class] +/// struct Foo { +/// #[php(prop)] +/// bar: Bar, // Now works because Bar implements FromZval via PhpClone +/// } +/// ``` +/// +/// PHP usage demonstrating clone semantics: +/// ```php +/// $bar = new Bar("original"); +/// $foo = new Foo($bar); +/// +/// // Reading $foo->bar returns a clone +/// $barCopy = $foo->bar; +/// $barCopy->value = "modified"; +/// +/// // Original is unchanged because $barCopy is a clone +/// echo $foo->bar->value; // Outputs: "original" +/// ``` +/// +/// See: +#[proc_macro_derive(PhpClone)] +pub fn php_clone_derive(input: TokenStream) -> TokenStream { + php_clone_derive_internal(input.into()).into() +} + +fn php_clone_derive_internal(input: TokenStream2) -> TokenStream2 { + let input = parse_macro_input2!(input as DeriveInput); + + php_clone::parser(input) +} + /// Defines an `extern` function with the Zend fastcall convention based on /// operating system. /// @@ -2500,10 +2699,14 @@ mod tests { } fn runtime_expand_derive(path: &PathBuf) { + type DeriveFn = fn(TokenStream2) -> TokenStream2; let file = std::fs::File::open(path).expect("Failed to open expand test file"); runtime_macros::emulate_derive_macro_expansion( file, - &[("ZvalConvert", zval_convert_derive_internal)], + &[ + ("ZvalConvert", zval_convert_derive_internal as DeriveFn), + ("PhpClone", php_clone_derive_internal as DeriveFn), + ], ) .expect("Failed to expand derive macros in test file"); } diff --git a/crates/macros/src/php_clone.rs b/crates/macros/src/php_clone.rs new file mode 100644 index 0000000000..2ffdf7b84d --- /dev/null +++ b/crates/macros/src/php_clone.rs @@ -0,0 +1,39 @@ +//! Implementation for the `#[derive(PhpClone)]` macro. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +/// Parses the derive input and generates the trait implementations for +/// cloneable PHP classes. +pub fn parser(input: DeriveInput) -> TokenStream { + let DeriveInput { ident, .. } = input; + + quote! { + impl ::ext_php_rs::convert::FromZendObject<'_> for #ident { + fn from_zend_object( + obj: &::ext_php_rs::types::ZendObject, + ) -> ::ext_php_rs::error::Result { + let class_obj = + ::ext_php_rs::types::ZendClassObject::<#ident>::from_zend_obj(obj) + .ok_or(::ext_php_rs::error::Error::ZendClassObjectExtraction)?; + ::ext_php_rs::error::Result::Ok((**class_obj).clone()) + } + } + + impl ::ext_php_rs::convert::FromZval<'_> for #ident { + const TYPE: ::ext_php_rs::flags::DataType = ::ext_php_rs::flags::DataType::Object( + ::std::option::Option::Some( + <#ident as ::ext_php_rs::class::RegisteredClass>::CLASS_NAME, + ), + ); + + fn from_zval( + zval: &::ext_php_rs::types::Zval, + ) -> ::std::option::Option { + let obj = zval.object()?; + ::from_zend_object(obj).ok() + } + } + } +} diff --git a/guide/src/macros/classes.md b/guide/src/macros/classes.md index 8cc0b869a8..4b0fe69c1c 100644 --- a/guide/src/macros/classes.md +++ b/guide/src/macros/classes.md @@ -360,6 +360,120 @@ echo Counter::$count; // 2 echo Counter::getCount(); // 2 ``` +## Using Classes as Properties + +By default, `#[php_class]` types cannot be used directly as properties of other +`#[php_class]` types because they don't implement `FromZval`. To enable this, +derive `PhpClone` on any class that needs to be used as a property. + +The class must implement `Clone`, and deriving `PhpClone` will implement +`FromZval` and `FromZendObject` for the type, allowing PHP objects to be +cloned into Rust values. + +```rust,ignore +use ext_php_rs::prelude::*; + +// Inner class that will be used as a property +#[php_class] +#[derive(Clone, PhpClone)] // PhpClone enables use as a property +pub struct Address { + #[php(prop)] + pub street: String, + #[php(prop)] + pub city: String, +} + +#[php_impl] +impl Address { + pub fn __construct(street: String, city: String) -> Self { + Self { street, city } + } +} + +// Outer class containing the inner class as a property +#[php_class] +pub struct Person { + #[php(prop)] + pub name: String, + #[php(prop)] + pub address: Address, // Works because Address derives PhpClone +} + +#[php_impl] +impl Person { + pub fn __construct(name: String, address: Address) -> Self { + Self { name, address } + } + + pub fn get_city(&self) -> String { + self.address.city.clone() + } +} + +#[php_module] +pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { + module + .class::
() + .class::() +} +``` + +From PHP: + +```php +name; // "John Doe" +echo $person->address->city; // "Springfield" +echo $person->getCity(); // "Springfield" + +// You can also set the nested property +$newAddress = new Address("456 Oak Ave", "Shelbyville"); +$person->address = $newAddress; +echo $person->address->city; // "Shelbyville" +``` + +### Clone Semantics + +When reading a property that uses `PhpClone`, PHP receives a **clone** of the +Rust value. This has important implications: + +```php +$address = new Address("123 Main St", "Springfield"); +$person = new Person("John Doe", $address); + +// Reading $person->address returns a CLONE +$addressCopy = $person->address; +$addressCopy->city = "Modified City"; + +// The original is unchanged because $addressCopy is a clone +echo $person->address->city; // Still "Springfield" + +// To modify the original, you must reassign the property +$person->address = $addressCopy; +echo $person->address->city; // Now "Modified City" +``` + +### Rc/Arc Considerations + +If your type contains `Rc`, `Arc`, or other reference-counted smart pointers, +cloning will create a new handle that **shares** the underlying data with the +original. This means mutations through the shared reference will affect both +the original and the clone. + +**Important notes:** + +- The inner class must derive both `Clone` and `PhpClone` +- When accessed from PHP, the property returns a clone of the Rust value +- Modifications to the returned object don't affect the original unless reassigned +- Types with `Rc`/`Arc` will share interior data after cloning + +See [GitHub issue #182](https://github.com/extphprs/ext-php-rs/issues/182) +for more context. + ## Abstract Classes Abstract classes cannot be instantiated directly and may contain abstract methods diff --git a/src/error.rs b/src/error.rs index ab1345d295..7d65d0a527 100644 --- a/src/error.rs +++ b/src/error.rs @@ -74,6 +74,14 @@ pub enum Error { SapiWriteUnavailable, /// Failed to make an object lazy (PHP 8.4+) LazyObjectFailed, + /// Failed to extract a [`ZendClassObject`] from a [`ZendObject`]. + /// + /// This typically occurs when trying to convert a PHP object to a specific + /// Rust type, but the object is not an instance of the expected class. + /// + /// [`ZendClassObject`]: crate::types::ZendClassObject + /// [`ZendObject`]: crate::types::ZendObject + ZendClassObjectExtraction, } impl Display for Error { @@ -123,6 +131,12 @@ impl Display for Error { Error::LazyObjectFailed => { write!(f, "Failed to make the object lazy") } + Error::ZendClassObjectExtraction => { + write!( + f, + "Failed to extract ZendClassObject: object is not an instance of the expected class" + ) + } } } } diff --git a/src/lib.rs b/src/lib.rs index 5e2a83eb02..284fada77c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,8 +76,8 @@ pub mod prelude { FcallInfo, FcallObserver, }; pub use crate::{ - ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface, - php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, + PhpClone, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, + php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; } @@ -108,6 +108,6 @@ pub const PHP_85: bool = cfg!(php85); #[cfg(feature = "enum")] pub use ext_php_rs_derive::php_enum; pub use ext_php_rs_derive::{ - ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, php_impl_interface, - php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, + PhpClone, ZvalConvert, php_class, php_const, php_extern, php_function, php_impl, + php_impl_interface, php_interface, php_module, wrap_constant, wrap_function, zend_fastcall, }; diff --git a/src/macros.rs b/src/macros.rs index b63166f330..93f301be5f 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -333,6 +333,128 @@ macro_rules! class_derives { }; } +/// Derives additional traits for cloneable [`RegisteredClass`] types to enable +/// using them as properties of other `#[php_class]` structs. +/// +/// # Prefer `#[derive(PhpClone)]` +/// +/// This macro is superseded by [`#[derive(PhpClone)]`](crate::PhpClone). +/// The derive macro is more ergonomic and follows Rust conventions: +/// +/// ```ignore +/// use ext_php_rs::prelude::*; +/// +/// #[php_class] +/// #[derive(Clone, PhpClone)] // Preferred approach +/// struct Bar { +/// #[php(prop)] +/// value: String, +/// } +/// ``` +/// +/// This macro is kept for backward compatibility. +/// +/// --- +/// +/// This macro should be called for any `#[php_class]` struct that: +/// 1. Implements [`Clone`] +/// 2. Needs to be used as a property in another `#[php_class]` struct +/// +/// The macro implements [`FromZendObject`] and [`FromZval`] for the owned type, +/// allowing PHP objects to be cloned into Rust values. +/// +/// # Important: Clone Semantics +/// +/// This macro creates a **clone** of the PHP object's underlying Rust data when +/// reading the property. This has important implications: +/// +/// - **Reading** the property returns a cloned copy of the data +/// - **Writing** to the cloned object will NOT modify the original PHP object +/// - Each read creates a new independent clone +/// +/// If you need to modify the original object, you should use methods on the +/// parent class that directly access the inner object, rather than reading +/// the property and modifying the clone. +/// +/// # Rc/Arc Considerations +/// +/// If your type contains [`Rc`], [`Arc`], or other reference-counted smart +/// pointers, be aware that cloning will create a new handle that shares the +/// underlying data with the original. This means: +/// +/// - Mutations through the shared reference WILL affect both the original and clone +/// - The reference count will be incremented +/// - This may lead to unexpected shared state between PHP objects +/// +/// Consider using deep cloning strategies if you need complete isolation. +/// +/// [`Rc`]: std::rc::Rc +/// [`Arc`]: std::sync::Arc +/// +/// # Example +/// +/// ```ignore +/// use ext_php_rs::prelude::*; +/// use ext_php_rs::class_derives_clone; +/// +/// #[php_class] +/// #[derive(Clone)] +/// struct Bar { +/// #[php(prop)] +/// value: String, +/// } +/// +/// class_derives_clone!(Bar); +/// +/// #[php_class] +/// struct Foo { +/// #[php(prop)] +/// bar: Bar, // Now works because Bar implements FromZval +/// } +/// ``` +/// +/// PHP usage demonstrating clone semantics: +/// ```php +/// $bar = new Bar("original"); +/// $foo = new Foo($bar); +/// +/// // Reading $foo->bar returns a clone +/// $barCopy = $foo->bar; +/// $barCopy->value = "modified"; +/// +/// // Original is unchanged because $barCopy is a clone +/// echo $foo->bar->value; // Outputs: "original" +/// ``` +/// +/// See: +/// +/// [`RegisteredClass`]: crate::class::RegisteredClass +/// [`FromZendObject`]: crate::convert::FromZendObject +/// [`FromZval`]: crate::convert::FromZval +#[macro_export] +macro_rules! class_derives_clone { + ($type: ty) => { + impl $crate::convert::FromZendObject<'_> for $type { + fn from_zend_object(obj: &$crate::types::ZendObject) -> $crate::error::Result { + let class_obj = $crate::types::ZendClassObject::<$type>::from_zend_obj(obj) + .ok_or($crate::error::Error::ZendClassObjectExtraction)?; + Ok((**class_obj).clone()) + } + } + + impl $crate::convert::FromZval<'_> for $type { + const TYPE: $crate::flags::DataType = $crate::flags::DataType::Object(Some( + <$type as $crate::class::RegisteredClass>::CLASS_NAME, + )); + + fn from_zval(zval: &$crate::types::Zval) -> ::std::option::Option { + let obj = zval.object()?; + ::from_zend_object(obj).ok() + } + } + }; +} + /// Derives `From for Zval` and `IntoZval` for a given type. macro_rules! into_zval { ($type: ty, $fn: ident, $dt: ident) => { diff --git a/tests/src/integration/class/class.php b/tests/src/integration/class/class.php index 165e0360f8..507df7bdf7 100644 --- a/tests/src/integration/class/class.php +++ b/tests/src/integration/class/class.php @@ -329,3 +329,55 @@ public function __construct(string $data) { $childReflection = new ReflectionClass(TestChildClass::class); assert($childReflection->getParentClass()->getName() === TestBaseClass::class, 'TestChildClass should extend TestBaseClass'); assert($childObj instanceof TestBaseClass, 'TestChildClass instance should be instanceof TestBaseClass'); + +// Test issue #182 - class structs containing class struct properties +$inner = new InnerClass('hello world'); +assert($inner->getValue() === 'hello world', 'InnerClass getValue should work'); +assert($inner->value === 'hello world', 'InnerClass property should be accessible'); + +$outer = new OuterClass($inner); +assert($outer->getInnerValue() === 'hello world', 'OuterClass should be able to access inner value'); + +// Test that the inner property is properly accessible +assert($outer->inner instanceof InnerClass, 'outer->inner should be InnerClass instance'); +assert($outer->inner->value === 'hello world', 'outer->inner->value should be accessible'); + +// Test setting inner property +$newInner = new InnerClass('new value'); +$outer->inner = $newInner; +assert($outer->getInnerValue() === 'new value', 'After setting inner, value should be updated'); + +// Test clone semantics - reading returns a clone, writes don't affect original +// This is important behavior to understand when using #[derive(PhpClone)] +$inner2 = new InnerClass('clone test'); +$outer2 = new OuterClass($inner2); + +// Verify initial state +assert($outer2->inner->value === 'clone test', 'Initial inner value should be "clone test"'); + +// Get a reference to inner - this is actually a CLONE +$innerCopy = $outer2->inner; +assert($innerCopy instanceof InnerClass, 'innerCopy should be an InnerClass instance'); +assert($innerCopy->value === 'clone test', 'innerCopy should have the original value'); + +// Modify the copy's value +$innerCopy->value = 'modified by copy'; +assert($innerCopy->value === 'modified by copy', 'innerCopy should have modified value'); + +// The ORIGINAL should be unchanged because innerCopy was a clone +assert($outer2->inner->value === 'clone test', 'Original inner value should be unchanged after modifying copy'); +assert($outer2->getInnerValue() === 'clone test', 'getInnerValue should still return original value'); + +// Verify that each read creates a new clone +$copy1 = $outer2->inner; +$copy2 = $outer2->inner; +$copy1->value = 'copy1 modified'; +assert($copy1->value === 'copy1 modified', 'copy1 should have modified value'); +assert($copy2->value === 'clone test', 'copy2 should still have original value (independent clone)'); +assert($outer2->inner->value === 'clone test', 'Original should still be unchanged'); + +// To actually modify the inner object, we need to set the whole property +$modifiedInner = new InnerClass('actually modified'); +$outer2->inner = $modifiedInner; +assert($outer2->inner->value === 'actually modified', 'After setting property, value should be updated'); +assert($outer2->getInnerValue() === 'actually modified', 'getInnerValue should return new value'); diff --git a/tests/src/integration/class/mod.rs b/tests/src/integration/class/mod.rs index d1863cc469..e954067a38 100644 --- a/tests/src/integration/class/mod.rs +++ b/tests/src/integration/class/mod.rs @@ -568,6 +568,45 @@ impl TestChildClass { } } +// Test for issue #182 - class structs containing class struct properties +// The inner class must derive Clone and PhpClone + +#[php_class] +#[derive(Clone, Default, PhpClone)] +pub struct InnerClass { + #[php(prop)] + pub value: String, +} + +#[php_impl] +impl InnerClass { + pub fn __construct(value: String) -> Self { + Self { value } + } + + pub fn get_value(&self) -> String { + self.value.clone() + } +} + +#[php_class] +#[derive(Default)] +pub struct OuterClass { + #[php(prop)] + pub inner: InnerClass, +} + +#[php_impl] +impl OuterClass { + pub fn __construct(inner: InnerClass) -> Self { + Self { inner } + } + + pub fn get_inner_value(&self) -> String { + self.inner.value.clone() + } +} + pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { let builder = builder .class::() @@ -586,6 +625,8 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { .class::() .class::() .class::() + .class::() + .class::() .function(wrap_function!(test_class)) .function(wrap_function!(throw_exception));