Skip to content

Commit b2adad4

Browse files
committed
fix(macro): nullable parameters #538
1 parent 6b192e8 commit b2adad4

File tree

2 files changed

+76
-19
lines changed

2 files changed

+76
-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,
@@ -568,18 +568,20 @@ impl<'a> Args<'a> {
568568
/// # Parameters
569569
///
570570
/// * `optional` - The first optional argument. If [`None`], the optional
571-
/// arguments will be from the first nullable argument after the last
572-
/// non-nullable argument to the end of the arguments.
571+
/// arguments will be from the first optional argument (nullable or has
572+
/// default) after the last required argument to the end of the arguments.
573573
pub fn split_args(&self, optional: Option<&Ident>) -> (&[TypedArg<'a>], &[TypedArg<'a>]) {
574574
let mut mid = None;
575575
for (i, arg) in self.typed.iter().enumerate() {
576+
// An argument is optional if it's nullable (Option<T>) or has a default value.
577+
let is_optional = arg.nullable || arg.default.is_some();
576578
if let Some(optional) = optional {
577579
if optional == arg.name {
578580
mid.replace(i);
579581
}
580-
} else if mid.is_none() && arg.nullable {
582+
} else if mid.is_none() && is_optional {
581583
mid.replace(i);
582-
} else if !arg.nullable {
584+
} else if !is_optional {
583585
mid.take();
584586
}
585587
}
@@ -657,8 +659,49 @@ impl TypedArg<'_> {
657659
fn accessor(&self, bail_fn: impl Fn(TokenStream) -> TokenStream) -> TokenStream {
658660
let name = self.name;
659661
if let Some(default) = &self.default {
660-
quote! {
661-
#name.val().unwrap_or(#default.into())
662+
if self.nullable {
663+
// For nullable types with defaults, null is acceptable
664+
quote! {
665+
#name.val().unwrap_or(#default.into())
666+
}
667+
} else {
668+
// For non-nullable types with defaults:
669+
// - If argument was omitted: use default
670+
// - If null was explicitly passed: throw TypeError
671+
// - If a value was passed: try to convert it
672+
let bail_null = bail_fn(quote! {
673+
::ext_php_rs::exception::PhpException::new(
674+
concat!("Argument `$", stringify!(#name), "` must not be null").into(),
675+
0,
676+
::ext_php_rs::zend::ce::type_error(),
677+
)
678+
});
679+
let bail_invalid = bail_fn(quote! {
680+
::ext_php_rs::exception::PhpException::default(
681+
concat!("Invalid value given for argument `", stringify!(#name), "`.").into()
682+
)
683+
});
684+
quote! {
685+
match #name.zval() {
686+
Some(zval) if zval.is_null() => {
687+
// Null was explicitly passed to a non-nullable parameter
688+
#bail_null
689+
}
690+
Some(_) => {
691+
// A value was passed, try to convert it
692+
match #name.val() {
693+
Some(val) => val,
694+
None => {
695+
#bail_invalid
696+
}
697+
}
698+
}
699+
None => {
700+
// Argument was omitted, use default
701+
#default.into()
702+
}
703+
}
704+
}
662705
}
663706
} else if self.variadic {
664707
let variadic_name = format_ident!("__variadic_{}", name);
@@ -690,21 +733,22 @@ impl TypedArg<'_> {
690733
}
691734
}
692735

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

tests/src/integration/defaults/defaults.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,16 @@
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);

0 commit comments

Comments
 (0)