Skip to content

[BUG] [PHP] generator ignores oneOf null branch, treating property as non-nullable #22185

@florentausha

Description

@florentausha

Bug Report Checklist

Description

The PHP generator ignores type: 'null' when it is declared inside a oneOf. As a result, the generated PHP model marks
the property as required and rejects null, even though the schema explicitly allows it. In my case, the
IntercomCompany.hosting_plan
property can be null in the API response, but the generated setter/validator throws because hosting_plan is treated as
non-nullable.

openapi-generator version

7.14.0 (docker image openapitools/openapi-generator-cli:v7.14.0)
Also reproduced with the latest version 7.16.0

OpenAPI declaration file content or url
components:
  schemas:
    HostingPricing:
      type: string
      enum:
        - launch
        - boost
        - supersonic
    IntercomCompany:
      type: object
      required:
        - channel_id
        - hosting_plan
      properties:
        channel_id:
          type: integer
          minimum: 1
        hosting_plan:
          oneOf:
            - $ref: '#/components/schemas/HostingPricing'
            - type: string
              enum:
                - enterprise
            - type: 'null'
Generation Details
docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli:v7.14.0 generate \
    -i /local/repro.yaml \
    -g php \
    -o /local/out

No custom config, just default PHP generator settings.

This generates the following file:

<?php
/**
 * IntercomCompany
 *
 * PHP version 8.1
 *
 * @category Class
 * @package  App\OpenApi
 * @author   OpenAPI Generator team
 * @link     https://openapi-generator.tech
 */

/**
 * Test
 *
 * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
 *
 * The version of the OpenAPI document: 1.0.0
 * Generated by: https://openapi-generator.tech
 * Generator version: 7.14.0
 */

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */

namespace App\OpenApi\Model;

use \ArrayAccess;
use \App\OpenApi\ObjectSerializer;

/**
 * IntercomCompany Class Doc Comment
 *
 * @category Class
 * @package  App\OpenApi
 * @author   OpenAPI Generator team
 * @link     https://openapi-generator.tech
 * @implements \ArrayAccess<string, mixed>
 */
class IntercomCompany implements ModelInterface, ArrayAccess, \JsonSerializable
{
    public const DISCRIMINATOR = null;

    /**
      * The original name of the model.
      *
      * @var string
      */
    protected static $openAPIModelName = 'IntercomCompany';

    /**
      * Array of property to type mappings. Used for (de)serialization
      *
      * @var string[]
      */
    protected static $openAPITypes = [
        'channel_id' => 'int',
        'hosting_plan' => '\App\OpenApi\Model\IntercomCompanyHostingPlan'
    ];

    /**
      * Array of property to format mappings. Used for (de)serialization
      *
      * @var string[]
      * @phpstan-var array<string, string|null>
      * @psalm-var array<string, string|null>
      */
    protected static $openAPIFormats = [
        'channel_id' => null,
        'hosting_plan' => null
    ];

    /**
      * Array of nullable properties. Used for (de)serialization
      *
      * @var boolean[]
      */
    protected static array $openAPINullables = [
        'channel_id' => false,
        'hosting_plan' => true
    ];

    /**
      * If a nullable field gets set to null, insert it here
      *
      * @var boolean[]
      */
    protected array $openAPINullablesSetToNull = [];

    /**
     * Array of property to type mappings. Used for (de)serialization
     *
     * @return array
     */
    public static function openAPITypes()
    {
        return self::$openAPITypes;
    }

    /**
     * Array of property to format mappings. Used for (de)serialization
     *
     * @return array
     */
    public static function openAPIFormats()
    {
        return self::$openAPIFormats;
    }

    /**
     * Array of nullable properties
     *
     * @return array
     */
    protected static function openAPINullables(): array
    {
        return self::$openAPINullables;
    }

