Repository: echolabsdev/prism (php)

Issue:
Nullable Schema types
Hi, thanks for the awesome library!

I was testing structured outputs and found a limitation.

[Because all fields must be required in the schema](https://platform.openai.com/docs/guides/structured-outputs#all-fields-must-be-required), OpenAI recommends making the type a nullable union if one wants an "optional" field.

```diff
{
    "name": "get_weather",
    "description": "Fetches the weather in the given location",
    "strict": true,
    "parameters": {
        "type": "object",
        "properties": {
            "location": {
                "type": "string",
                "description": "The location to get the weather for"
            },
            "unit": {
+               "type": ["string", "null"],
                "description": "The unit to return the temperature in",
                "enum": ["F", "C"]
            }
        },
        "additionalProperties": false,
        "required": [
            "location", "unit"
        ]
    }
}
```

As far as I understand, this is not possible with the current Schema objects, right? Would be nice if it could be added in the future. :)

Pull request:
Add nullable schema types
To close #69 (nice)

Patch:
diff --git a/src/Concerns/NullableSchema.php b/src/Concerns/NullableSchema.php
new file mode 100644
index 0000000..0719244
--- /dev/null
+++ b/src/Concerns/NullableSchema.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace EchoLabs\Prism\Concerns;
+
+trait NullableSchema
+{
+    /**
+     * @return array<int, string>
+     */
+    protected function castToNullable(string $type): array
+    {
+        return [$type, 'null'];
+    }
+}
diff --git a/src/Schema/ArraySchema.php b/src/Schema/ArraySchema.php
index 00c4f13..b8aaacf 100644
--- a/src/Schema/ArraySchema.php
+++ b/src/Schema/ArraySchema.php
@@ -4,14 +4,18 @@ declare(strict_types=1);
 
 namespace EchoLabs\Prism\Schema;
 
