Skip to content

Commit b99d90c

Browse files
committed
fix(macro): nullable parameters #538
1 parent 1573d74 commit b99d90c

File tree

3 files changed

+88
-19
lines changed

3 files changed

+88
-19
lines changed

crates/macros/src/function.rs

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ impl<'a> Args<'a> {
493493

494494
// If the variable is `&[&Zval]` treat it as the variadic argument.
495495
let default = defaults.remove(ident);
496-
let nullable = type_is_nullable(ty.as_ref(), default.is_some())?;
496+
let nullable = type_is_nullable(ty.as_ref())?;
497497
let (variadic, as_ref, ty) = Self::parse_typed(ty);
498498
result.typed.push(TypedArg {
499499
name: ident,
@@ -570,18 +570,20 @@ impl<'a> Args<'a> {
570570
/// # Parameters
571571
///
572572
/// * `optional` - The first optional argument. If [`None`], the optional
573-
/// arguments will be from the first nullable argument after the last
574-
/// non-nullable argument to the end of the arguments.
573+
/// arguments will be from the first optional argument (nullable or has
574+
/// default) after the last required argument to the end of the arguments.
575575
pub fn split_args(&self, optional: Option<&Ident>) -> (&[TypedArg<'a>], &[TypedArg<'a>]) {
576576
let mut mid = None;
577577
for (i, arg) in self.typed.iter().enumerate() {
578+
// An argument is optional if it's nullable (Option<T>) or has a default value.
579+
let is_optional = arg.nullable || arg.default.is_some();
578580
if let Some(optional) = optional {
579581
if optional == arg.name {
580582
mid.replace(i);
581583
}
582-
} else if mid.is_none() && arg.nullable {
584+
} else if mid.is_none() && is_optional {
583585
mid.replace(i);
584-
} else if !arg.nullable {
586+
} else if !is_optional {
585587
mid.take();
586588
}
587589
}
@@ -659,8 +661,49 @@ impl TypedArg<'_> {
659661
fn accessor(&self, bail_fn: impl Fn(TokenStream) -> TokenStream) -> TokenStream {
660662
let name = self.name;
661663
if let Some(default) = &self.default {
662-
quote! {
663-
#name.val().unwrap_or(#default.into())
664+
if self.nullable {
665+
// For nullable types with defaults, null is acceptable
666+
quote! {
667+
#name.val().unwrap_or(#default.into())
668+
}
669+
} else {
670+
// For non-nullable types with defaults:
671+
// - If argument was omitted: use default
672+
// - If null was explicitly passed: throw TypeError
673+
// - If a value was passed: try to convert it
674+
let bail_null = bail_fn(quote! {
675+
::ext_php_rs::exception::PhpException::new(
676+
concat!("Argument `$", stringify!(#name), "` must not be null").into(),
677+
0,
678+
::ext_php_rs::zend::ce::type_error(),
679+
)
680+
});
681+
let bail_invalid = bail_fn(quote! {
682+
::ext_php_rs::exception::PhpException::default(
683+
concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
684+
)
685+
});
686+
quote! {
687+
match #name.zval() {
688+
Some(zval) if zval.is_null() => {
689+
// Null was explicitly passed to a non-nullable parameter
690+
#bail_null
691+
}
692+
Some(_) => {
693+
// A value was passed, try to convert it
694+
match #name.val() {
695+
Some(val) => val,
696+
None => {
697+
#bail_invalid
698+
}
699+
}
700+
}
701+
None => {
702+
// Argument was omitted, use default
703+
#default.into()
704+
}
705+
}
706+
}
664707
}
665708
} else if self.variadic {
666709
let variadic_name = format_ident!("__variadic_{}", name);
@@ -692,21 +735,22 @@ impl TypedArg<'_> {
692735
}
693736
}
694737

695-
/// Returns true of the given type is nullable in PHP.
738+
/// Returns true if the given type is nullable in PHP (i.e., it's an `Option<T>`).
739+
///
740+
/// Note: Having a default value does NOT make a type nullable. A parameter with
741+
/// a default value is optional (can be omitted), but passing `null` explicitly
742+
/// should still be rejected unless the type is `Option<T>`.
696743
// TODO(david): Eventually move to compile-time constants for this (similar to
697744
// FromZval::NULLABLE).
698-
pub fn type_is_nullable(ty: &Type, has_default: bool) -> Result<bool> {
745+
pub fn type_is_nullable(ty: &Type) -> Result<bool> {
699746
Ok(match ty {
700-
syn::Type::Path(path) => {
701-
has_default
702-
|| path
703-
.path
704-
.segments
705-
.iter()
706-
.next_back()
707-
.is_some_and(|seg| seg.ident == "Option")
708-
}
709-
syn::Type::Reference(_) => false, /* Reference cannot be nullable unless */
747+
Type::Path(path) => path
748+
.path
749+
.segments
750+
.iter()
751+
.next_back()
752+
.is_some_and(|seg| seg.ident == "Option"),
753+
Type::Reference(_) => false, /* Reference cannot be nullable unless */
710754
// wrapped in `Option` (in that case it'd be a Path).
711755
_ => bail!(ty => "Unsupported argument type."),
712756
})

tests/src/integration/defaults/defaults.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,21 @@
77
assert(test_defaults_multiple_option_arguments() === 'Default');
88
assert(test_defaults_multiple_option_arguments(a: 'a') === 'a');
99
assert(test_defaults_multiple_option_arguments(b: 'b') === 'b');
10+
11+
// Test that passing null to a non-nullable parameter with a default value throws TypeError
12+
// (fixes: https://github.com/extphprs/ext-php-rs/issues/538)
13+
$threw = false;
14+
try {
15+
test_defaults_integer(null);
16+
} catch (TypeError $e) {
17+
$threw = true;
18+
}
19+
assert($threw, 'Expected TypeError when passing null to non-nullable parameter with default');
20+
21+
// But passing null to a nullable parameter should still work
22+
assert(test_defaults_nullable_string(null) === null);
23+
24+
// Test nullable parameter with Some() default value
25+
assert(test_defaults_nullable_with_some_default() === 'fallback', 'Should return fallback when called without arguments');
26+
assert(test_defaults_nullable_with_some_default(null) === null, 'Should return null when null is passed');
27+
assert(test_defaults_nullable_with_some_default('custom') === 'custom', 'Should return custom value when provided');

tests/src/integration/defaults/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@ pub fn test_defaults_multiple_option_arguments(
2222
Ok(a.or(b).unwrap_or_else(|| "Default".to_string()))
2323
}
2424

25+
#[php_function]
26+
#[php(defaults(a = Some("fallback".to_string())))]
27+
pub fn test_defaults_nullable_with_some_default(a: Option<String>) -> Option<String> {
28+
a
29+
}
30+
2531
pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
2632
builder
2733
.function(wrap_function!(test_defaults_integer))
2834
.function(wrap_function!(test_defaults_nullable_string))
2935
.function(wrap_function!(test_defaults_multiple_option_arguments))
36+
.function(wrap_function!(test_defaults_nullable_with_some_default))
3037
}
3138

3239
#[cfg(test)]

0 commit comments

Comments
 (0)