    /**
     * Array of nullable field names deliberately set to null
     *
     * @return boolean[]
     */
    private function getOpenAPINullablesSetToNull(): array
    {
        return $this->openAPINullablesSetToNull;
    }

    /**
     * Setter - Array of nullable field names deliberately set to null
     *
     * @param boolean[] $openAPINullablesSetToNull
     */
    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
    {
        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
    }

    /**
     * Checks if a property is nullable
     *
     * @param string $property
     * @return bool
     */
    public static function isNullable(string $property): bool
    {
        return self::openAPINullables()[$property] ?? false;
    }

    /**
     * Checks if a nullable property is set to null.
     *
     * @param string $property
     * @return bool
     */
    public function isNullableSetToNull(string $property): bool
    {
        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
    }

    /**
     * Array of attributes where the key is the local name,
     * and the value is the original name
     *
     * @var string[]
     */
    protected static $attributeMap = [
        'channel_id' => 'channel_id',
        'hosting_plan' => 'hosting_plan'
    ];

    /**
     * Array of attributes to setter functions (for deserialization of responses)
     *
     * @var string[]
     */
    protected static $setters = [
        'channel_id' => 'setChannelId',
        'hosting_plan' => 'setHostingPlan'
    ];

    /**
     * Array of attributes to getter functions (for serialization of requests)
     *
     * @var string[]
     */
    protected static $getters = [
        'channel_id' => 'getChannelId',
        'hosting_plan' => 'getHostingPlan'
    ];

    /**
     * Array of attributes where the key is the local name,
     * and the value is the original name
     *
     * @return array
     */
    public static function attributeMap()
    {
        return self::$attributeMap;
    }

    /**
     * Array of attributes to setter functions (for deserialization of responses)
     *
     * @return array
     */
    public static function setters()
    {
        return self::$setters;
    }

    /**
     * Array of attributes to getter functions (for serialization of requests)
     *
     * @return array
     */
    public static function getters()
    {
        return self::$getters;
    }

    /**
     * The original name of the model.
     *
     * @return string
     */
    public function getModelName()
    {
        return self::$openAPIModelName;
    }


    /**
     * Associative array for storing property values
     *
     * @var mixed[]
     */
    protected $container = [];

    /**
     * Constructor
     *
     * @param mixed[]|null $data Associated array of property values
     *                      initializing the model
     */
    public function __construct(?array $data = null)
    {
        $this->setIfExists('channel_id', $data ?? [], null);
        $this->setIfExists('hosting_plan', $data ?? [], null);
    }