+use EchoLabs\Prism\Concerns\NullableSchema;
 use EchoLabs\Prism\Contracts\Schema;
 
 class ArraySchema implements Schema
 {
+    use NullableSchema;
+
     public function __construct(
         public readonly string $name,
         public readonly string $description,
         public readonly Schema $items,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -25,7 +29,9 @@ class ArraySchema implements Schema
     {
         return [
             'description' => $this->description,
-            'type' => 'array',
+            'type' => $this->nullable
+                ? $this->castToNullable('array')
+                : 'array',
             'items' => $this->items->toArray(),
         ];
     }
diff --git a/src/Schema/BooleanSchema.php b/src/Schema/BooleanSchema.php
index 09bb99d..7333474 100644
--- a/src/Schema/BooleanSchema.php
+++ b/src/Schema/BooleanSchema.php
@@ -4,13 +4,17 @@ declare(strict_types=1);
 
 namespace EchoLabs\Prism\Schema;
 
+use EchoLabs\Prism\Concerns\NullableSchema;
 use EchoLabs\Prism\Contracts\Schema;
 
 class BooleanSchema implements Schema
 {
+    use NullableSchema;
+
     public function __construct(
         public readonly string $name,
         public readonly string $description,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -24,7 +28,9 @@ class BooleanSchema implements Schema
     {
         return [
             'description' => $this->description,
-            'type' => 'boolean',
+            'type' => $this->nullable
+                ? $this->castToNullable('boolean')
+                : 'boolean',
         ];
     }
 }
diff --git a/src/Schema/EnumSchema.php b/src/Schema/EnumSchema.php
index 0a5984d..a8d6a05 100644
--- a/src/Schema/EnumSchema.php
+++ b/src/Schema/EnumSchema.php
@@ -15,6 +15,7 @@ class EnumSchema implements Schema
         public readonly string $name,
         public readonly string $description,
         public readonly array $options,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -29,6 +30,49 @@ class EnumSchema implements Schema
         return [
             'description' => $this->description,
             'enum' => $this->options,
+            'type' => $this->types(),
         ];
     }
+
+    /**
+     * @return string[]|string
+     */
+    protected function types(): array|string
+    {
+        $types = $this->resolveTypes();
+
+        if ($this->nullable) {
+            $types[] = 'null';
+        }
+
+        if ($this->hasSingleType($types)) {
+            return $types[0];
+        }
+
+        return $types;
+    }
+
+    /**
+     * @param  string[]  $types
+     */
+    protected function hasSingleType(array $types): bool
+    {
+        return count($types) === 1;
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function resolveTypes(): array
+    {
+        return collect($this->options)
+            ->map(fn (mixed $option): string => match (gettype($option)) {
+                'integer', 'double' => 'number',
+                'string' => 'string'
+            })
+            ->unique()
+            ->values()
+            ->toArray();
+
+    }
 }
diff --git a/src/Schema/NumberSchema.php b/src/Schema/NumberSchema.php
index 3477fef..ecad01e 100644
--- a/src/Schema/NumberSchema.php
+++ b/src/Schema/NumberSchema.php
@@ -4,13 +4,17 @@ declare(strict_types=1);
 
 namespace EchoLabs\Prism\Schema;
 
+use EchoLabs\Prism\Concerns\NullableSchema;
 use EchoLabs\Prism\Contracts\Schema;
 
 class NumberSchema implements Schema
 {
+    use NullableSchema;
+
     public function __construct(
         public readonly string $name,
         public readonly string $description,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -24,7 +28,9 @@ class NumberSchema implements Schema
     {
         return [
             'description' => $this->description,
-            'type' => 'number',
+            'type' => $this->nullable
+                ? $this->castToNullable('number')
+                : 'number',
         ];
     }
 }
diff --git a/src/Schema/ObjectSchema.php b/src/Schema/ObjectSchema.php
index 901c152..681d110 100644
--- a/src/Schema/ObjectSchema.php
+++ b/src/Schema/ObjectSchema.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
 
 namespace EchoLabs\Prism\Schema;
 
+use EchoLabs\Prism\Concerns\NullableSchema;
 use EchoLabs\Prism\Contracts\Schema;
 
 class ObjectSchema implements Schema
 {
+    use NullableSchema;
+
     /**
      * @param  array<int, Schema>  $properties
      * @param  array<int, string>  $requiredFields
@@ -18,6 +21,7 @@ class ObjectSchema implements Schema
         public readonly array $properties,
         public readonly array $requiredFields = [],
         public readonly bool $allowAdditionalProperties = false,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -31,7 +35,9 @@ class ObjectSchema implements Schema
     {
         return [
             'description' => $this->description,
-            'type' => 'object',
+            'type' => $this->nullable
+                ? $this->castToNullable('object')
+                : 'object',
             'properties' => $this->propertiesArray(),
             'required' => $this->requiredFields,
             'additionalProperties' => $this->allowAdditionalProperties,
diff --git a/src/Schema/StringSchema.php b/src/Schema/StringSchema.php
index 74ac59d..f38f06a 100644
--- a/src/Schema/StringSchema.php
+++ b/src/Schema/StringSchema.php
@@ -4,13 +4,17 @@ declare(strict_types=1);
 
 namespace EchoLabs\Prism\Schema;
 
+use EchoLabs\Prism\Concerns\NullableSchema;
 use EchoLabs\Prism\Contracts\Schema;
 
 class StringSchema implements Schema
 {
+    use NullableSchema;
+
     public function __construct(
         public readonly string $name,
         public readonly string $description,
+        public readonly bool $nullable = false,
     ) {}
 
     #[\Override]
@@ -24,7 +28,9 @@ class StringSchema implements Schema
     {
         return [
             'description' => $this->description,
-            'type' => 'string',
+            'type' => $this->nullable
+                ? $this->castToNullable('string')
+                : 'string',
         ];
     }
 }


Test patch:
diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php
index 07784a0..dd42b85 100644
--- a/tests/SchemaTest.php
+++ b/tests/SchemaTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Tests;
 
 use EchoLabs\Prism\Schema\ArraySchema;
+use EchoLabs\Prism\Schema\BooleanSchema;
 use EchoLabs\Prism\Schema\EnumSchema;
 use EchoLabs\Prism\Schema\NumberSchema;
 use EchoLabs\Prism\Schema\ObjectSchema;
@@ -16,7 +17,7 @@ it('they can have nested properties', function (): void {
         description: 'a user object',
         properties: [
             new StringSchema('name', 'the users name'),
-            new NumberSchema('age', 'the users age', false),
+            new NumberSchema('age', 'the users age'),
             new EnumSchema(
                 name: 'status',
                 description: 'the users status',
@@ -64,6 +65,7 @@ it('they can have nested properties', function (): void {
                     'inactive',
                     'suspended',
                 ],
+                'type' => 'string',
             ],
             'hobbies' => [
                 'description' => 'the users hobbies',
@@ -102,3 +104,138 @@ it('they can have nested properties', function (): void {
         'additionalProperties' => false,
     ]);
 });
+
+it('they can be nullable', function (): void {
+    $schema = new ObjectSchema(
+        name: 'user',
+        description: 'a user object',
+        properties: [
+            new StringSchema('name', 'the users name', nullable: true),
+            new NumberSchema('age', 'the users age', nullable: true),
+            new EnumSchema(
+                name: 'status',
+                description: 'the users status',
+                options: [
+                    'active',
+                    'inactive',
+                    'suspended',
+                ],
+                nullable: true
+            ),
+            new ArraySchema(
+                name: 'hobbies',
+                description: 'the users hobbies',
+                items: new StringSchema('hobby', 'the users hobby'),
+                nullable: true
+            ),
+            new BooleanSchema(name: 'is_admin', description: 'is an administrative user', nullable: true),
+            new ObjectSchema(
+                name: 'address',
+                description: 'the users address',
+                properties: [
+                    new StringSchema('street', 'the street part of the address'),
+                    new StringSchema('city', 'the city part of the address'),
+                    new StringSchema('country', 'the country part of the address'),
+                    new NumberSchema('zip', 'the zip code part of the address'),
+                ],
+                requiredFields: ['street', 'city', 'country', 'zip']
+            ),
+        ],
+        nullable: true
+    );
+
+    expect($schema->toArray())->toBe([
+        'description' => 'a user object',
+        'type' => ['object', 'null'],
+        'properties' => [
+            'name' => [
+                'description' => 'the users name',
+                'type' => ['string', 'null'],
+            ],
+            'age' => [
+                'description' => 'the users age',
+                'type' => ['number', 'null'],
+            ],
+            'status' => [
+                'description' => 'the users status',
+                'enum' => [
+                    'active',
+                    'inactive',
+                    'suspended',
+                ],
+                'type' => ['string', 'null'],
+            ],
+            'hobbies' => [
+                'description' => 'the users hobbies',
+                'type' => ['array', 'null'],
+                'items' => [
+                    'description' => 'the users hobby',
+                    'type' => 'string',
+                ],
+            ],
+            'is_admin' => [
+                'description' => 'is an administrative user',
+                'type' => ['boolean', 'null'],
+            ],
+            'address' => [
+                'description' => 'the users address',
+                'type' => 'object',
+                'properties' => [
+                    'street' => [
+                        'description' => 'the street part of the address',
+                        'type' => 'string',
+                    ],
+                    'city' => [
+                        'description' => 'the city part of the address',
+                        'type' => 'string',
+                    ],
+                    'country' => [
+                        'description' => 'the country part of the address',
+                        'type' => 'string',
+                    ],
+                    'zip' => [
+                        'description' => 'the zip code part of the address',
+                        'type' => 'number',
+                    ],
+                ],
+                'required' => ['street', 'city', 'country', 'zip'],
+                'additionalProperties' => false,
+            ],
+        ],
+        'required' => [],
+        'additionalProperties' => false,
+    ]);
+});
+
+it('nullable enums include types', function (): void {
+    $enumSchema = new EnumSchema(
+        name: 'temp',
+        description: 'sick or fever temp',
+        options: [98.6, 100, 'unknown', 105],
+        nullable: true
+    );
+
+    expect($enumSchema->toArray())->toBe([
+        'description' => 'sick or fever temp',
+        'enum' => [98.6, 100, 'unknown', 105],
+        'type' => [
+            'number',
+            'string',
+            'null',
+        ],
+    ]);
+});
+
+it('non-nullable enum with single type returns single type', function (): void {
+    $enumSchema = new EnumSchema(
+        name: 'user_type',
+        description: 'the type of user',
+        options: ['admin', 'super_admin', 'standard']
+    );
+
+    expect($enumSchema->toArray())->toBe([
+        'description' => 'the type of user',
+        'enum' => ['admin', 'super_admin', 'standard'],
+        'type' => 'string',
+    ]);
+});