}> */ public static function unions(): iterable { yield 'not a union' => ['string', ['string']]; yield 'simple union' => ['int|string', ['int', 'string']]; yield 'three branches' => ['int|string|bool', ['int', 'string', 'bool']]; yield 'array branch alongside a scalar' => ['string[]|int', ['string[]', 'int']]; yield 'generic branch alongside a scalar' => ['array|int', ['array', 'int']]; // The `|` here parameterises the outer type; splitting it would turn // one type into two that do not exist. yield 'union inside a generic' => ['array', ['array']]; yield 'union inside a shape' => ['array{a: int|string}', ['array{a: int|string}']]; yield 'nested generics' => ['array>', ['array>']]; yield 'shape beside a scalar' => ['array{a: int|string}|null', ['array{a: int|string}', 'null']]; yield 'whitespace is trimmed' => ['int | string', ['int', 'string']]; yield 'empty branches are dropped' => ['int||string', ['int', 'string']]; } /** * @param list $expected */ #[DataProvider('unions')] #[TestDox('a type string splits on its top-level union only')] public function testSplitUnion(string $type, array $expected): void { $this->assertSame($expected, SchemaGenerator::splitUnion($type)); } /** * @return iterable}> */ public static function mappedTypes(): iterable { yield 'scalar union' => ['int|string', ['integer', 'string']]; // The regression: the `[]` check ran before the union split, so this // came back as ['array'] and the `int` branch vanished. yield 'array beside a scalar keeps both' => ['string[]|int', ['array', 'integer']]; yield 'scalar before an array keeps both' => ['int|string[]', ['integer', 'array']]; yield 'generic beside a scalar keeps both' => ['array|bool', ['array', 'boolean']]; yield 'a generic union is still one array' => ['array', ['array']]; yield 'a shape is still one object' => ['array{a: int|string}', ['object']]; } /** * @param list $expected */ #[DataProvider('mappedTypes')] #[TestDox('a union maps to every JSON Schema type its branches need')] public function testMappedTypes(string $type, array $expected): void { $generator = new SchemaGenerator(new DocBlockParser()); $map = new \ReflectionMethod($generator, 'mapPhpTypeToJsonSchemaType'); $this->assertSame($expected, $map->invoke($generator, $type)); } #[TestDox('a generated schema keeps both branches of an array-or-scalar union')] public function testGeneratedUnionSchemaKeepsBothBranches(): void { $schema = (new SchemaGenerator(new DocBlockParser())) ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); // `items` constrains the instance only when it *is* an array, so it // coexists with the scalar branch instead of excluding it. $this->assertSame(['array', 'integer'], $schema['properties']['mixedish']['type']); // The array branch keeps its own element-type constraint rather than // losing it to the union as a whole (`items` would otherwise be `{}`). $this->assertSame(['type' => 'string'], $schema['properties']['mixedish']['items']); $this->assertSame(['integer', 'string'], $schema['properties']['scalars']['type']); $this->assertSame('array', $schema['properties']['arrayOnly']['type']); $this->assertSame(['array', 'null'], $schema['properties']['nullableArray']['type']); // Two array branches with different element types can't both be // enforced without rejecting a value that is valid under one of // them, so neither constraint is applied (falls back to the "matches // anything" empty schema `ensureArrayItems()` defaults every array to). $this->assertSame('array', $schema['properties']['disagreeingArrays']['type']); $this->assertEquals(new \stdClass(), $schema['properties']['disagreeingArrays']['items']); } #[TestDox('a value from either branch validates against the generated schema')] public function testBothBranchesValidate(): void { $schema = (new SchemaGenerator(new DocBlockParser())) ->generate(new \ReflectionMethod(UnionFixture::class, 'handle')); $validator = new SchemaValidator(); $base = ['scalars' => 1, 'arrayOnly' => ['a'], 'nullableArray' => null, 'disagreeingArrays' => []]; $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => ['a', 'b']], $schema)); $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42], $schema)); // A float is in neither branch, so it is still refused. $this->assertNotSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 1.5], $schema)); // Neither element type is enforced, so both branches' values pass. $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => ['a', 'b']], $schema)); $this->assertSame([], $validator->validateAgainstJsonSchema([...$base, 'mixedish' => 42, 'disagreeingArrays' => [1, 2]], $schema)); } }