    /**
    * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
    * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
    * $this->openAPINullablesSetToNull array
    *
    * @param string $variableName
    * @param array  $fields
    * @param mixed  $defaultValue
    */
    private function setIfExists(string $variableName, array $fields, $defaultValue): void
    {
        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
            $this->openAPINullablesSetToNull[] = $variableName;
        }

        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
    }

    /**
     * Show all the invalid properties with reasons.
     *
     * @return array invalid properties with reasons
     */
    public function listInvalidProperties()
    {
        $invalidProperties = [];

        if ($this->container['channel_id'] === null) {
            $invalidProperties[] = "'channel_id' can't be null";
        }
        if (($this->container['channel_id'] < 1)) {
            $invalidProperties[] = "invalid value for 'channel_id', must be bigger than or equal to 1.";
        }

        if ($this->container['hosting_plan'] === null) {
            $invalidProperties[] = "'hosting_plan' can't be null";
        }
        return $invalidProperties;
    }

    /**
     * Validate all the properties in the model
     * return true if all passed
     *
     * @return bool True if all properties are valid
     */
    public function valid()
    {
        return count($this->listInvalidProperties()) === 0;
    }


    /**
     * Gets channel_id
     *
     * @return int
     */
    public function getChannelId()
    {
        return $this->container['channel_id'];
    }

    /**
     * Sets channel_id
     *
     * @param int $channel_id channel_id
     *
     * @return self
     */
    public function setChannelId($channel_id)
    {
        if (is_null($channel_id)) {
            throw new \InvalidArgumentException('non-nullable channel_id cannot be null');
        }

        if (($channel_id < 1)) {
            throw new \InvalidArgumentException('invalid value for $channel_id when calling IntercomCompany., must be bigger than or equal to 1.');
        }

        $this->container['channel_id'] = $channel_id;

        return $this;
    }

    /**
     * Gets hosting_plan
     *
     * @return \App\OpenApi\Model\IntercomCompanyHostingPlan
     */
    public function getHostingPlan()
    {
        return $this->container['hosting_plan'];
    }

    /**
     * Sets hosting_plan
     *
     * @param \App\OpenApi\Model\IntercomCompanyHostingPlan $hosting_plan hosting_plan
     *
     * @return self
     */
    public function setHostingPlan($hosting_plan)
    {
        if (is_null($hosting_plan)) {
            array_push($this->openAPINullablesSetToNull, 'hosting_plan');
        } else {
            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
            $index = array_search('hosting_plan', $nullablesSetToNull);
            if ($index !== FALSE) {
                unset($nullablesSetToNull[$index]);
                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
            }
        }
        $this->container['hosting_plan'] = $hosting_plan;

        return $this;
    }
    /**
     * Returns true if offset exists. False otherwise.
     *
     * @param integer $offset Offset
     *
     * @return boolean
     */
    public function offsetExists($offset): bool
    {
        return isset($this->container[$offset]);
    }

    /**
     * Gets offset.
     *
     * @param integer $offset Offset
     *
     * @return mixed|null
     */
    #[\ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        return $this->container[$offset] ?? null;
    }

    /**
     * Sets value based on offset.
     *
     * @param int|null $offset Offset
     * @param mixed    $value  Value to be set
     *
     * @return void
     */
    public function offsetSet($offset, $value): void
    {
        if (is_null($offset)) {
            $this->container[] = $value;
        } else {
            $this->container[$offset] = $value;
        }
    }

    /**
     * Unsets offset.
     *
     * @param integer $offset Offset
     *
     * @return void
     */
    public function offsetUnset($offset): void
    {
        unset($this->container[$offset]);
    }

    /**
     * Serializes the object to a value that can be serialized natively by json_encode().
     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
     *
     * @return mixed Returns data which can be serialized by json_encode(), which is a value
     * of any type other than a resource.
     */
    #[\ReturnTypeWillChange]
    public function jsonSerialize()
    {
       return ObjectSerializer::sanitizeForSerialization($this);
    }

    /**
     * Gets the string presentation of the object
     *
     * @return string
     */
    public function __toString()
    {
        return json_encode(
            ObjectSerializer::sanitizeForSerialization($this),
            JSON_PRETTY_PRINT
        );
    }

    /**
     * Gets a header-safe presentation of the object
     *
     * @return string
     */
    public function toHeaderValue()
    {
        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
    }
}

Which contains:

        if ($this->container['hosting_plan'] === null) {
            $invalidProperties[] = "'hosting_plan' can't be null";
        }
Steps to reproduce
  1. Use the spec above (repro.yaml) and run the generation command shown.
  2. Inspect the generated Model/IntercomCompany.php.
  3. Note that listInvalidProperties() contains "'hosting_plan' can't be null" and the setter throws when null is given.
  4. Try to instantiate the model with ['channel_id' => 1, 'hosting_plan' => null] and call valid(). It returns false
    because hosting_plan is treated as non-nullable.
Related issues/PRs

I couldn’t find an existing issue covering oneOf with type: 'null' in the PHP generator.

Suggest a fix

When a schema contains a oneOf (or anyOf) branch with type: 'null', the generator should mark the property as nullable
in the PHP model (openAPINullables['hosting_plan'] = true) and skip the 'hosting_plan' can't be null validation. The
same applies to
other generators that rely on similar logic.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions