diff --git a/composer.json b/composer.json index ec5d2a3f37b..7069c0edff6 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ "issues": "https://issues.apache.org/jira/browse/AVRO" }, "require": { - "php": "^8.1" + "php": "^8.1", + "nikic/php-parser": "^5.7" }, "deps": [ "vendor/phpunit/phpunit" diff --git a/lang/php/lib/Generator/AvroCodeGenerator.php b/lang/php/lib/Generator/AvroCodeGenerator.php new file mode 100644 index 00000000000..cd12c4c5f73 --- /dev/null +++ b/lang/php/lib/Generator/AvroCodeGenerator.php @@ -0,0 +1,374 @@ + */ + private array $registry = []; + + public function __construct() + { + $this->factory = new BuilderFactory(); + $this->printer = new Standard(['shortArraySyntax' => true]); + } + + /** + * @return array Map of filename to file contents + */ + public function translate( + AvroSchema $schema, + string $path, + string $phpNamespace + ): array { + $this->buildRegistry($schema); + + $files = []; + + foreach ($this->registry as $name => $registeredSchema) { + $node = match (true) { + $registeredSchema instanceof AvroEnumSchema => $this->buildEnum( + $registeredSchema, + $phpNamespace, + $registeredSchema->symbols() + ), + $registeredSchema instanceof AvroRecordSchema => $this->buildRecord( + $registeredSchema, + $phpNamespace + ), + default => null + }; + + if (null !== $node) { + $code = <<printer->prettyPrint([$node])} + + PHP; + + $filename = $path.'/'.ucwords($name).'.php'; + $files[$filename] = $code; + } + } + + return $files; + } + + private function buildRegistry(AvroSchema $rootSchema): void + { + $this->registry = []; + $this->collectSchemas($rootSchema); + } + + private function collectSchemas(AvroSchema $schema): void + { + switch ($schema::class) { + case AvroRecordSchema::class: + if (!array_key_exists($schema->fullname(), $this->registry)) { + $this->registry[$schema->fullname()] = $schema; + foreach ($schema->fields() as $field) { + $this->collectSchemas($field->type()); + } + } + + break; + case AvroEnumSchema::class: + $this->registry[$schema->fullname()] = $schema; + + break; + case AvroArraySchema::class: + $this->collectSchemas($schema->items()); + + break; + case AvroMapSchema::class: + $this->collectSchemas($schema->values()); + + break; + case AvroUnionSchema::class: + foreach ($schema->schemas() as $unionSchema) { + $this->collectSchemas($unionSchema); + } + + break; + } + } + + private function buildRecord( + AvroRecordSchema $avroRecord, + string $phpNamespace + ): Node { + $className = ucwords($avroRecord->name()); + $class = $this->factory->class($className)->makeFinal()->implement('\\JsonSerializable'); + + foreach ($avroRecord->fields() as $field) { + $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace); + $property = $this->factory->property($field->name()) + ->makePrivate() + ->setType($phpType); + + $phpDocType = $this->avroTypeToPhpDoc($field->type(), $phpNamespace); + if (null !== $phpDocType) { + $property->setDocComment('/** @var '.$phpDocType.' */'); + } + + if ($field->hasDefaultValue()) { + $property->setDefault($this->buildDefault($field->defaultValue())); + } + + $class->addStmt($property); + } + + $constructor = $this->factory->method('__construct')->makePublic(); + $constructorParamDocs = []; + foreach ($avroRecord->fields() as $field) { + $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace); + $param = $this->factory->param($field->name())->setType($phpType); + if ($field->hasDefaultValue()) { + $param->setDefault($this->buildDefault($field->defaultValue())); + } + + $phpDocType = $this->avroTypeToPhpDoc($field->type(), $phpNamespace); + if (null !== $phpDocType) { + $constructorParamDocs[] = '@param '.$phpDocType.' $'.$field->name(); + } + + $constructor->addParam($param); + $constructor->addStmt( + new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $field->name()), + new Node\Expr\Variable($field->name()) + ) + ); + } + if ([] !== $constructorParamDocs) { + $docLines = "/**\n"; + foreach ($constructorParamDocs as $doc) { + $docLines .= ' * '.$doc."\n"; + } + $docLines .= ' */'; + $constructor->setDocComment($docLines); + } + $class->addStmt($constructor); + + foreach ($avroRecord->fields() as $field) { + $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace); + $getter = $this->factory->method($field->name()) + ->makePublic() + ->setReturnType($phpType) + ->addStmt( + new Stmt\Return_( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $field->name()) + ) + ); + + $phpDocType = $this->avroTypeToPhpDoc($field->type(), $phpNamespace); + if (null !== $phpDocType) { + $getter->setDocComment('/** @return '.$phpDocType.' */'); + } + + $class->addStmt($getter); + } + + $arrayItems = []; + foreach ($avroRecord->fields() as $field) { + $arrayItems[] = new Node\ArrayItem( + $this->buildJsonSerializeValue($field->type(), $field->name()), + new String_($field->name()) + ); + } + $jsonSerialize = $this->factory->method('jsonSerialize') + ->makePublic() + ->setReturnType('mixed') + ->addStmt( + new Stmt\Return_( + new Node\Expr\Array_($arrayItems, ['kind' => Node\Expr\Array_::KIND_SHORT]) + ) + ); + $class->addStmt($jsonSerialize); + + return $this->factory->namespace($phpNamespace) + ->addStmt($class) + ->getNode(); + } + + /** + * Builds the expression used inside jsonSerialize() for a single field. + * + * - EnumSchema → $this->field->value (plain string for Avro + JSON) + * - union[null, Enum] → $this->field?->value (null-safe, still plain) + * - anything else → $this->field + */ + private function buildJsonSerializeValue(AvroSchema $fieldType, string $fieldName): Node\Expr + { + $propertyFetch = new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $fieldName); + + if ($fieldType instanceof AvroEnumSchema) { + return new Node\Expr\PropertyFetch($propertyFetch, 'value'); + } + + if ($fieldType instanceof AvroUnionSchema) { + $nonNullSchemas = array_values(array_filter( + $fieldType->schemas(), + static fn (AvroSchema $s): bool => !($s instanceof AvroPrimitiveSchema && AvroSchema::NULL_TYPE === $s->type()) + )); + + if (1 === count($nonNullSchemas) && $nonNullSchemas[0] instanceof AvroEnumSchema) { + return new Node\Expr\NullsafePropertyFetch($propertyFetch, 'value'); + } + } + + return $propertyFetch; + } + + /** + * @param list $values + */ + private function buildEnum( + AvroEnumSchema $avroEnum, + string $phpNamespace, + array $values + ): Node { + $className = ucwords($avroEnum->name()); + $enum = $this->factory->enum($className)->setScalarType('string'); + + foreach ($values as $value) { + $caseName = strtoupper($value); + $enum->addStmt( + $this->factory->enumCase($caseName)->setValue($value) + ); + } + + return $this->factory->namespace($phpNamespace) + ->addStmt($enum) + ->getNode(); + } + + private function avroTypeToPhp(AvroSchema $schema, string $phpNamespace): string + { + return match (true) { + $schema instanceof AvroPrimitiveSchema => $this->avroPrimitiveTypeToPhp($schema), + $schema instanceof AvroArraySchema, $schema instanceof AvroMapSchema => 'array', + $schema instanceof AvroRecordSchema, $schema instanceof AvroEnumSchema => '\\'.$phpNamespace.'\\'.ucwords($schema->name()), + $schema instanceof AvroUnionSchema => $this->unionToPhp($schema, $phpNamespace), + default => 'mixed' + }; + } + + private function avroPrimitiveTypeToPhp(AvroPrimitiveSchema $primitiveSchema): string + { + return match ($primitiveSchema->type()) { + AvroSchema::NULL_TYPE => 'null', + AvroSchema::BOOLEAN_TYPE => 'bool', + AvroSchema::INT_TYPE, AvroSchema::LONG_TYPE => 'int', + AvroSchema::FLOAT_TYPE, AvroSchema::DOUBLE_TYPE => 'float', + AvroSchema::STRING_TYPE, AvroSchema::BYTES_TYPE => 'string', + default => throw new AvroCodeGeneratorException("Unknown primitive type: ".$primitiveSchema->type()), + }; + } + + private function unionToPhp(AvroUnionSchema $union, string $phpNamespace): string + { + $types = []; + foreach ($union->schemas() as $schema) { + $types[] = $this->avroTypeToPhp($schema, $phpNamespace); + } + + return implode('|', array_unique($types)); + } + + private function buildDefault(mixed $value): mixed + { + if (is_array($value)) { + return $this->factory->val($value); + } + + return $value; + } + + /** + * Returns a PHPDoc type string for schemas that need richer type info than + * what PHP's native type system can express (arrays and maps), or null when + * the native type hint is sufficient. + */ + private function avroTypeToPhpDoc(AvroSchema $schema, string $phpNamespace): ?string + { + return match (true) { + $schema instanceof AvroArraySchema => 'list<'.$this->avroTypeToPhpDocInner($schema->items(), $phpNamespace).'>', + $schema instanceof AvroMapSchema => 'arrayavroTypeToPhpDocInner($schema->values(), $phpNamespace).'>', + $schema instanceof AvroUnionSchema => $this->unionToPhpDoc($schema, $phpNamespace), + default => null, + }; + } + + private function avroTypeToPhpDocInner(AvroSchema $schema, string $phpNamespace): string + { + return match (true) { + $schema instanceof AvroPrimitiveSchema => $this->avroPrimitiveTypeToPhp($schema), + $schema instanceof AvroArraySchema => 'list<'.$this->avroTypeToPhpDocInner($schema->items(), $phpNamespace).'>', + $schema instanceof AvroMapSchema => 'arrayavroTypeToPhpDocInner($schema->values(), $phpNamespace).'>', + $schema instanceof AvroRecordSchema, $schema instanceof AvroEnumSchema => '\\'.$phpNamespace.'\\'.ucwords($schema->name()), + $schema instanceof AvroUnionSchema => $this->unionToPhp($schema, $phpNamespace), + default => 'mixed', + }; + } + + private function unionToPhpDoc(AvroUnionSchema $union, string $phpNamespace): ?string + { + $hasArrayOrMap = false; + $docParts = []; + + foreach ($union->schemas() as $schema) { + if ($schema instanceof AvroArraySchema || $schema instanceof AvroMapSchema) { + $hasArrayOrMap = true; + $docParts[] = $this->avroTypeToPhpDocInner($schema, $phpNamespace); + } else { + $docParts[] = $this->avroTypeToPhp($schema, $phpNamespace); + } + } + + if (!$hasArrayOrMap) { + return null; + } + + return implode('|', array_unique($docParts)); + } +} diff --git a/lang/php/lib/Generator/AvroCodeGeneratorException.php b/lang/php/lib/Generator/AvroCodeGeneratorException.php new file mode 100644 index 00000000000..5a51754fc3d --- /dev/null +++ b/lang/php/lib/Generator/AvroCodeGeneratorException.php @@ -0,0 +1,27 @@ +symbols; } @@ -72,13 +72,14 @@ public function symbols() * @return bool true if the given symbol exists in this * enum schema and false otherwise */ - public function hasSymbol($symbol) + public function hasSymbol($symbol): bool { return in_array($symbol, $this->symbols); } /** * @param int $index + * @throws AvroException * @return string enum schema symbol with the given (zero-based) index */ public function symbolByIndex($index) diff --git a/lang/php/lib/Schema/AvroName.php b/lang/php/lib/Schema/AvroName.php index 8b8f48b6a8d..a83ff88499d 100644 --- a/lang/php/lib/Schema/AvroName.php +++ b/lang/php/lib/Schema/AvroName.php @@ -109,6 +109,11 @@ public function nameAndNamespace(): array return [$this->name, $this->namespace]; } + public function name(): string + { + return $this->name; + } + public function fullname(): string { return $this->fullname; diff --git a/lang/php/lib/Schema/AvroNamedSchema.php b/lang/php/lib/Schema/AvroNamedSchema.php index b34e90f30ca..29fda82b4cd 100644 --- a/lang/php/lib/Schema/AvroNamedSchema.php +++ b/lang/php/lib/Schema/AvroNamedSchema.php @@ -77,6 +77,11 @@ public function toAvro(): string|array return $avro; } + public function name(): string + { + return $this->name->name(); + } + public function qualifiedName(): string { return $this->name->qualifiedName(); diff --git a/lang/php/test/Generator/AvroCodeGeneratorTest.php b/lang/php/test/Generator/AvroCodeGeneratorTest.php new file mode 100644 index 00000000000..8473727795f --- /dev/null +++ b/lang/php/test/Generator/AvroCodeGeneratorTest.php @@ -0,0 +1,1677 @@ +transpiler = new AvroCodeGenerator(); + } + + #[Test] + public function nested_schema_generation(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'MyApp\\Avro\\Generated'); + + self::assertCount(2, $files); + + self::assertArrayHasKey('/generated/Lisp.php', $files); + self::assertArrayHasKey('/generated/Cons.php', $files); + + $expectedLisp = <<value = \$value; + } + public function value(): null|string|\MyApp\Avro\Generated\Cons + { + return \$this->value; + } + public function jsonSerialize(): mixed + { + return ['value' => \$this->value]; + } + } + + PHP; + + self::assertEquals($expectedLisp, $files['/generated/Lisp.php']); + + $expectedLisp = <<car = \$car; + \$this->cdr = \$cdr; + } + public function car(): \MyApp\Avro\Generated\Lisp + { + return \$this->car; + } + public function cdr(): \MyApp\Avro\Generated\Lisp + { + return \$this->cdr; + } + public function jsonSerialize(): mixed + { + return ['car' => \$this->car, 'cdr' => \$this->cdr]; + } + } + + PHP; + self::assertEquals($expectedLisp, $files['/generated/Cons.php']); + } + + #[Test] + public function simple_record_with_primitive_types(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Model'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/User.php', $files); + + $expected = <<name = \$name; + \$this->age = \$age; + \$this->active = \$active; + \$this->score = \$score; + } + public function name(): string + { + return \$this->name; + } + public function age(): int + { + return \$this->age; + } + public function active(): bool + { + return \$this->active; + } + public function score(): float + { + return \$this->score; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'age' => \$this->age, 'active' => \$this->active, 'score' => \$this->score]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/User.php']); + } + + #[Test] + public function enum_schema_generation(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Enums'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Color.php', $files); + + $expected = <<transpiler->translate($avroSchema, '/generated', 'App\\Config'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Config.php', $files); + + $expected = <<retries = \$retries; + \$this->label = \$label; + \$this->enabled = \$enabled; + } + public function retries(): int + { + return \$this->retries; + } + public function label(): string + { + return \$this->label; + } + public function enabled(): bool + { + return \$this->enabled; + } + public function jsonSerialize(): mixed + { + return ['retries' => \$this->retries, 'label' => \$this->label, 'enabled' => \$this->enabled]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Config.php']); + } + + #[Test] + public function record_with_array_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Music'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Playlist.php', $files); + + $expected = << */ + private array \$tags; + /** + * @param list \$tags + */ + public function __construct(string \$name, array \$tags) + { + \$this->name = \$name; + \$this->tags = \$tags; + } + public function name(): string + { + return \$this->name; + } + /** @return list */ + public function tags(): array + { + return \$this->tags; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'tags' => \$this->tags]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Playlist.php']); + } + + #[Test] + public function record_with_map_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Data'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Metadata.php', $files); + + $expected = << */ + private array \$properties; + /** + * @param array \$properties + */ + public function __construct(array \$properties) + { + \$this->properties = \$properties; + } + /** @return array */ + public function properties(): array + { + return \$this->properties; + } + public function jsonSerialize(): mixed + { + return ['properties' => \$this->properties]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Metadata.php']); + } + + #[Test] + public function record_with_enum_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Vehicles'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Car.php', $files); + self::assertArrayHasKey('/generated/FuelType.php', $files); + + $expectedCar = <<brand = \$brand; + \$this->fuel = \$fuel; + } + public function brand(): string + { + return \$this->brand; + } + public function fuel(): \App\Vehicles\FuelType + { + return \$this->fuel; + } + public function jsonSerialize(): mixed + { + return ['brand' => \$this->brand, 'fuel' => \$this->fuel->value]; + } + } + + PHP; + + self::assertEquals($expectedCar, $files['/generated/Car.php']); + + $expectedEnum = <<transpiler->translate($avroSchema, '/generated', 'App\\Social'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Profile.php', $files); + + $expected = <<username = \$username; + \$this->bio = \$bio; + } + public function username(): string + { + return \$this->username; + } + public function bio(): null|string + { + return \$this->bio; + } + public function jsonSerialize(): mixed + { + return ['username' => \$this->username, 'bio' => \$this->bio]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Profile.php']); + } + + #[Test] + public function record_with_all_primitive_types(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Types'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/AllTypes.php', $files); + + $expected = <<nullField = \$nullField; + \$this->boolField = \$boolField; + \$this->intField = \$intField; + \$this->longField = \$longField; + \$this->floatField = \$floatField; + \$this->doubleField = \$doubleField; + \$this->stringField = \$stringField; + \$this->bytesField = \$bytesField; + } + public function nullField(): null + { + return \$this->nullField; + } + public function boolField(): bool + { + return \$this->boolField; + } + public function intField(): int + { + return \$this->intField; + } + public function longField(): int + { + return \$this->longField; + } + public function floatField(): float + { + return \$this->floatField; + } + public function doubleField(): float + { + return \$this->doubleField; + } + public function stringField(): string + { + return \$this->stringField; + } + public function bytesField(): string + { + return \$this->bytesField; + } + public function jsonSerialize(): mixed + { + return ['nullField' => \$this->nullField, 'boolField' => \$this->boolField, 'intField' => \$this->intField, 'longField' => \$this->longField, 'floatField' => \$this->floatField, 'doubleField' => \$this->doubleField, 'stringField' => \$this->stringField, 'bytesField' => \$this->bytesField]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/AllTypes.php']); + } + + #[Test] + public function record_with_nested_array_of_records(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Org'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Team.php', $files); + self::assertArrayHasKey('/generated/Member.php', $files); + + $expectedTeam = << */ + private array \$members; + /** + * @param list<\App\Org\Member> \$members + */ + public function __construct(string \$name, array \$members) + { + \$this->name = \$name; + \$this->members = \$members; + } + public function name(): string + { + return \$this->name; + } + /** @return list<\App\Org\Member> */ + public function members(): array + { + return \$this->members; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'members' => \$this->members]; + } + } + + PHP; + + self::assertEquals($expectedTeam, $files['/generated/Team.php']); + + $expectedMember = <<name = \$name; + \$this->role = \$role; + } + public function name(): string + { + return \$this->name; + } + public function role(): string + { + return \$this->role; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'role' => \$this->role]; + } + } + + PHP; + + self::assertEquals($expectedMember, $files['/generated/Member.php']); + } + + #[Test] + public function record_with_multiple_union_types(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Events'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Event.php', $files); + + $expected = <<payload = \$payload; + } + public function payload(): null|string|int|bool + { + return \$this->payload; + } + public function jsonSerialize(): mixed + { + return ['payload' => \$this->payload]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Event.php']); + } + + #[Test] + public function record_with_nested_record_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Shop'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Order.php', $files); + self::assertArrayHasKey('/generated/Address.php', $files); + + $expectedOrder = <<id = \$id; + \$this->address = \$address; + } + public function id(): int + { + return \$this->id; + } + public function address(): \App\Shop\Address + { + return \$this->address; + } + public function jsonSerialize(): mixed + { + return ['id' => \$this->id, 'address' => \$this->address]; + } + } + + PHP; + + self::assertEquals($expectedOrder, $files['/generated/Order.php']); + + $expectedAddress = <<street = \$street; + \$this->city = \$city; + } + public function street(): string + { + return \$this->street; + } + public function city(): string + { + return \$this->city; + } + public function jsonSerialize(): mixed + { + return ['street' => \$this->street, 'city' => \$this->city]; + } + } + + PHP; + + self::assertEquals($expectedAddress, $files['/generated/Address.php']); + } + + #[Test] + public function enum_with_single_symbol(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Enums'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Singleton.php', $files); + + $expected = <<transpiler->translate($avroSchema, '/generated', 'App\\HR'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Employee.php', $files); + self::assertArrayHasKey('/generated/Manager.php', $files); + + $expectedEmployee = <<name = \$name; + \$this->manager = \$manager; + } + public function name(): string + { + return \$this->name; + } + public function manager(): null|\App\HR\Manager + { + return \$this->manager; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'manager' => \$this->manager]; + } + } + + PHP; + + self::assertEquals($expectedEmployee, $files['/generated/Employee.php']); + + $expectedManager = <<name = \$name; + \$this->department = \$department; + } + public function name(): string + { + return \$this->name; + } + public function department(): string + { + return \$this->department; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'department' => \$this->department]; + } + } + + PHP; + + self::assertEquals($expectedManager, $files['/generated/Manager.php']); + } + + #[Test] + public function record_with_map_of_records(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Library'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Library.php', $files); + self::assertArrayHasKey('/generated/Book.php', $files); + + $expectedLibrary = << */ + private array \$books; + /** + * @param array \$books + */ + public function __construct(string \$name, array \$books) + { + \$this->name = \$name; + \$this->books = \$books; + } + public function name(): string + { + return \$this->name; + } + /** @return array */ + public function books(): array + { + return \$this->books; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'books' => \$this->books]; + } + } + + PHP; + + self::assertEquals($expectedLibrary, $files['/generated/Library.php']); + + $expectedBook = <<title = \$title; + \$this->pages = \$pages; + } + public function title(): string + { + return \$this->title; + } + public function pages(): int + { + return \$this->pages; + } + public function jsonSerialize(): mixed + { + return ['title' => \$this->title, 'pages' => \$this->pages]; + } + } + + PHP; + + self::assertEquals($expectedBook, $files['/generated/Book.php']); + } + + #[Test] + public function record_with_record_reuse_by_name(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Billing'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Invoice.php', $files); + self::assertArrayHasKey('/generated/PostalAddress.php', $files); + + $expectedInvoice = <<id = \$id; + \$this->billingAddress = \$billingAddress; + \$this->shippingAddress = \$shippingAddress; + } + public function id(): int + { + return \$this->id; + } + public function billingAddress(): \App\Billing\PostalAddress + { + return \$this->billingAddress; + } + public function shippingAddress(): \App\Billing\PostalAddress + { + return \$this->shippingAddress; + } + public function jsonSerialize(): mixed + { + return ['id' => \$this->id, 'billingAddress' => \$this->billingAddress, 'shippingAddress' => \$this->shippingAddress]; + } + } + + PHP; + + self::assertEquals($expectedInvoice, $files['/generated/Invoice.php']); + } + + #[Test] + public function record_with_array_default_value(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Config'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Settings.php', $files); + + $expected = << */ + private array \$tags = []; + /** + * @param list \$tags + */ + public function __construct(array \$tags = []) + { + \$this->tags = \$tags; + } + /** @return list */ + public function tags(): array + { + return \$this->tags; + } + public function jsonSerialize(): mixed + { + return ['tags' => \$this->tags]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Settings.php']); + } + + #[Test] + public function record_with_mixed_default_and_required_fields(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Inventory'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Item.php', $files); + + $expected = <<name = \$name; + \$this->quantity = \$quantity; + \$this->description = \$description; + } + public function name(): string + { + return \$this->name; + } + public function quantity(): int + { + return \$this->quantity; + } + public function description(): string + { + return \$this->description; + } + public function jsonSerialize(): mixed + { + return ['name' => \$this->name, 'quantity' => \$this->quantity, 'description' => \$this->description]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Item.php']); + } + + #[Test] + public function record_with_nullable_enum_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Tasks'); + + self::assertCount(2, $files); + self::assertArrayHasKey('/generated/Task.php', $files); + self::assertArrayHasKey('/generated/Priority.php', $files); + + $expectedTask = <<title = \$title; + \$this->priority = \$priority; + } + public function title(): string + { + return \$this->title; + } + public function priority(): null|\App\Tasks\Priority + { + return \$this->priority; + } + public function jsonSerialize(): mixed + { + return ['title' => \$this->title, 'priority' => \$this->priority?->value]; + } + } + + PHP; + + self::assertEquals($expectedTask, $files['/generated/Task.php']); + + $expectedPriority = <<transpiler->translate($avroSchema, '/generated', 'App\\Reports'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Report.php', $files); + + $expected = << */ + private null|array \$scores = null; + /** + * @param null|list \$scores + */ + public function __construct(string \$title, null|array \$scores = null) + { + \$this->title = \$title; + \$this->scores = \$scores; + } + public function title(): string + { + return \$this->title; + } + /** @return null|list */ + public function scores(): null|array + { + return \$this->scores; + } + public function jsonSerialize(): mixed + { + return ['title' => \$this->title, 'scores' => \$this->scores]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Report.php']); + } + + #[Test] + public function record_with_nullable_map_field(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\UI'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Dashboard.php', $files); + + $expected = << */ + private null|array \$widgets = null; + /** + * @param null|array \$widgets + */ + public function __construct(null|array \$widgets = null) + { + \$this->widgets = \$widgets; + } + /** @return null|array */ + public function widgets(): null|array + { + return \$this->widgets; + } + public function jsonSerialize(): mixed + { + return ['widgets' => \$this->widgets]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Dashboard.php']); + } + + #[Test] + public function record_with_nested_array_of_arrays(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Math'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Matrix.php', $files); + + $expected = <<> */ + private array \$rows; + /** + * @param list> \$rows + */ + public function __construct(array \$rows) + { + \$this->rows = \$rows; + } + /** @return list> */ + public function rows(): array + { + return \$this->rows; + } + public function jsonSerialize(): mixed + { + return ['rows' => \$this->rows]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Matrix.php']); + } + + #[Test] + public function record_with_map_of_arrays(): void + { + $schema = <<transpiler->translate($avroSchema, '/generated', 'App\\Search'); + + self::assertCount(1, $files); + self::assertArrayHasKey('/generated/Index.php', $files); + + $expected = <<> */ + private array \$entries; + /** + * @param array> \$entries + */ + public function __construct(array \$entries) + { + \$this->entries = \$entries; + } + /** @return array> */ + public function entries(): array + { + return \$this->entries; + } + public function jsonSerialize(): mixed + { + return ['entries' => \$this->entries]; + } + } + + PHP; + + self::assertEquals($expected, $files['/generated/Index.php']); + } +}