From daf1560923c68612e94bf9fd405161578ffd9114 Mon Sep 17 00:00:00 2001 From: Xenira <1288524+Xenira@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:04:46 +0200 Subject: [PATCH] feat(array): support `Vec<(K,V)>` for hashtables Refs: #425 --- guide/src/types/hashmap.md | 97 ++++- src/types/array.rs | 363 ++++++++++++++++++ tests/src/integration/array/array.php | 18 + tests/src/integration/array/mod.rs | 12 +- .../integration/magic_method/magic_method.php | 2 +- tests/src/integration/magic_method/mod.rs | 12 +- 6 files changed, 496 insertions(+), 8 deletions(-) diff --git a/guide/src/types/hashmap.md b/guide/src/types/hashmap.md index d0003e2b2f..0769247cf1 100644 --- a/guide/src/types/hashmap.md +++ b/guide/src/types/hashmap.md @@ -14,6 +14,17 @@ numeric key, the key is represented as a string before being inserted. Converting from a `HashMap` to a zval is valid when the key implements `AsRef`, and the value implements `IntoZval`. +
+ + When using `HashMap` the order of the elements it not preserved. + + HashMaps are unordered collections, so the order of elements may not be the same + when converting from PHP to Rust and back. + + If you need to preserve the order of elements, consider using `Vec<(K, V)>` or + `Vec` instead. +
+ ## Rust example ```rust,no_run @@ -49,9 +60,93 @@ var_dump(test_hashmap([ Output: ```text -k: hello v: world k: rust v: php +k: hello v: world k: 0 v: okk +array(3) { + [0] => string(3) "php", + [1] => string(5) "world", + [2] => string(3) "okk" +} +``` + +## `Vec<(K, V)>` and `Vec` + +`Vec<(K, V)>` and `Vec` are used to represent associative arrays in PHP +where the keys can be strings or integers. + +If using `String` or `&str` as the key type, only string keys will be accepted. + +For `i64` keys, string keys that can be parsed as integers will be accepted, and +converted to `i64`. + +If you need to accept both string and integer keys, use `ArrayKey` as the key type. + +### Rust example + +```rust,no_run +# #![cfg_attr(windows, feature(abi_vectorcall))] +# extern crate ext_php_rs; +# use ext_php_rs::prelude::*; +# use ext_php_rs::types::ArrayKey; +#[php_function] +pub fn test_vec_kv(vec: Vec<(String, String)>) -> Vec { + for (k, v) in vec.iter() { + println!("k: {} v: {}", k, v); + } + + vec.into_iter() + .map(|(_, v)| v) + .collect::>() +} + +#[php_function] +pub fn test_vec_arraykey(vec: Vec<(ArrayKey, String)>) -> Vec { + for (k, v) in vec.iter() { + println!("k: {} v: {}", k, v); + } + + vec.into_iter() + .map(|(_, v)| v) + .collect::>() +} +# fn main() {} +``` + +## PHP example + +```php + string(5) "world", + [1] => string(3) "php", + [2] => string(3) "okk" +} +k: hello v: world +k: 1 v: php +k: 2 v: okk array(3) { [0] => string(5) "world", [1] => string(3) "php", diff --git a/src/types/array.rs b/src/types/array.rs index 587c7408b1..602eda7d4e 100644 --- a/src/types/array.rs +++ b/src/types/array.rs @@ -797,6 +797,30 @@ impl From for ArrayKey<'_> { } } +impl TryFrom> for String { + type Error = Error; + + fn try_from(value: ArrayKey<'_>) -> std::result::Result { + match value { + ArrayKey::String(s) => Ok(s), + ArrayKey::Str(s) => Ok(s.to_string()), + ArrayKey::Long(_) => Err(Error::InvalidProperty), + } + } +} + +impl TryFrom> for i64 { + type Error = Error; + + fn try_from(value: ArrayKey<'_>) -> std::result::Result { + match value { + ArrayKey::Long(i) => Ok(i), + ArrayKey::String(s) => s.parse::().map_err(|_| Error::InvalidProperty), + ArrayKey::Str(s) => s.parse::().map_err(|_| Error::InvalidProperty), + } + } +} + impl ArrayKey<'_> { /// Check if the key is an integer. /// @@ -1135,6 +1159,67 @@ where } } +impl<'a, K, V> TryFrom<&'a ZendHashTable> for Vec<(K, V)> +where + K: TryFrom, Error = Error>, + V: FromZval<'a>, +{ + type Error = Error; + + fn try_from(value: &'a ZendHashTable) -> Result { + let mut vec = Vec::with_capacity(value.len()); + + for (key, val) in value { + vec.push(( + key.try_into()?, + V::from_zval(val).ok_or_else(|| Error::ZvalConversion(val.get_type()))?, + )); + } + + Ok(vec) + } +} + +impl<'a, V> TryFrom<&'a ZendHashTable> for Vec<(ArrayKey<'a>, V)> +where + V: FromZval<'a>, +{ + type Error = Error; + + fn try_from(value: &'a ZendHashTable) -> Result { + let mut vec = Vec::with_capacity(value.len()); + + for (key, val) in value { + vec.push(( + key, + V::from_zval(val).ok_or_else(|| Error::ZvalConversion(val.get_type()))?, + )); + } + + Ok(vec) + } +} + +impl<'a, K, V> TryFrom> for ZBox +where + K: Into>, + V: IntoZval, +{ + type Error = Error; + + fn try_from(value: Vec<(K, V)>) -> Result { + let mut ht = ZendHashTable::with_capacity( + value.len().try_into().map_err(|_| Error::IntegerOverflow)?, + ); + + for (k, v) in value { + ht.insert(k, v)?; + } + + Ok(ht) + } +} + // TODO: Generalize hasher #[allow(clippy::implicit_hasher)] impl IntoZval for HashMap @@ -1165,6 +1250,44 @@ where } } +impl<'a, K, V> IntoZval for Vec<(K, V)> +where + K: Into>, + V: IntoZval, +{ + const TYPE: DataType = DataType::Array; + const NULLABLE: bool = false; + + fn set_zval(self, zv: &mut Zval, _: bool) -> Result<()> { + let arr = self.try_into()?; + zv.set_hashtable(arr); + Ok(()) + } +} + +impl<'a, K, V> FromZval<'a> for Vec<(K, V)> +where + K: TryFrom, Error = Error>, + V: FromZval<'a>, +{ + const TYPE: DataType = DataType::Array; + + fn from_zval(zval: &'a Zval) -> Option { + zval.array().and_then(|arr| arr.try_into().ok()) + } +} + +impl<'a, V> FromZval<'a> for Vec<(ArrayKey<'a>, V)> +where + V: FromZval<'a>, +{ + const TYPE: DataType = DataType::Array; + + fn from_zval(zval: &'a Zval) -> Option { + zval.array().and_then(|arr| arr.try_into().ok()) + } +} + /////////////////////////////////////////// // Vec /////////////////////////////////////////// @@ -1265,3 +1388,243 @@ impl<'a> FromIterator<(&'a str, Zval)> for ZBox { ht } } + +#[cfg(test)] +#[cfg(feature = "embed")] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::embed::Embed; + + #[test] + fn test_string_try_from_array_key() { + let key = ArrayKey::String("test".to_string()); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test".to_string()); + + let key = ArrayKey::Str("test"); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test".to_string()); + + let key = ArrayKey::Long(42); + let result: Result = key.try_into(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidProperty)); + + let key = ArrayKey::String("42".to_string()); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "42".to_string()); + + let key = ArrayKey::Str("123"); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 123); + } + + #[test] + fn test_i64_try_from_array_key() { + let key = ArrayKey::Long(42); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 42); + + let key = ArrayKey::String("42".to_string()); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 42); + + let key = ArrayKey::Str("123"); + let result: Result = key.try_into(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 123); + + let key = ArrayKey::String("not a number".to_string()); + let result: Result = key.try_into(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidProperty)); + } + + #[test] + fn test_vec_string_v_try_from_hash_table() { + Embed::run(|| { + let mut ht = ZendHashTable::new(); + ht.insert("key1", "value1").unwrap(); + ht.insert("key2", "value2").unwrap(); + + let vec: Vec<(String, String)> = ht.as_ref().try_into().unwrap(); + assert_eq!(vec.len(), 2); + assert_eq!(vec[0].0, "key1"); + assert_eq!(vec[0].1, "value1"); + assert_eq!(vec[1].0, "key2"); + assert_eq!(vec[1].1, "value2"); + + let mut ht2 = ZendHashTable::new(); + ht2.insert(1, "value1").unwrap(); + ht2.insert(2, "value2").unwrap(); + + let vec2: Result> = ht2.as_ref().try_into(); + assert!(vec2.is_err()); + assert!(matches!(vec2.unwrap_err(), Error::InvalidProperty)); + }); + } + + #[test] + fn test_vec_i64_v_try_from_hash_table() { + Embed::run(|| { + let mut ht = ZendHashTable::new(); + ht.insert(1, "value1").unwrap(); + ht.insert("2", "value2").unwrap(); + + let vec: Vec<(i64, String)> = ht.as_ref().try_into().unwrap(); + assert_eq!(vec.len(), 2); + assert_eq!(vec[0].0, 1); + assert_eq!(vec[0].1, "value1"); + assert_eq!(vec[1].0, 2); + assert_eq!(vec[1].1, "value2"); + + let mut ht2 = ZendHashTable::new(); + ht2.insert("key1", "value1").unwrap(); + ht2.insert("key2", "value2").unwrap(); + + let vec2: Result> = ht2.as_ref().try_into(); + assert!(vec2.is_err()); + assert!(matches!(vec2.unwrap_err(), Error::InvalidProperty)); + }); + } + + #[test] + fn test_vec_array_key_v_try_from_hash_table() { + Embed::run(|| { + let mut ht = ZendHashTable::new(); + ht.insert("key1", "value1").unwrap(); + ht.insert(2, "value2").unwrap(); + ht.insert("3", "value3").unwrap(); + + let vec: Vec<(ArrayKey, String)> = ht.as_ref().try_into().unwrap(); + assert_eq!(vec.len(), 3); + assert_eq!(vec[0].0, ArrayKey::String("key1".to_string())); + assert_eq!(vec[0].1, "value1"); + assert_eq!(vec[1].0, ArrayKey::Long(2)); + assert_eq!(vec[1].1, "value2"); + assert_eq!(vec[2].0, ArrayKey::Long(3)); + assert_eq!(vec[2].1, "value3"); + }); + } + + #[test] + fn test_hash_table_try_from_vec() { + Embed::run(|| { + let vec = vec![("key1", "value1"), ("key2", "value2"), ("key3", "value3")]; + + let ht: ZBox = vec.try_into().unwrap(); + assert_eq!(ht.len(), 3); + assert_eq!(ht.get("key1").unwrap().string().unwrap(), "value1"); + assert_eq!(ht.get("key2").unwrap().string().unwrap(), "value2"); + assert_eq!(ht.get("key3").unwrap().string().unwrap(), "value3"); + + let vec_i64 = vec![(1, "value1"), (2, "value2"), (3, "value3")]; + + let ht_i64: ZBox = vec_i64.try_into().unwrap(); + assert_eq!(ht_i64.len(), 3); + assert_eq!(ht_i64.get(1).unwrap().string().unwrap(), "value1"); + assert_eq!(ht_i64.get(2).unwrap().string().unwrap(), "value2"); + assert_eq!(ht_i64.get(3).unwrap().string().unwrap(), "value3"); + }); + } + + #[test] + fn test_vec_k_v_into_zval() { + Embed::run(|| { + let vec = vec![("key1", "value1"), ("key2", "value2"), ("key3", "value3")]; + + let zval = vec.into_zval(false).unwrap(); + assert!(zval.is_array()); + let ht: &ZendHashTable = zval.array().unwrap(); + assert_eq!(ht.len(), 3); + assert_eq!(ht.get("key1").unwrap().string().unwrap(), "value1"); + assert_eq!(ht.get("key2").unwrap().string().unwrap(), "value2"); + assert_eq!(ht.get("key3").unwrap().string().unwrap(), "value3"); + + let vec_i64 = vec![(1, "value1"), (2, "value2"), (3, "value3")]; + let zval_i64 = vec_i64.into_zval(false).unwrap(); + assert!(zval_i64.is_array()); + let ht_i64: &ZendHashTable = zval_i64.array().unwrap(); + assert_eq!(ht_i64.len(), 3); + assert_eq!(ht_i64.get(1).unwrap().string().unwrap(), "value1"); + assert_eq!(ht_i64.get(2).unwrap().string().unwrap(), "value2"); + assert_eq!(ht_i64.get(3).unwrap().string().unwrap(), "value3"); + }); + } + + #[test] + fn test_vec_k_v_from_zval() { + Embed::run(|| { + let mut ht = ZendHashTable::new(); + ht.insert("key1", "value1").unwrap(); + ht.insert("key2", "value2").unwrap(); + ht.insert("key3", "value3").unwrap(); + let mut zval = Zval::new(); + zval.set_hashtable(ht); + + let vec: Vec<(String, String)> = Vec::<(String, String)>::from_zval(&zval).unwrap(); + assert_eq!(vec.len(), 3); + assert_eq!(vec[0].0, "key1"); + assert_eq!(vec[0].1, "value1"); + assert_eq!(vec[1].0, "key2"); + assert_eq!(vec[1].1, "value2"); + assert_eq!(vec[2].0, "key3"); + assert_eq!(vec[2].1, "value3"); + + let mut ht_i64 = ZendHashTable::new(); + ht_i64.insert(1, "value1").unwrap(); + ht_i64.insert("2", "value2").unwrap(); + ht_i64.insert(3, "value3").unwrap(); + let mut zval_i64 = Zval::new(); + zval_i64.set_hashtable(ht_i64); + + let vec_i64: Vec<(i64, String)> = Vec::<(i64, String)>::from_zval(&zval_i64).unwrap(); + assert_eq!(vec_i64.len(), 3); + assert_eq!(vec_i64[0].0, 1); + assert_eq!(vec_i64[0].1, "value1"); + assert_eq!(vec_i64[1].0, 2); + assert_eq!(vec_i64[1].1, "value2"); + assert_eq!(vec_i64[2].0, 3); + assert_eq!(vec_i64[2].1, "value3"); + + let mut ht_mixed = ZendHashTable::new(); + ht_mixed.insert("key1", "value1").unwrap(); + ht_mixed.insert(2, "value2").unwrap(); + ht_mixed.insert("3", "value3").unwrap(); + let mut zval_mixed = Zval::new(); + zval_mixed.set_hashtable(ht_mixed); + + let vec_mixed: Option> = + Vec::<(String, String)>::from_zval(&zval_mixed); + assert!(vec_mixed.is_none()); + }); + } + + #[test] + fn test_vec_array_key_v_from_zval() { + Embed::run(|| { + let mut ht = ZendHashTable::new(); + ht.insert("key1", "value1").unwrap(); + ht.insert(2, "value2").unwrap(); + ht.insert("3", "value3").unwrap(); + let mut zval = Zval::new(); + zval.set_hashtable(ht); + + let vec: Vec<(ArrayKey, String)> = Vec::<(ArrayKey, String)>::from_zval(&zval).unwrap(); + assert_eq!(vec.len(), 3); + assert_eq!(vec[0].0, ArrayKey::String("key1".to_string())); + assert_eq!(vec[0].1, "value1"); + assert_eq!(vec[1].0, ArrayKey::Long(2)); + assert_eq!(vec[1].1, "value2"); + assert_eq!(vec[2].0, ArrayKey::Long(3)); + assert_eq!(vec[2].1, "value3"); + }); + } +} diff --git a/tests/src/integration/array/array.php b/tests/src/integration/array/array.php index 808c5df399..23cc5b06c3 100644 --- a/tests/src/integration/array/array.php +++ b/tests/src/integration/array/array.php @@ -31,3 +31,21 @@ assert($arrayKeys[10] === "qux"); assert($arrayKeys["10"] === "qux"); assert($arrayKeys["quux"] === "quuux"); + +$assoc_keys = test_array_assoc_array_keys([ + 'a' => '1', + 2 => '2', + '3' => '3', +]); +assert($assoc_keys === [ + 'a' => '1', + 2 => '2', + '3' => '3', +]); + +$assoc_keys = test_array_assoc_array_keys(['foo', 'bar', 'baz']); +assert($assoc_keys === [ + 0 => 'foo', + 1 => 'bar', + 2 => 'baz', +]); diff --git a/tests/src/integration/array/mod.rs b/tests/src/integration/array/mod.rs index 84719a9cdc..d95658eed7 100644 --- a/tests/src/integration/array/mod.rs +++ b/tests/src/integration/array/mod.rs @@ -1,7 +1,11 @@ use std::collections::HashMap; use ext_php_rs::{ - convert::IntoZval, ffi::HashTable, php_function, prelude::ModuleBuilder, types::Zval, + convert::IntoZval, + ffi::HashTable, + php_function, + prelude::ModuleBuilder, + types::{ArrayKey, Zval}, wrap_function, }; @@ -15,6 +19,11 @@ pub fn test_array_assoc(a: HashMap) -> HashMap { a } +#[php_function] +pub fn test_array_assoc_array_keys(a: Vec<(ArrayKey, String)>) -> Vec<(ArrayKey, String)> { + a +} + #[php_function] pub fn test_array_keys() -> Zval { let mut ht = HashTable::new(); @@ -31,6 +40,7 @@ pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder { builder .function(wrap_function!(test_array)) .function(wrap_function!(test_array_assoc)) + .function(wrap_function!(test_array_assoc_array_keys)) .function(wrap_function!(test_array_keys)) } diff --git a/tests/src/integration/magic_method/magic_method.php b/tests/src/integration/magic_method/magic_method.php index 069022249a..32b7f1415a 100644 --- a/tests/src/integration/magic_method/magic_method.php +++ b/tests/src/integration/magic_method/magic_method.php @@ -33,5 +33,5 @@ assert(null === $magicMethod->callUndefinedMagicMethod()); // __call_static -assert("Hello from static call 6" === MagicMethod::callStaticSomeMagic(1, 2, 3)); +assert("Hello from static call 1, 2, 3" === MagicMethod::callStaticSomeMagic(1, 2, 3)); assert(null === MagicMethod::callUndefinedStaticSomeMagic()); diff --git a/tests/src/integration/magic_method/mod.rs b/tests/src/integration/magic_method/mod.rs index 8378208fe4..378509c85a 100644 --- a/tests/src/integration/magic_method/mod.rs +++ b/tests/src/integration/magic_method/mod.rs @@ -1,5 +1,8 @@ #![allow(clippy::unused_self)] -use ext_php_rs::{prelude::*, types::Zval}; +use ext_php_rs::{ + prelude::*, + types::{ArrayKey, Zval}, +}; use std::collections::HashMap; #[php_class] @@ -26,7 +29,7 @@ impl MagicMethod { } } - pub fn __call_static(name: String, arguments: HashMap) -> Zval { + pub fn __call_static(name: String, arguments: Vec<(ArrayKey<'_>, &Zval)>) -> Zval { let mut zval = Zval::new(); if name == "callStaticSomeMagic" { let concat_args = format!( @@ -34,10 +37,9 @@ impl MagicMethod { arguments .iter() .filter(|(_, v)| v.is_long()) - .map(|(_, s)| s.long().unwrap()) + .map(|(_, s)| s.long().unwrap().to_string()) .collect::>() - .iter() - .sum::() + .join(", ") ); let _ = zval.set_string(&concat_args, false);