diff --git a/.codeclimate.yml b/.codeclimate.yml index 92095bb..e9dc751 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -4,7 +4,7 @@ checks: argument-count: enabled: true config: - threshold: 4 + threshold: 5 complex-logic: enabled: true config: @@ -24,7 +24,7 @@ checks: method-lines: enabled: true config: - threshold: 25 + threshold: 30 nested-control-flow: enabled: true config: @@ -32,7 +32,7 @@ checks: return-statements: enabled: true config: - threshold: 4 + threshold: 5 similar-code: enabled: true config: @@ -42,8 +42,8 @@ checks: config: threshold: #language-specific defaults. overrides affect all languages. exclude_patterns: - - "src/Parser/TokenParser.php" - "src/Command" + - "src/Parser/ClassParser.php" - "tests/" - "**/vendor/" - "example/" diff --git a/README.md b/README.md index ba64bac..ce4f1be 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Avro schema generator for PHP [![Actions Status](https://github.com/php-kafka/php-avro-schema-generator/workflows/CI/badge.svg)](https://github.com/php-kafka/php-avro-schema-generator/workflows/CI/badge.svg) [![Maintainability](https://api.codeclimate.com/v1/badges/41aecf21566d7e9bfb69/maintainability)](https://codeclimate.com/github/php-kafka/php-avro-schema-generator/maintainability) -[![Test Coverage](https://api.codeclimate.com/v1/badges/41aecf21566d7e9bfb69/test_coverage)](https://codeclimate.com/github/php-kafka/php-avro-schema-generator/test_coverage) +[![Test Coverage](https://api.codeclimate.com/v1/badges/41aecf21566d7e9bfb69/test_coverage)](https://codeclimate.com/github/php-kafka/php-avro-schema-generator/test_coverage) +![Supported PHP versions: 7.4 .. 8.x](https://img.shields.io/badge/php-7.4%20..%208.x-blue.svg) [![Latest Stable Version](https://poser.pugx.org/php-kafka/php-avro-schema-generator/v/stable)](https://packagist.org/packages/php-kafka/php-avro-schema-generator) ## Installation diff --git a/composer.json b/composer.json index 4a93cdb..8eb2900 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "require": { "ext-json": "*", "flix-tech/avro-php": "^3.0|^4.0", - "symfony/console": "^4.3|^5.1" + "symfony/console": "^4.3|^5.1", + "nikic/php-parser": "^4.13", + "pimple/pimple": "^3.5" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.15", diff --git a/example/classes/SomeBaseClass.php b/example/classes/SomeBaseClass.php index 1079adf..4e12dc5 100644 --- a/example/classes/SomeBaseClass.php +++ b/example/classes/SomeBaseClass.php @@ -8,7 +8,6 @@ abstract class SomeBaseClass { - /** * @var Wonderland */ diff --git a/phpstan.neon b/phpstan.neon index a39b4ad..d24029b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,8 +1,4 @@ parameters: level: 8 paths: [ src ] - checkGenericClassInNonGenericObjectType: false - ignoreErrors: - - "#Call to function token_get_all\\(\\) on a separate line has no effect.#" - - "#Strict comparison using === between non-empty-array and ',' will always evaluate to false.#" - - "#Strict comparison using === between non-empty-array and ';' will always evaluate to false.#" \ No newline at end of file + checkGenericClassInNonGenericObjectType: false \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index cff2887..40ef934 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,7 @@ src/Command + src/AppContainer.php diff --git a/src/AppContainer.php b/src/AppContainer.php new file mode 100644 index 0000000..db8ecb4 --- /dev/null +++ b/src/AppContainer.php @@ -0,0 +1,35 @@ +register(new GeneratorServiceProvider()) + ->register(new MergerServiceProvider()) + ->register(new ParserServiceProvider()) + ->register(new ConverterServiceProvider()) + ->register(new RegistryServiceProvider()) + ->register(new CommandServiceProvider()); + + + return $container; + } +} diff --git a/src/Avro/Avro.php b/src/Avro/Avro.php index db3a021..ebd28be 100644 --- a/src/Avro/Avro.php +++ b/src/Avro/Avro.php @@ -20,4 +20,24 @@ class Avro 'map' => self::LONELIEST_NUMBER, 'fixed' => self::LONELIEST_NUMBER, ]; + + /** + * @var string[] + */ + public const MAPPED_TYPES = array( + 'null' => 'null', + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'string' => 'string', + 'int' => 'int', + 'integer' => 'int', + 'float' => 'float', + 'double' => 'double', + 'array' => 'array', + 'object' => 'object', + 'callable' => 'callable', + 'resource' => 'resource', + 'mixed' => 'mixed', + 'Collection' => 'array', + ); } diff --git a/src/Command/SchemaGenerateCommand.php b/src/Command/SchemaGenerateCommand.php index bed3639..b88541d 100644 --- a/src/Command/SchemaGenerateCommand.php +++ b/src/Command/SchemaGenerateCommand.php @@ -5,7 +5,9 @@ namespace PhpKafka\PhpAvroSchemaGenerator\Command; use PhpKafka\PhpAvroSchemaGenerator\Generator\SchemaGenerator; +use PhpKafka\PhpAvroSchemaGenerator\Generator\SchemaGeneratorInterface; use PhpKafka\PhpAvroSchemaGenerator\Registry\ClassRegistry; +use PhpKafka\PhpAvroSchemaGenerator\Registry\ClassRegistryInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -13,6 +15,19 @@ class SchemaGenerateCommand extends Command { + private SchemaGeneratorInterface $generator; + private ClassRegistryInterface $classRegistry; + + public function __construct( + ClassRegistryInterface $classRegistry, + SchemaGeneratorInterface $generator, + string $name = null + ) { + $this->classRegistry = $classRegistry; + $this->generator = $generator; + parent::__construct($name); + } + protected function configure(): void { $this @@ -36,15 +51,12 @@ public function execute(InputInterface $input, OutputInterface $output): int $classDirectory = $this->getPath($classDirectoryArg); $outputDirectory = $this->getPath($outputDirectoryArg); - $registry = (new ClassRegistry()) - ->addClassDirectory($classDirectory) - ->load(); - - $generator = new SchemaGenerator($registry, $outputDirectory); - - $schemas = $generator->generate(); + $registry = $this->classRegistry->addClassDirectory($classDirectory)->load(); + $this->generator->setOutputDirectory($outputDirectory); + $this->generator->setClassRegistry($registry); - $result = $generator->exportSchemas($schemas); + $schemas = $this->generator->generate(); + $result = $this->generator->exportSchemas($schemas); // retrieve the argument value using getArgument() $output->writeln(sprintf('Generated %d schema files', $result)); diff --git a/src/Command/SubSchemaMergeCommand.php b/src/Command/SubSchemaMergeCommand.php index d518efa..f318984 100644 --- a/src/Command/SubSchemaMergeCommand.php +++ b/src/Command/SubSchemaMergeCommand.php @@ -4,12 +4,14 @@ namespace PhpKafka\PhpAvroSchemaGenerator\Command; +use PhpKafka\PhpAvroSchemaGenerator\Merger\SchemaMergerInterface; use PhpKafka\PhpAvroSchemaGenerator\Optimizer\FieldOrderOptimizer; use PhpKafka\PhpAvroSchemaGenerator\Optimizer\FullNameOptimizer; use PhpKafka\PhpAvroSchemaGenerator\Optimizer\OptimizerInterface; use PhpKafka\PhpAvroSchemaGenerator\Optimizer\PrimitiveSchemaOptimizer; use PhpKafka\PhpAvroSchemaGenerator\Registry\SchemaRegistry; use PhpKafka\PhpAvroSchemaGenerator\Merger\SchemaMerger; +use PhpKafka\PhpAvroSchemaGenerator\Registry\SchemaRegistryInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -18,12 +20,26 @@ class SubSchemaMergeCommand extends Command { + private SchemaRegistryInterface $schemaRegistry; + private SchemaMergerInterface $schemaMerger; + /** @var string[] */ - protected $optimizerOptionMapping = [ + protected array $optimizerOptionMapping = [ 'optimizeFieldOrder' => FieldOrderOptimizer::class, 'optimizeFullNames' => FullNameOptimizer::class, 'optimizePrimitiveSchemas' => PrimitiveSchemaOptimizer::class, ]; + + public function __construct( + SchemaMergerInterface $schemaMerger, + SchemaRegistryInterface $schemaRegistry, + string $name = null + ) { + $this->schemaMerger = $schemaMerger; + $this->schemaRegistry = $schemaRegistry; + parent::__construct($name); + } + protected function configure(): void { $this @@ -71,20 +87,19 @@ public function execute(InputInterface $input, OutputInterface $output): int $templateDirectory = $this->getPath($templateDirectoryArg); $outputDirectory = $this->getPath($outputDirectoryArg); - $registry = (new SchemaRegistry()) - ->addSchemaTemplateDirectory($templateDirectory) - ->load(); + $registry = $this->schemaRegistry->addSchemaTemplateDirectory($templateDirectory)->load(); - $merger = new SchemaMerger($registry, $outputDirectory); + $this->schemaMerger->setSchemaRegistry($registry); + $this->schemaMerger->setOutputDirectory($outputDirectory); /** @var OptimizerInterface $optimizerClass */ foreach ($this->optimizerOptionMapping as $optionName => $optimizerClass) { if (true === (bool) $input->getOption($optionName)) { - $merger->addOptimizer(new $optimizerClass()); + $this->schemaMerger->addOptimizer(new $optimizerClass()); } } - $result = $merger->merge( + $result = $this->schemaMerger->merge( (bool) $input->getOption('prefixWithNamespace'), (bool) $input->getOption('useFilenameAsSchemaName') ); diff --git a/src/Converter/PhpClassConverter.php b/src/Converter/PhpClassConverter.php new file mode 100644 index 0000000..9fab08e --- /dev/null +++ b/src/Converter/PhpClassConverter.php @@ -0,0 +1,244 @@ + + */ + private array $typesToSkip = [ + 'null' => 1, + 'object' => 1, + 'callable' => 1, + 'resource' => 1, + 'mixed' => 1 + ]; + + /** + * @param ClassParserInterface $parser + */ + public function __construct(ClassParserInterface $parser) + { + $this->parser = $parser; + } + + + public function convert(string $phpClass): ?PhpClassInterface + { + $this->parser->setCode($phpClass); + + if (null === $this->parser->getClassName()) { + return null; + } + + $convertedProperties = $this->getConvertedProperties($this->parser->getProperties()); + + return new PhpClass( + $this->parser->getClassName(), + $this->parser->getNamespace(), + $phpClass, + $convertedProperties + ); + } + + /** + * @param PhpClassPropertyInterface[] $properties + * @return PhpClassPropertyInterface[] + */ + private function getConvertedProperties(array $properties): array + { + $convertedProperties = []; + foreach ($properties as $property) { + if (false === is_string($property->getPropertyType())) { + continue; + } + + $convertedType = $this->getConvertedType($property->getPropertyType()); + + if (null === $convertedType) { + continue; + } + + $convertedProperties[] = new PhpClassProperty( + $property->getPropertyName(), + $convertedType, + $property->getPropertyDefault(), + $property->getPropertyDoc(), + $property->getPropertyLogicalType() + ); + } + + return $convertedProperties; + } + + /** + * @param string $type + * @return string|string[]|null + */ + private function getConvertedType(string $type) + { + $types = explode('|', $type); + + if (1 === count($types)) { + return $this->getFullTypeName($type); + } + + return $this->getConvertedUnionType($types); + } + + private function getFullTypeName(string $type): ?string + { + if (true === isset(Avro::MAPPED_TYPES[$type])) { + $type = Avro::MAPPED_TYPES[$type]; + } + + if (true === isset($this->typesToSkip[$type])) { + return null; + } + + if (true === isset(Avro::BASIC_TYPES[$type])) { + return $type; + } + + $usedClasses = $this->parser->getUsedClasses(); + + if (true === isset($usedClasses[$type])) { + return $this->convertNamespace($usedClasses[$type]); + } + + if (null !== $this->parser->getNamespace()) { + return $this->convertNamespace($this->parser->getNamespace() . '\\' . $type); + } + + return $type; + } + + /** + * @param string[] $types + * @return array + */ + private function getConvertedUnionType(array $types): array + { + $convertedUnionType = []; + + foreach ($types as $type) { + if (true === isset($this->typesToSkip[$type])) { + continue; + } + + if (false === $this->isArrayType($type)) { + $convertedUnionType[] = $this->getFullTypeName($type); + } + } + + $arrayType = $this->getArrayType($types); + + if (0 !== count($convertedUnionType) && [] !== $arrayType) { + $convertedUnionType[] = $arrayType; + } else { + return $arrayType; + } + + return $convertedUnionType; + } + + /** + * @param string[] $types + * @return string[] + */ + private function getArrayType(array $types): array + { + $itemPrefix = '['; + $itemSuffix = ']'; + + $arrayTypes = $this->getArrayTypes($types); + + if (0 === count($arrayTypes)) { + return []; + } + + $arrayTypes = $this->getCleanedArrayTypes($arrayTypes); + + if (0 === count($arrayTypes)) { + $arrayTypes[] = 'string'; + } + + if (1 === count($arrayTypes)) { + $itemPrefix = ''; + $itemSuffix = ''; + } + + return [ + 'type' => 'array', + 'items' => $itemPrefix . implode(',', $arrayTypes) . $itemSuffix + ]; + } + + /** + * @param string[] $types + * @return string[] + */ + private function getArrayTypes(array $types): array + { + $arrayTypes = []; + + foreach ($types as $type) { + if (true === $this->isArrayType($type)) { + $arrayTypes[] = $type; + } + } + + return $arrayTypes; + } + + /** + * @param string[] $arrayTypes + * @return string[] + */ + private function getCleanedArrayTypes(array $arrayTypes): array + { + foreach ($arrayTypes as $idx => $arrayType) { + if ('array' === $arrayType) { + unset($arrayTypes[$idx]); + continue; + } + + $cleanedType = str_replace('[]', '', $arrayType); + + if (null === $this->getFullTypeName($cleanedType)) { + unset($arrayTypes[$idx]); + continue; + } + + $arrayTypes[$idx] = $this->getFullTypeName($cleanedType); + } + + return $arrayTypes; + } + + private function isArrayType(string $type): bool + { + if ('array' === $type || str_contains($type, '[]')) { + return true; + } + + return false; + } + + private function convertNamespace(string $namespace): string + { + return str_replace('\\', '.', $namespace); + } +} diff --git a/src/Converter/PhpClassConverterInterface.php b/src/Converter/PhpClassConverterInterface.php new file mode 100644 index 0000000..8f8730d --- /dev/null +++ b/src/Converter/PhpClassConverterInterface.php @@ -0,0 +1,12 @@ + 1, - 'object' => 1, - 'callable' => 1, - 'resource' => 1, - 'mixed' => 1 - ]; - - /** - * @var string - */ - private $outputDirectory; + private string $outputDirectory; /** - * @var ClassRegistryInterface + * @var ?ClassRegistryInterface */ - private $classRegistry; + private ?ClassRegistryInterface $classRegistry; - public function __construct(ClassRegistryInterface $classRegistry, string $outputDirectory = '/tmp') + public function __construct(string $outputDirectory = '/tmp') { - $this->classRegistry = $classRegistry; $this->outputDirectory = $outputDirectory; } /** - * @return ClassRegistryInterface + * @return ClassRegistryInterface|null */ - public function getClassRegistry(): ClassRegistryInterface + public function getClassRegistry(): ?ClassRegistryInterface { return $this->classRegistry; } + /** + * @param ClassRegistryInterface $classRegistry + */ + public function setClassRegistry(ClassRegistryInterface $classRegistry): void + { + $this->classRegistry = $classRegistry; + } + /** * @return string */ @@ -54,6 +48,14 @@ public function getOutputDirectory(): string return $this->outputDirectory; } + /** + * @param string $outputDirectory + */ + public function setOutputDirectory(string $outputDirectory): void + { + $this->outputDirectory = $outputDirectory; + } + /** * @return array */ @@ -61,39 +63,62 @@ public function generate(): array { $schemas = []; + if (null === $this->getClassRegistry()) { + throw new RuntimeException('Please set a ClassRegistry for the generator'); + } + /** @var PhpClassInterface $class */ foreach ($this->getClassRegistry()->getClasses() as $class) { $schema = []; $schema['type'] = 'record'; $schema['name'] = $class->getClassName(); - $schema['namespace'] = $this->convertNamespace($class->getClassNamespace()); + if (null !== $this->convertNamespace($class->getClassNamespace())) { + $schema['namespace'] = $this->convertNamespace($class->getClassNamespace()); + } $schema['fields'] = []; /** @var PhpClassPropertyInterface $property */ foreach ($class->getClassProperties() as $property) { - if (true === isset($this->typesToSkip[$property->getPropertyType()])) { - continue; - } - - $field = ['name' => $property->getPropertyName()]; - if ('array' === $property->getPropertyType()) { - $field['type'] = [ - 'type' => $property->getPropertyType(), - 'items' => $this->convertNamespace($property->getPropertyArrayType() ?? 'string') - ]; - } else { - $field['type'] = $this->convertNamespace($property->getPropertyType()); - } - + $field = $this->getFieldForProperty($property); $schema['fields'][] = $field; } - $schemas[$schema['namespace'] . '.' . $schema['name']] = json_encode($schema); + if (false === isset($schema['namespace'])) { + $namespace = $schema['name']; + } else { + $namespace = $schema['namespace'] . '.' . $schema['name']; + } + + $schemas[$namespace] = json_encode($schema); } return $schemas; } + /** + * @param PhpClassPropertyInterface $property + * @return array + */ + private function getFieldForProperty(PhpClassPropertyInterface $property): array + { + $field = ['name' => $property->getPropertyName()]; + $field['type'] = $property->getPropertyType(); + + if (PhpClassPropertyInterface::NO_DEFAULT !== $property->getPropertyDefault()) { + $field['default'] = $property->getPropertyDefault(); + } + + if (null !== $property->getPropertyDoc() && '' !== $property->getPropertyDoc()) { + $field['doc'] = $property->getPropertyDoc(); + } + + if (null !== $property->getPropertyLogicalType()) { + $field['logicalType'] = $property->getPropertyLogicalType(); + } + + return $field; + } + /** * @param array $schemas * @return int @@ -121,11 +146,15 @@ private function getSchemaFilename(string $schemaName): string } /** - * @param string $namespace - * @return string + * @param string|null $namespace + * @return string|null */ - private function convertNamespace(string $namespace): string + private function convertNamespace(?string $namespace): ?string { + if (null === $namespace) { + return null; + } + return str_replace('\\', '.', $namespace); } } diff --git a/src/Generator/SchemaGeneratorInterface.php b/src/Generator/SchemaGeneratorInterface.php index 08f59d1..92a0a30 100644 --- a/src/Generator/SchemaGeneratorInterface.php +++ b/src/Generator/SchemaGeneratorInterface.php @@ -9,15 +9,25 @@ interface SchemaGeneratorInterface { /** - * @return ClassRegistryInterface + * @return ClassRegistryInterface|null */ - public function getClassRegistry(): ClassRegistryInterface; + public function getClassRegistry(): ?ClassRegistryInterface; + + /** + * @param ClassRegistryInterface $classRegistry + */ + public function setClassRegistry(ClassRegistryInterface $classRegistry): void; /** * @return string */ public function getOutputDirectory(): string; + /** + * @param string $outputDirectory + */ + public function setOutputDirectory(string $outputDirectory): void; + /** * @return array */ diff --git a/src/Merger/SchemaMerger.php b/src/Merger/SchemaMerger.php index f2b6146..70a20f7 100644 --- a/src/Merger/SchemaMerger.php +++ b/src/Merger/SchemaMerger.php @@ -11,38 +11,40 @@ use PhpKafka\PhpAvroSchemaGenerator\Optimizer\OptimizerInterface; use PhpKafka\PhpAvroSchemaGenerator\Registry\SchemaRegistryInterface; use PhpKafka\PhpAvroSchemaGenerator\Schema\SchemaTemplateInterface; +use RuntimeException; final class SchemaMerger implements SchemaMergerInterface { - /** - * @var string - */ - private $outputDirectory; + private string $outputDirectory; - /** - * @var SchemaRegistryInterface - */ - private $schemaRegistry; + private ?SchemaRegistryInterface $schemaRegistry; /** * @var OptimizerInterface[] */ - private $optimizers = []; + private array $optimizers = []; - public function __construct(SchemaRegistryInterface $schemaRegistry, string $outputDirectory = '/tmp') + public function __construct(string $outputDirectory = '/tmp') { - $this->schemaRegistry = $schemaRegistry; $this->outputDirectory = $outputDirectory; } /** - * @return SchemaRegistryInterface + * @return SchemaRegistryInterface|null */ - public function getSchemaRegistry(): SchemaRegistryInterface + public function getSchemaRegistry(): ?SchemaRegistryInterface { return $this->schemaRegistry; } + /** + * @param SchemaRegistryInterface $schemaRegistry + */ + public function setSchemaRegistry(SchemaRegistryInterface $schemaRegistry): void + { + $this->schemaRegistry = $schemaRegistry; + } + /** * @return string */ @@ -51,6 +53,14 @@ public function getOutputDirectory(): string return $this->outputDirectory; } + /** + * @param string $outputDirectory + */ + public function setOutputDirectory(string $outputDirectory): void + { + $this->outputDirectory = $outputDirectory; + } + /** * @param SchemaTemplateInterface $rootSchemaTemplate * @return SchemaTemplateInterface @@ -59,6 +69,10 @@ public function getOutputDirectory(): string */ public function getResolvedSchemaTemplate(SchemaTemplateInterface $rootSchemaTemplate): SchemaTemplateInterface { + if (null === $this->getSchemaRegistry()) { + throw new RuntimeException('Please set a SchemaRegistery for the merger'); + } + $rootDefinition = $rootSchemaTemplate->getSchemaDefinition(); do { @@ -70,9 +84,11 @@ public function getResolvedSchemaTemplate(SchemaTemplateInterface $rootSchemaTem if (false === strpos($e->getMessage(), ' is not a schema we know about.')) { throw $e; } + $exceptionThrown = true; $schemaId = $this->getSchemaIdFromExceptionMessage($e->getMessage()); - $embeddedTemplate = $this->schemaRegistry->getSchemaById($schemaId); + $embeddedTemplate = $this->getSchemaRegistry()->getSchemaById($schemaId); + if (null === $embeddedTemplate) { throw new SchemaMergerException( sprintf(SchemaMergerException::UNKNOWN_SCHEMA_TYPE_EXCEPTION_MESSAGE, $schemaId) @@ -120,6 +136,10 @@ public function merge( $mergedFiles = 0; $registry = $this->getSchemaRegistry(); + if (null === $registry) { + throw new RuntimeException('Please set a SchemaRegistery for the merger'); + } + /** @var SchemaTemplateInterface $rootSchemaTemplate */ foreach ($registry->getRootSchemas() as $rootSchemaTemplate) { try { diff --git a/src/Merger/SchemaMergerInterface.php b/src/Merger/SchemaMergerInterface.php index ea847a3..33f7e5a 100644 --- a/src/Merger/SchemaMergerInterface.php +++ b/src/Merger/SchemaMergerInterface.php @@ -11,15 +11,25 @@ interface SchemaMergerInterface { /** - * @return SchemaRegistryInterface + * @return SchemaRegistryInterface|null */ - public function getSchemaRegistry(): SchemaRegistryInterface; + public function getSchemaRegistry(): ?SchemaRegistryInterface; + + /** + * @param SchemaRegistryInterface $schemaRegistry + */ + public function setSchemaRegistry(SchemaRegistryInterface $schemaRegistry): void; /** * @return string */ public function getOutputDirectory(): string; + /** + * @param string $outputDirectory + */ + public function setOutputDirectory(string $outputDirectory): void; + /** * @param SchemaTemplateInterface $rootSchemaTemplate * @return SchemaTemplateInterface @@ -27,9 +37,11 @@ public function getOutputDirectory(): string; public function getResolvedSchemaTemplate(SchemaTemplateInterface $rootSchemaTemplate): SchemaTemplateInterface; /** + * @param bool $prefixWithNamespace + * @param bool $useTemplateName * @return int */ - public function merge(): int; + public function merge(bool $prefixWithNamespace = false, bool $useTemplateName = false): int; /** * @param SchemaTemplateInterface $rootRootSchemaTemplate diff --git a/src/Parser/ClassParser.php b/src/Parser/ClassParser.php new file mode 100644 index 0000000..e23a069 --- /dev/null +++ b/src/Parser/ClassParser.php @@ -0,0 +1,214 @@ +parser = $parser; + $this->propertyParser = $propertyParser; + } + + public function setCode(string $code): void + { + $this->statements = $this->parser->parse($code); + } + + /** + * @return string|null + */ + public function getClassName(): ?string + { + if (null === $this->statements) { + return null; + } + + foreach ($this->statements as $statement) { + if ($statement instanceof Namespace_) { + foreach ($statement->stmts as $nsStatement) { + if ($nsStatement instanceof Class_) { + if ($nsStatement->name instanceof Identifier) { + return $nsStatement->name->name; + } + } + } + } + } + + return null; + } + + /** + * @return string|null + */ + public function getParentClassName(): ?string + { + if (null === $this->statements) { + return null; + } + + foreach ($this->statements as $statement) { + if ($statement instanceof Namespace_) { + foreach ($statement->stmts as $nsStatement) { + if ($nsStatement instanceof Class_) { + if (null !== $nsStatement->extends) { + return implode('\\', $nsStatement->extends->parts); + } + } + } + } else { + if ($statement instanceof Class_) { + if (null !== $statement->extends) { + return implode('\\', $statement->extends->parts); + } + } + } + } + + return null; + } + + public function getUsedClasses(): array + { + $usedClasses = []; + + if (null === $this->statements) { + return $usedClasses; + } + + foreach ($this->statements as $statement) { + if ($statement instanceof Namespace_) { + foreach ($statement->stmts as $nStatement) { + if ($nStatement instanceof Use_) { + /** @var UseUse $use */ + foreach ($nStatement->uses as $use) { + $className = $use->name->parts[array_key_last($use->name->parts)]; + $usedClasses[$className] = implode('\\', $use->name->parts); + } + } + } + } + } + + return $usedClasses; + } + + /** + * @return string + */ + public function getNamespace(): ?string + { + if (null === $this->statements) { + return null; + } + + foreach ($this->statements as $statement) { + if ($statement instanceof Namespace_) { + if ($statement->name instanceof Name) { + return implode('\\', $statement->name->parts); + } + } + } + + return null; + } + + /** + * @return PhpClassPropertyInterface[] + */ + public function getProperties(): array + { + $properties = $this->getClassProperties($this->statements ?? []); + + $parentStatements = $this->getParentClassStatements(); + + if (true === is_array($parentStatements)) { + $properties = array_merge($properties, $this->getClassProperties($parentStatements)); + } + + return $properties; + } + + /** + * @param Stmt[] $statements + * @return PhpClassPropertyInterface[] + */ + private function getClassProperties(array $statements): array + { + $properties = []; + + foreach ($statements as $statement) { + if ($statement instanceof Namespace_) { + foreach ($statement->stmts as $nsStatement) { + if ($nsStatement instanceof Class_) { + foreach ($nsStatement->stmts as $pStatement) { + if ($pStatement instanceof Property) { + $properties[] = $this->propertyParser->parseProperty($pStatement); + } + } + } + } + } + } + + return $properties; + } + + /** + * @return Stmt[]|null + * @throws ReflectionException + */ + private function getParentClassStatements(): ?array + { + /** @var class-string[] $usedClasses */ + $usedClasses = $this->getUsedClasses(); + $parentClass = $this->getParentClassName(); + + if (null === $parentClass) { + return []; + } + + if (null !== $usedClasses[$this->getParentClassName()]) { + $parentClass = $usedClasses[$this->getParentClassName()]; + } + + $rc = new ReflectionClass($parentClass); + $filename = $rc->getFileName(); + + if (false === $filename) { + return []; + } + + $parentClass = file_get_contents($filename); + + if (false === $parentClass) { + // @codeCoverageIgnoreStart + return []; + // @codeCoverageIgnoreEnd + } + + return $this->parser->parse($parentClass); + } +} diff --git a/src/Parser/ClassParserInterface.php b/src/Parser/ClassParserInterface.php new file mode 100644 index 0000000..1d685d4 --- /dev/null +++ b/src/Parser/ClassParserInterface.php @@ -0,0 +1,28 @@ + + */ + public function getUsedClasses(): array; + + public function getParentClassName(): ?string; + + public function setCode(string $code): void; +} diff --git a/src/Parser/ClassPropertyParser.php b/src/Parser/ClassPropertyParser.php new file mode 100644 index 0000000..870632f --- /dev/null +++ b/src/Parser/ClassPropertyParser.php @@ -0,0 +1,179 @@ +docParser = $docParser; + } + + /** + * @param Property|mixed $property + * @return PhpClassPropertyInterface + */ + public function parseProperty($property): PhpClassPropertyInterface + { + if (false === $property instanceof Property) { + throw new RuntimeException(sprintf('Property must be of type: %s', Property::class)); + } + + $propertyAttributes = $this->getPropertyAttributes($property); + + return new PhpClassProperty( + $propertyAttributes['name'], + $propertyAttributes['types'], + $propertyAttributes['default'], + $propertyAttributes['doc'], + $propertyAttributes['logicalType'] + ); + } + + /** + * @param Property $property + * @return array + */ + private function getPropertyAttributes(Property $property): array + { + $attributes = $this->getEmptyAttributesArray(); + $docComments = $this->getAllPropertyDocComments($property); + $attributes['name'] = $this->getPropertyName($property); + + $attributes['types'] = $this->getTypeFromDocComment($docComments); + if (null === $attributes['types']) { + $attributes['types'] = $this->getPropertyType($property, $docComments); + } + + $attributes['default'] = $this->getDefaultFromDocComment($docComments); + $attributes['doc'] = $this->getDocFromDocComment($docComments); + $attributes['logicalType'] = $this->getLogicalTypeFromDocComment($docComments); + + return $attributes; + } + + private function getPropertyName(Property $property): string + { + return $property->props[0]->name->name; + } + + /** + * @param Property $property + * @param array $docComments + * @return string + */ + private function getPropertyType(Property $property, array $docComments): string + { + if ($property->type instanceof Identifier) { + return Avro::MAPPED_TYPES[$property->type->name] ?? $property->type->name; + } elseif ($property->type instanceof UnionType) { + $types = ''; + $separator = ''; + /** @var Identifier $type */ + foreach ($property->type->types as $type) { + $type = Avro::MAPPED_TYPES[$type->name] ?? $type->name; + $types .= $separator . $type; + $separator = ','; + } + + return $types; + } + + return $this->getDocCommentByType($docComments, 'var') ?? 'string'; + } + + /** + * @param array $docComments + * @return mixed + */ + private function getDocCommentByType(array $docComments, string $type) + { + return $docComments[$type] ?? null; + } + + /** + * @param array $docComments + * @return string|null + */ + private function getTypeFromDocComment(array $docComments): ?string + { + return $docComments['avro-type'] ?? null; + } + + /** + * @param array $docComments + * @return string + */ + private function getDefaultFromDocComment(array $docComments): string + { + return $docComments['avro-default'] ?? PhpClassPropertyInterface::NO_DEFAULT; + } + + /** + * @param array $docComments + * @return string|null + */ + private function getLogicalTypeFromDocComment(array $docComments): ?string + { + return $docComments['avro-logical-type'] ?? null; + } + + /** + * @param array $docComments + * @return string|null + */ + private function getDocFromDocComment(array $docComments): ?string + { + return $docComments['avro-doc'] ?? $docComments[DocCommentParserInterface::DOC_DESCRIPTION] ?? null; + } + + /** + * @param Property $property + * @return array + */ + private function getAllPropertyDocComments(Property $property): array + { + $docComments = []; + + foreach ($property->getAttributes() as $attributeName => $attributeValue) { + if ('comments' === $attributeName) { + /** @var Doc $comment */ + foreach ($attributeValue as $comment) { + $docComments = array_merge($docComments, $this->docParser->parseDoc($comment->getText())); + } + } + } + + return $docComments; + } + + /** + * @return array + */ + private function getEmptyAttributesArray(): array + { + return [ + 'name' => null, + 'types' => null, + 'default' => PhpClassPropertyInterface::NO_DEFAULT, + 'logicalType' => null, + 'doc' => null + ]; + } +} diff --git a/src/Parser/ClassPropertyParserInterface.php b/src/Parser/ClassPropertyParserInterface.php new file mode 100644 index 0000000..f180109 --- /dev/null +++ b/src/Parser/ClassPropertyParserInterface.php @@ -0,0 +1,17 @@ + + */ + public function parseDoc(string $docComment): array + { + $doc = []; + $docLines = explode(PHP_EOL, $docComment); + $cleanLines = $this->cleanDocLines($docLines); + $foundFirstAt = false; + + foreach ($cleanLines as $idx => $line) { + if (true === str_starts_with($line, '@')) { + $foundFirstAt = true; + $nextSpace = strpos($line, ' '); + $doc[substr($line, 1, $nextSpace - 1)] = substr($line, $nextSpace + 1); + unset($cleanLines[$idx]); + } elseif (true === $foundFirstAt) { + //ignore other stuff for now + //TODO: Improve multiline @ doc comment + unset($cleanLines[$idx]); + } + } + + $doc[self::DOC_DESCRIPTION] = implode(' ', $cleanLines); + + return $doc; + } + + /** + * @param string[] $docLines + * @return string[] + */ + private function cleanDocLines(array $docLines): array + { + foreach ($docLines as $idx => $docLine) { + $docLines[$idx] = $this->cleanDocLine($docLine); + + if ('' === $docLines[$idx]) { + unset($docLines[$idx]); + } + } + + return $docLines; + } + + private function cleanDocLine(string $docLine): string + { + $trimmedString = ltrim(rtrim($docLine)); + + if (true === str_starts_with($trimmedString, '/**')) { + $trimmedString = substr($trimmedString, 3); + $trimmedString = ltrim($trimmedString); + } + + if (true === str_ends_with($trimmedString, '*/')) { + $trimmedString = substr($trimmedString, 0, strlen($trimmedString) - 2); + $trimmedString = rtrim($trimmedString); + } + + if (true === str_starts_with($trimmedString, '*')) { + $trimmedString = substr($trimmedString, 1); + $trimmedString = ltrim($trimmedString); + } + + return ltrim(rtrim($trimmedString)); + } +} diff --git a/src/Parser/DocCommentParserInterface.php b/src/Parser/DocCommentParserInterface.php new file mode 100644 index 0000000..ccbba2d --- /dev/null +++ b/src/Parser/DocCommentParserInterface.php @@ -0,0 +1,16 @@ + + */ + public function parseDoc(string $docComment): array; +} diff --git a/src/Parser/TokenParser.php b/src/Parser/TokenParser.php deleted file mode 100644 index bf0435e..0000000 --- a/src/Parser/TokenParser.php +++ /dev/null @@ -1,464 +0,0 @@ - - * @author Christian Kaps - */ -class TokenParser -{ - /** - * The token list. - * - * @var array - */ - private $tokens; - - /** - * The number of tokens. - * - * @var int - */ - private $numTokens; - - /** - * @var string - */ - private string $className; - - /** - * @var string - */ - private $namespace = ''; - - /** - * The current array pointer. - * - * @var int - */ - private $pointer = 0; - - /** - * @var string[] - */ - private $ignoredTypes = array( - 'null' => 'null', - 'bool' => 'boolean', - 'boolean' => 'boolean', - 'string' => 'string', - 'int' => 'int', - 'integer' => 'int', - 'float' => 'float', - 'double' => 'double', - 'array' => 'array', - 'object' => 'object', - 'callable' => 'callable', - 'resource' => 'resource', - 'mixed' => 'mixed', - 'Collection' => 'array', - ); - - /** - * @param string $contents - */ - public function __construct($contents) - { - $this->tokens = token_get_all($contents); - - // The PHP parser sets internal compiler globals for certain things. Annoyingly, the last docblock comment it - // saw gets stored in doc_comment. When it comes to compile the next thing to be include()d this stored - // doc_comment becomes owned by the first thing the compiler sees in the file that it considers might have a - // docblock. If the first thing in the file is a class without a doc block this would cause calls to - // getDocBlock() on said class to return our long lost doc_comment. Argh. - // To workaround, cause the parser to parse an empty docblock. Sure getDocBlock() will return this, but at least - // it's harmless to us. - token_get_all("numTokens = count($this->tokens); - } - - /** - * @return string - */ - public function getClassName(): ?string - { - if (true === isset($this->className)) { - return $this->className; - } - - for ($i = 0; $i < count($this->tokens); $i++) { - if ($this->tokens[$i][0] === T_CLASS) { - $this->className = $this->tokens[$i + 2][1]; - return $this->className; - } - } - - return null; - } - - /** - * @return string - */ - public function getNamespace(): string - { - if ('' !== $this->namespace) { - return $this->namespace; - } - - for ($i = 0; $i < count($this->tokens); $i++) { - if ($this->tokens[$i][0] === T_NAMESPACE) { - $index = 2; - while (true) { - if (true === is_string($this->tokens[$i + $index])) { - break 2; - } - $this->namespace .= $this->tokens[$i + $index][1]; - ++$index; - } - } - } - - return $this->namespace; - } - - /** - * Gets all use statements. - * - * @param string $namespaceName The namespace name of the reflected class. - * - * @return array A list with all found use statements. - * @codeCoverageIgnore - * @infection-ignore-all - */ - public function parseUseStatements($namespaceName) - { - $statements = array(); - while (($token = $this->next())) { - if ($token[0] === T_USE) { - $statements = array_merge($statements, $this->parseUseStatement()); - continue; - } - if ($token[0] !== T_NAMESPACE || $this->parseNamespace() != $namespaceName) { - continue; - } - - // Get fresh array for new namespace. This is to prevent the parser to collect the use statements - // for a previous namespace with the same name. This is the case if a namespace is defined twice - // or if a namespace with the same name is commented out. - $statements = array(); - } - - $this->pointer = 0; - - return $statements; - } - - /** - * @param class-string $classPath - * @return array - * @throws \ReflectionException - */ - public function getProperties(string $classPath): array - { - $properties = []; - - $reflectionClass = new ReflectionClass($classPath); - - foreach ($reflectionClass->getProperties() as $property) { - $simpleType = (string) $this->getPropertyClass($property, false); - $nestedType = (string) $this->getPropertyClass($property, true); - $properties[] = new PhpClassProperty($property->getName(), $simpleType, $nestedType); - } - - return $properties; - } - - /** - * Parse the docblock of the property to get the class of the var annotation. - * - * @param ReflectionProperty $property - * @param boolean $ignorePrimitive - * - * @throws \RuntimeException - * @return string|null Type of the property (content of var annotation) - * @codeCoverageIgnore - * @infection-ignore-all - */ - public function getPropertyClass(ReflectionProperty $property, bool $ignorePrimitive = true) - { - $type = null; - /** @var false|string $phpVersionResult */ - $phpVersionResult = phpversion(); - $phpVersion = false === $phpVersionResult ? '7.0.0' : $phpVersionResult; - // Get is explicit type declaration if possible - if (version_compare($phpVersion, '7.4.0', '>=') && null !== $property->getType()) { - $reflectionType = $property->getType(); - - if ($reflectionType instanceof \ReflectionNamedType) { - $type = $reflectionType->getName(); - } - } - - if (is_null($type)) { // Try get the content of the @var annotation - if (preg_match('/@var\s+([^\s]+)/', (string) $property->getDocComment(), $matches)) { - list(, $type) = $matches; - } else { - return null; - } - } - - $types = explode('|', $this->replaceTypeStrings($type)); - - foreach ($types as $type) { - // Ignore primitive types - if (true === isset($this->ignoredTypes[$type])) { - if (false === $ignorePrimitive) { - return $this->ignoredTypes[$type]; - } - - if (true === $ignorePrimitive && 1 < count($types)) { - continue; - } - - return null; - } - // Ignore types containing special characters ([], <> ...) - if (!preg_match('/^[a-zA-Z0-9\\\\_]+$/', $type)) { - return null; - } - $class = $property->getDeclaringClass(); - // If the class name is not fully qualified (i.e. doesn't start with a \) - if ($type[0] !== '\\') { - // Try to resolve the FQN using the class context - $resolvedType = $this->tryResolveFqn($type, $class, $property); - - if (!$resolvedType) { - throw new \RuntimeException(sprintf( - 'The @var annotation on %s::%s contains a non existent class "%s". ' - . 'Did you maybe forget to add a "use" statement for this annotation?', - $class->name, - $property->getName(), - $type - )); - } - - $type = $resolvedType; - } - - if (!$this->classExists($type)) { - throw new \RuntimeException(sprintf( - 'The @var annotation on %s::%s contains a non existent class "%s"', - $class->name, - $property->getName(), - $type - )); - } - - // Remove the leading \ (FQN shouldn't contain it) - $type = ltrim($type, '\\'); - } - - return $type; - } - - /** - * Attempts to resolve the FQN of the provided $type based on the $class and $member context. - * - * @param string $type - * @param ReflectionClass $class - * @param Reflector $member - * - * @return string|null Fully qualified name of the type, or null if it could not be resolved - * @codeCoverageIgnore - * @infection-ignore-all - */ - private function tryResolveFqn($type, ReflectionClass $class, Reflector $member) - { - $alias = ($pos = strpos($type, '\\')) === false ? $type : substr($type, 0, $pos); - $loweredAlias = strtolower($alias); - // Retrieve "use" statements - $parser = new TokenParser((string) file_get_contents((string) $class->getFileName())); - $uses = $parser->parseUseStatements($class->getNamespaceName()); - - if (isset($uses[$loweredAlias])) { - // Imported classes - if ($pos !== false) { - return $uses[$loweredAlias] . substr($type, $pos); - } else { - return $uses[$loweredAlias]; - } - } elseif ($this->classExists($class->getNamespaceName() . '\\' . $type)) { - return $class->getNamespaceName() . '\\' . $type; - } elseif (isset($uses['__NAMESPACE__']) && $this->classExists($uses['__NAMESPACE__'] . '\\' . $type)) { - // Class namespace - return $uses['__NAMESPACE__'] . '\\' . $type; - } elseif ($this->classExists($type)) { - // No namespace - return $type; - } - if (version_compare((string) phpversion(), '5.4.0', '<')) { - return null; - } else { - // If all fail, try resolving through related traits - return $this->tryResolveFqnInTraits($type, $class, $member); - } - } - - /** - * Attempts to resolve the FQN of the provided $type based on the $class and $member context, specifically searching - * through the traits that are used by the provided $class. - * - * @param string $type - * @param ReflectionClass $class - * @param Reflector $member - * - * @return string|null Fully qualified name of the type, or null if it could not be resolved - * @codeCoverageIgnore - * @infection-ignore-all - */ - private function tryResolveFqnInTraits($type, ReflectionClass $class, Reflector $member) - { - /** @var ReflectionClass[] $traits */ - $traits = array(); - // Get traits for the class and its parents - while ($class) { - $traits = array_merge($traits, $class->getTraits()); - $class = $class->getParentClass(); - } - - foreach ($traits as $trait) { - // Eliminate traits that don't have the property/method/parameter - if ($member instanceof ReflectionProperty && !$trait->hasProperty($member->name)) { - continue; - } elseif ($member instanceof ReflectionMethod && !$trait->hasMethod($member->name)) { - continue; - } elseif ( - $member instanceof ReflectionParameter - && !$trait->hasMethod($member->getDeclaringFunction()->name) - ) { - continue; - } - // Run the resolver again with the ReflectionClass instance for the trait - $resolvedType = $this->tryResolveFqn($type, $trait, $member); - - if ($resolvedType) { - return $resolvedType; - } - } - return null; - } - - /** - * Gets the next non whitespace and non comment token. - * - * @param boolean $docCommentIsComment If TRUE then a doc comment is considered a comment and skipped. - * If FALSE then only whitespace and normal comments are skipped. - * - * @return array|null The token if exists, null otherwise. - */ - private function next($docCommentIsComment = true) - { - for ($i = $this->pointer; $i < $this->numTokens; $i++) { - $this->pointer++; - if ( - $this->tokens[$i][0] === T_WHITESPACE - || $this->tokens[$i][0] === T_COMMENT - || ($docCommentIsComment - && $this->tokens[$i][0] === T_DOC_COMMENT) - ) { - continue; - } - - return $this->tokens[$i]; - } - - return null; - } - - /** - * Parses a single use statement. - * - * @return array A list with all found class names for a use statement. - * @codeCoverageIgnore - * @infection-ignore-all - */ - private function parseUseStatement() - { - $class = ''; - $alias = ''; - $statements = array(); - $explicitAlias = false; - while (($token = $this->next())) { - $isNameToken = $token[0] === T_STRING || $token[0] === T_NS_SEPARATOR; - if (!$explicitAlias && $isNameToken) { - $class .= $token[1]; - $alias = $token[1]; - } elseif ($explicitAlias && $isNameToken) { - $alias .= $token[1]; - } elseif ($token[0] === T_AS) { - $explicitAlias = true; - $alias = ''; - } elseif ($token === ',') { - $statements[strtolower($alias)] = $class; - $class = ''; - $alias = ''; - $explicitAlias = false; - } elseif ($token === ';') { - $statements[strtolower($alias)] = $class; - break; - } else { - break; - } - } - - return $statements; - } - - /** - * Gets the namespace. - * - * @return string The found namespace. - */ - private function parseNamespace() - { - $name = ''; - while (($token = $this->next()) && ($token[0] === T_STRING || $token[0] === T_NS_SEPARATOR)) { - $name .= $token[1]; - } - - return $name; - } - - /** - * @param string $class - * @return bool - */ - private function classExists($class) - { - return class_exists($class) || interface_exists($class); - } - - /** - * @param string $type - * @return string - */ - private function replaceTypeStrings(string $type): string - { - return str_replace('[]', '', $type); - } -} diff --git a/src/PhpClass/PhpClass.php b/src/PhpClass/PhpClass.php index d7f49cf..a629d40 100644 --- a/src/PhpClass/PhpClass.php +++ b/src/PhpClass/PhpClass.php @@ -6,33 +6,22 @@ class PhpClass implements PhpClassInterface { - /** - * @var string - */ - private $classBody; - - /** - * @var string - */ - private $className; - - /** - * @var string - */ - private $classNamespace; + private string $classBody; + private string $className; + private ?string $classNamespace; /** - * @var PhpClassProperty[] + * @var PhpClassPropertyInterface[] */ - private $classProperties; + private array $classProperties; /** * @param string $className - * @param string $classNamespace + * @param ?string $classNamespace * @param string $classBody - * @param PhpClassProperty[] $classProperties + * @param PhpClassPropertyInterface[] $classProperties */ - public function __construct(string $className, string $classNamespace, string $classBody, array $classProperties) + public function __construct(string $className, ?string $classNamespace, string $classBody, array $classProperties) { $this->className = $className; $this->classNamespace = $classNamespace; @@ -43,7 +32,7 @@ public function __construct(string $className, string $classNamespace, string $c /** * @return string */ - public function getClassNamespace(): string + public function getClassNamespace(): ?string { return $this->classNamespace; } @@ -65,7 +54,7 @@ public function getClassBody(): string } /** - * @return PhpClassProperty[] + * @return PhpClassPropertyInterface[] */ public function getClassProperties(): array { diff --git a/src/PhpClass/PhpClassInterface.php b/src/PhpClass/PhpClassInterface.php index 6289c22..126d3b9 100644 --- a/src/PhpClass/PhpClassInterface.php +++ b/src/PhpClass/PhpClassInterface.php @@ -6,14 +6,14 @@ interface PhpClassInterface { - public function getClassNamespace(): string; + public function getClassNamespace(): ?string; public function getClassName(): string; public function getClassBody(): string; /** - * @return PhpClassProperty[] + * @return PhpClassPropertyInterface[] */ public function getClassProperties(): array; } diff --git a/src/PhpClass/PhpClassProperty.php b/src/PhpClass/PhpClassProperty.php index 5a62467..16e06a9 100644 --- a/src/PhpClass/PhpClassProperty.php +++ b/src/PhpClass/PhpClassProperty.php @@ -4,51 +4,68 @@ namespace PhpKafka\PhpAvroSchemaGenerator\PhpClass; +use PhpKafka\PhpAvroSchemaGenerator\Parser\PropertyAttributesInterface; + class PhpClassProperty implements PhpClassPropertyInterface { - /** - * @var string - */ - private $propertyName; + /** @var mixed */ + private $propertyDefault; + private ?string $propertyDoc; + private ?string $propertyLogicalType; + private string $propertyName; - /** - * @var string - */ + /** @var string|string[] */ private $propertyType; /** - * @var string|null + * @param string $propertyName + * @param string[]|string $propertyType + * @param null|mixed $propertyDefault + * @param null|string $propertyDoc + * @param null|string $propertyLogicalType */ - private $propertyArrayType; - - public function __construct(string $propertyName, string $propertyType, ?string $propertyArrayType) - { + public function __construct( + string $propertyName, + $propertyType, + $propertyDefault = self::NO_DEFAULT, + ?string $propertyDoc = null, + ?string $propertyLogicalType = null + ) { + $this->propertyDefault = $propertyDefault; + $this->propertyDoc = $propertyDoc; + $this->propertyLogicalType = $propertyLogicalType; $this->propertyName = $propertyName; $this->propertyType = $propertyType; - $this->propertyArrayType = $propertyArrayType; } /** - * @return string + * @return mixed */ - public function getPropertyName(): string + public function getPropertyDefault() { - return $this->propertyName; + return $this->propertyDefault; } - /** - * @return string - */ - public function getPropertyType(): string + public function getPropertyDoc(): ?string { - return $this->propertyType; + return $this->propertyDoc; + } + + public function getPropertyLogicalType(): ?string + { + return $this->propertyLogicalType; + } + + public function getPropertyName(): string + { + return $this->propertyName; } /** - * @return string|null + * @return string[]|string */ - public function getPropertyArrayType(): ?string + public function getPropertyType() { - return $this->propertyArrayType; + return $this->propertyType; } } diff --git a/src/PhpClass/PhpClassPropertyInterface.php b/src/PhpClass/PhpClassPropertyInterface.php index 120dc2e..b8c6fce 100644 --- a/src/PhpClass/PhpClassPropertyInterface.php +++ b/src/PhpClass/PhpClassPropertyInterface.php @@ -6,18 +6,21 @@ interface PhpClassPropertyInterface { - /** - * @return string - */ - public function getPropertyName(): string; + public const NO_DEFAULT = 'there-was-no-default-set'; /** - * @return string + * @return mixed */ - public function getPropertyType(): string; + public function getPropertyDefault(); + + public function getPropertyDoc(): ?string; + + public function getPropertyLogicalType(): ?string; + + public function getPropertyName(): string; /** - * @return string|null + * @return string[]|string */ - public function getPropertyArrayType(): ?string; + public function getPropertyType(); } diff --git a/src/Registry/ClassRegistry.php b/src/Registry/ClassRegistry.php index e768895..c3bcdc2 100644 --- a/src/Registry/ClassRegistry.php +++ b/src/Registry/ClassRegistry.php @@ -5,9 +5,11 @@ namespace PhpKafka\PhpAvroSchemaGenerator\Registry; use FilesystemIterator; +use PhpKafka\PhpAvroSchemaGenerator\Converter\PhpClassConverterInterface; use PhpKafka\PhpAvroSchemaGenerator\Exception\ClassRegistryException; use PhpKafka\PhpAvroSchemaGenerator\Parser\TokenParser; use PhpKafka\PhpAvroSchemaGenerator\PhpClass\PhpClass; +use PhpKafka\PhpAvroSchemaGenerator\PhpClass\PhpClassInterface; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -20,10 +22,17 @@ final class ClassRegistry implements ClassRegistryInterface protected $classDirectories = []; /** - * @var PhpClass[] + * @var PhpClassInterface[] */ protected $classes = []; + private PhpClassConverterInterface $classConverter; + + public function __construct(PhpClassConverterInterface $classConverter) + { + $this->classConverter = $classConverter; + } + /** * @param string $classDirectory * @return ClassRegistryInterface @@ -69,7 +78,7 @@ public function load(): ClassRegistryInterface } /** - * @return PhpClass[] + * @return PhpClassInterface[] */ public function getClasses(): array { @@ -95,21 +104,10 @@ private function registerClassFile(SplFileInfo $fileInfo): void ) ); } + $convertedClass = $this->classConverter->convert($fileContent); - $parser = new TokenParser($fileContent); - - if (null === $parser->getClassName()) { - return; + if (null !== $convertedClass) { + $this->classes[] = $convertedClass; } - - /** @var class-string $className */ - $className = $parser->getNamespace() . '\\' . $parser->getClassName(); - $properties = $parser->getProperties($className); - $this->classes[] = new PhpClass( - $parser->getClassName(), - $parser->getNamespace(), - $fileContent, - $properties - ); } } diff --git a/src/Registry/ClassRegistryInterface.php b/src/Registry/ClassRegistryInterface.php index 1559ec1..3bf1020 100644 --- a/src/Registry/ClassRegistryInterface.php +++ b/src/Registry/ClassRegistryInterface.php @@ -4,7 +4,7 @@ namespace PhpKafka\PhpAvroSchemaGenerator\Registry; -use PhpKafka\PhpAvroSchemaGenerator\PhpClass\PhpClass; +use PhpKafka\PhpAvroSchemaGenerator\PhpClass\PhpClassInterface; interface ClassRegistryInterface { @@ -25,7 +25,7 @@ public function getClassDirectories(): array; public function load(): ClassRegistryInterface; /** - * @return PhpClass[] + * @return PhpClassInterface[] */ public function getClasses(): array; } diff --git a/src/ServiceProvider/CommandServiceProvider.php b/src/ServiceProvider/CommandServiceProvider.php new file mode 100644 index 0000000..b5f5560 --- /dev/null +++ b/src/ServiceProvider/CommandServiceProvider.php @@ -0,0 +1,36 @@ +create(ParserFactory::PREFER_PHP7); + }; + + $container[DocCommentParserInterface::class] = static function (): DocCommentParserInterface { + return new DocCommentParser(); + }; + + $container[ClassPropertyParserInterface::class] = + static function (Container $container): ClassPropertyParserInterface { + return new ClassPropertyParser($container[DocCommentParserInterface::class]); + }; + + $container[ClassParserInterface::class] = static function (Container $container): ClassParserInterface { + return new ClassParser($container[Parser::class], $container[ClassPropertyParserInterface::class]); + }; + } +} diff --git a/src/ServiceProvider/RegistryServiceProvider.php b/src/ServiceProvider/RegistryServiceProvider.php new file mode 100644 index 0000000..d66a941 --- /dev/null +++ b/src/ServiceProvider/RegistryServiceProvider.php @@ -0,0 +1,27 @@ +add(new SchemaGenerateCommand()); -$application->add(new SubSchemaMergeCommand()); +$container = AppContainer::init(); +$application = new Application(); +$application->addCommands($container['console.commands']); $application->run(); diff --git a/tests/Integration/Parser/ClassParserTest.php b/tests/Integration/Parser/ClassParserTest.php new file mode 100644 index 0000000..7ef9793 --- /dev/null +++ b/tests/Integration/Parser/ClassParserTest.php @@ -0,0 +1,95 @@ +create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode(file_get_contents($filePath)); + self::assertEquals('SomeTestClass', $parser->getClassName()); + self::assertEquals('SomeTestClass', $parser->getClassName()); + } + + public function testGetClassNameForInterface() + { + $filePath = __DIR__ . '/../../../example/classes/SomeTestInterface.php'; + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode(file_get_contents($filePath)); + self::assertNull($parser->getClassName()); + } + + public function testGetNamespace() + { + $filePath = __DIR__ . '/../../../example/classes/SomeTestClass.php'; + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode(file_get_contents($filePath)); + self::assertEquals('PhpKafka\\PhpAvroSchemaGenerator\\Example', $parser->getNamespace()); + self::assertEquals('PhpKafka\\PhpAvroSchemaGenerator\\Example', $parser->getNamespace()); + } + + public function testGetProperties() + { + $filePath = __DIR__ . '/../../../example/classes/SomeTestClass.php'; + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode(file_get_contents($filePath)); + $properties = $parser->getProperties(); + self::assertCount(15, $properties); + + foreach($properties as $property) { + self::assertInstanceOf(PhpClassPropertyInterface::class, $property); + } + } + + public function testClassAndNamespaceAreNullWithNoCode(): void + { + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $refObject = new \ReflectionObject($parser); + $refProperty = $refObject->getProperty('statements'); + $refProperty->setAccessible( true ); + $refProperty->setValue($parser, null); + self::assertNull($parser->getClassName()); + self::assertNull($parser->getNamespace()); + self::assertNull($parser->getParentClassName()); + self::assertEquals([], $parser->getUsedClasses()); + } + + public function testClassWithNoParent(): void + { + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode('getNamespace()); + self::assertNull($parser->getParentClassName()); + self::assertEquals([], $parser->getProperties()); + + } + + public function testClassWithNoParentFile(): void + { + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $parser->setCode('getProperties()); + + } +} diff --git a/tests/Integration/Parser/DocCommentParserTest.php b/tests/Integration/Parser/DocCommentParserTest.php new file mode 100644 index 0000000..a15e6cb --- /dev/null +++ b/tests/Integration/Parser/DocCommentParserTest.php @@ -0,0 +1,31 @@ +parseDoc('/** + * @var string + asdf some text + */'); + + self::assertEquals( + [ + 'var' => 'string', + 'function-description' =>'' + ], + $result + ); + } +} \ No newline at end of file diff --git a/tests/Integration/Parser/TokenParserTest.php b/tests/Integration/Parser/TokenParserTest.php deleted file mode 100644 index 2010b84..0000000 --- a/tests/Integration/Parser/TokenParserTest.php +++ /dev/null @@ -1,50 +0,0 @@ -getClassName()); - self::assertEquals('SomeTestClass', $parser->getClassName()); - } - - public function testGetClassNameForInterface() - { - $filePath = __DIR__ . '/../../../example/classes/SomeTestInterface.php'; - $parser = new TokenParser(file_get_contents($filePath)); - self::assertNull($parser->getClassName()); - } - - public function testGetNamespace() - { - $filePath = __DIR__ . '/../../../example/classes/SomeTestClass.php'; - $parser = new TokenParser(file_get_contents($filePath)); - self::assertEquals('PhpKafka\\PhpAvroSchemaGenerator\\Example', $parser->getNamespace()); - self::assertEquals('PhpKafka\\PhpAvroSchemaGenerator\\Example', $parser->getNamespace()); - } - - public function testGetProperties() - { - $filePath = __DIR__ . '/../../../example/classes/SomeTestClass.php'; - $parser = new TokenParser(file_get_contents($filePath)); - $properties = $parser->getProperties($parser->getNamespace() . '\\' . $parser->getClassName()); - self::assertCount(15, $properties); - - foreach($properties as $property) { - self::assertInstanceOf(PhpClassPropertyInterface::class, $property); - } - } -} diff --git a/tests/Integration/Registry/ClassRegistryTest.php b/tests/Integration/Registry/ClassRegistryTest.php index 4d2c33a..7812c9c 100644 --- a/tests/Integration/Registry/ClassRegistryTest.php +++ b/tests/Integration/Registry/ClassRegistryTest.php @@ -4,10 +4,15 @@ namespace PhpKafka\PhpAvroSchemaGenerator\Tests\Integration\Registry; +use PhpKafka\PhpAvroSchemaGenerator\Converter\PhpClassConverter; use PhpKafka\PhpAvroSchemaGenerator\Exception\ClassRegistryException; +use PhpKafka\PhpAvroSchemaGenerator\Parser\ClassParser; +use PhpKafka\PhpAvroSchemaGenerator\Parser\ClassPropertyParser; +use PhpKafka\PhpAvroSchemaGenerator\Parser\DocCommentParser; use PhpKafka\PhpAvroSchemaGenerator\PhpClass\PhpClassInterface; use PhpKafka\PhpAvroSchemaGenerator\Registry\ClassRegistry; use PhpKafka\PhpAvroSchemaGenerator\Registry\ClassRegistryInterface; +use PhpParser\ParserFactory; use PHPUnit\Framework\TestCase; use ReflectionClass; use SplFileInfo; @@ -19,7 +24,10 @@ class ClassRegistryTest extends TestCase { public function testClassDirectory() { - $registry = new ClassRegistry(); + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $converter = new PhpClassConverter($parser); + $registry = new ClassRegistry($converter); $result = $registry->addClassDirectory('/tmp'); self::assertInstanceOf(ClassRegistryInterface::class, $result); @@ -30,7 +38,10 @@ public function testLoad() { $classDir = __DIR__ . '/../../../example/classes'; - $registry = (new ClassRegistry())->addClassDirectory($classDir)->load(); + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $converter = new PhpClassConverter($parser); + $registry = (new ClassRegistry($converter))->addClassDirectory($classDir)->load(); self::assertInstanceOf(ClassRegistryInterface::class, $registry); @@ -46,7 +57,10 @@ public function testLoad() public function testRegisterSchemaFileThatDoesntExist() { $fileInfo = new SplFileInfo('somenonexistingfile'); - $registry = new ClassRegistry(); + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $converter = new PhpClassConverter($parser); + $registry = new ClassRegistry($converter); self::expectException(ClassRegistryException::class); self::expectExceptionMessage(ClassRegistryException::FILE_PATH_EXCEPTION_MESSAGE); @@ -64,7 +78,10 @@ public function testRegisterSchemaFileThatIsNotReadable() $fileInfo = new SplFileInfo('testfile'); - $registry = new ClassRegistry(); + $propertyParser = new ClassPropertyParser(new DocCommentParser()); + $parser = new ClassParser((new ParserFactory())->create(ParserFactory::PREFER_PHP7), $propertyParser); + $converter = new PhpClassConverter($parser); + $registry = new ClassRegistry($converter); self::expectException(ClassRegistryException::class); self::expectExceptionMessage( diff --git a/tests/Unit/Generator/SchemaGeneratorTest.php b/tests/Unit/Generator/SchemaGeneratorTest.php index da58fdd..0a798e6 100644 --- a/tests/Unit/Generator/SchemaGeneratorTest.php +++ b/tests/Unit/Generator/SchemaGeneratorTest.php @@ -17,7 +17,8 @@ public function testDefaultOutputDirectory() { $registry = $this->getMockForAbstractClass(ClassRegistryInterface::class); - $generator = new SchemaGenerator($registry); + $generator = new SchemaGenerator(); + $generator->setClassRegistry($registry); self::assertEquals($registry, $generator->getClassRegistry()); self::assertEquals('/tmp', $generator->getOutputDirectory()); @@ -28,7 +29,9 @@ public function testGetters() $registry = $this->getMockForAbstractClass(ClassRegistryInterface::class); $directory = '/tmp/foo'; - $generator = new SchemaGenerator($registry, $directory); + $generator = new SchemaGenerator(); + $generator->setClassRegistry($registry); + $generator->setOutputDirectory($directory); self::assertEquals($registry, $generator->getClassRegistry()); self::assertEquals($directory, $generator->getOutputDirectory()); @@ -51,50 +54,56 @@ public function testGenerate() ], [ 'name' => 'name', - 'type' => 'string' + 'type' => 'string', + 'default' => 'test', + 'doc' => 'test', + 'logicalType' => 'test' ] ] ]), - 'name.space.Test2Class' => json_encode([ + 'Test2Class' => json_encode([ 'type' => 'record', 'name' => 'Test2Class', - 'namespace' => 'name.space', 'fields' => [ [ 'name' => 'name', - 'type' => 'string' + 'type' => 'string', + 'default' => 'test', + 'doc' => 'test', + 'logicalType' => 'test' ] ] ]) ]; $property1 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); - $property1->expects(self::exactly(3))->method('getPropertyType')->willReturn('array'); + $property1->expects(self::exactly(1))->method('getPropertyType')->willReturn(["type" => "array","items" => "test.foo"]); $property1->expects(self::exactly(1))->method('getPropertyName')->willReturn('items'); - $property1->expects(self::exactly(1))->method('getPropertyArrayType')->willReturn('test\\foo'); + $property1->expects(self::exactly(1))->method('getPropertyDefault')->willReturn(PhpClassPropertyInterface::NO_DEFAULT); $property2 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); - $property2->expects(self::exactly(6))->method('getPropertyType')->willReturn('string'); + $property2->expects(self::exactly(2))->method('getPropertyType')->willReturn('string'); $property2->expects(self::exactly(2))->method('getPropertyName')->willReturn('name'); + $property2->expects(self::exactly(4))->method('getPropertyDefault')->willReturn('test'); + $property2->expects(self::exactly(6))->method('getPropertyDoc')->willReturn('test'); + $property2->expects(self::exactly(4))->method('getPropertyLogicalType')->willReturn('test'); - $property3 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); - $property3->expects(self::once())->method('getPropertyType')->willReturn('mixed'); - $property3->expects(self::never())->method('getPropertyName'); $class1 = $this->getMockForAbstractClass(PhpClassInterface::class); $class1->expects(self::once())->method('getClassName')->willReturn('TestClass'); - $class1->expects(self::once())->method('getClassNamespace')->willReturn('name\\space'); - $class1->expects(self::once())->method('getClassProperties')->willReturn([$property1, $property2, $property3]); + $class1->expects(self::exactly(2))->method('getClassNamespace')->willReturn('name\\space'); + $class1->expects(self::once())->method('getClassProperties')->willReturn([$property1, $property2]); $class2 = $this->getMockForAbstractClass(PhpClassInterface::class); $class2->expects(self::once())->method('getClassName')->willReturn('Test2Class'); - $class2->expects(self::once())->method('getClassNamespace')->willReturn('name\\space'); + $class2->expects(self::once())->method('getClassNamespace')->willReturn(null); $class2->expects(self::once())->method('getClassProperties')->willReturn([$property2]); $registry = $this->getMockForAbstractClass(ClassRegistryInterface::class); $registry->expects(self::once())->method('getClasses')->willReturn([$class1, $class2]); - $generator = new SchemaGenerator($registry); + $generator = new SchemaGenerator(); + $generator->setClassRegistry($registry); $result = $generator->generate(); self::assertEquals($expectedResult, $result); self::assertCount(2, $result); @@ -107,7 +116,8 @@ public function testExportSchemas() ]; $registry = $this->getMockForAbstractClass(ClassRegistryInterface::class); - $generator = new SchemaGenerator($registry); + $generator = new SchemaGenerator(); + $generator->setClassRegistry($registry); $fileCount = $generator->exportSchemas($schemas); self::assertFileExists('/tmp/filename.avsc'); @@ -116,4 +126,17 @@ public function testExportSchemas() unlink('/tmp/filename.avsc'); } + + public function testGenerateWithoutRegistry() + { + self::expectException(\RuntimeException::class); + self::expectExceptionMessage('Please set a ClassRegistry for the generator'); + + $generator = new SchemaGenerator(); + $refObject = new \ReflectionObject($generator); + $refProperty = $refObject->getProperty('classRegistry'); + $refProperty->setAccessible( true ); + $refProperty->setValue($generator, null); + $generator->generate(); + } } diff --git a/tests/Unit/Merger/SchemaMergerTest.php b/tests/Unit/Merger/SchemaMergerTest.php index 486cf1b..1558c21 100644 --- a/tests/Unit/Merger/SchemaMergerTest.php +++ b/tests/Unit/Merger/SchemaMergerTest.php @@ -21,22 +21,29 @@ class SchemaMergerTest extends TestCase public function testGetSchemaRegistry() { $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); self::assertEquals($schemaRegistry, $merger->getSchemaRegistry()); } public function testGetOutputDirectoryDefault() { - $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); self::assertEquals('/tmp', $merger->getOutputDirectory()); } public function testGetOutputDirectory() { - $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); $outputDirectory = '/root'; - $merger = new SchemaMerger($schemaRegistry, $outputDirectory); + $merger = new SchemaMerger($outputDirectory); + self::assertEquals($outputDirectory, $merger->getOutputDirectory()); + } + + public function testSetOutputDirectory() + { + $outputDirectory = '/root'; + $merger = new SchemaMerger(); + $merger->setOutputDirectory($outputDirectory); self::assertEquals($outputDirectory, $merger->getOutputDirectory()); } @@ -47,7 +54,8 @@ public function testGetResolvedSchemaTemplateThrowsException() $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); $schemaTemplate = $this->getMockForAbstractClass(SchemaTemplateInterface::class); $schemaTemplate->expects(self::once())->method('getSchemaDefinition')->willReturn('{"type": 1}'); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); self::assertEquals([], $merger->getResolvedSchemaTemplate($schemaTemplate)); } @@ -71,7 +79,8 @@ public function testGetResolvedSchemaTemplateResolveEmbeddedException() ->expects(self::once()) ->method('getSchemaDefinition') ->willReturn($definitionWithType); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); self::assertEquals([], $merger->getResolvedSchemaTemplate($schemaTemplate)); } @@ -119,7 +128,8 @@ public function testGetResolvedSchemaTemplate() ->with($expectedResult) ->willReturn($rootSchemaTemplate); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); $merger->getResolvedSchemaTemplate($rootSchemaTemplate); } @@ -289,7 +299,8 @@ public function testGetResolvedSchemaTemplateWithMultiEmbedd() ->with($expectedResult) ->willReturn($rootSchemaTemplate); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); $merger->getResolvedSchemaTemplate($rootSchemaTemplate); } @@ -339,7 +350,8 @@ public function testGetResolvedSchemaTemplateWithDifferentNamespaceForEmbeddedSc ->with($expectedResult) ->willReturn($rootSchemaTemplate); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); $merger->getResolvedSchemaTemplate($rootSchemaTemplate); } @@ -368,7 +380,9 @@ public function testMergeException() ->expects(self::once()) ->method('getRootSchemas') ->willReturn([$schemaTemplate]); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); + $merger->merge(); } @@ -401,7 +415,8 @@ public function testMerge() ->willReturn([$schemaTemplate]); $optimizer = $this->getMockForAbstractClass(OptimizerInterface::class); $optimizer->expects(self::once())->method('optimize')->with($schemaTemplate)->willReturn($schemaTemplate); - $merger = new SchemaMerger($schemaRegistry, '/tmp/foobar'); + $merger = new SchemaMerger('/tmp/foobar'); + $merger->setSchemaRegistry($schemaRegistry); $merger->addOptimizer($optimizer); $mergedFiles = $merger->merge(true); @@ -437,7 +452,8 @@ public function testMergePrimitive() ->expects(self::once()) ->method('getRootSchemas') ->willReturn([$schemaTemplate]); - $merger = new SchemaMerger($schemaRegistry, '/tmp/foobar'); + $merger = new SchemaMerger('/tmp/foobar'); + $merger->setSchemaRegistry($schemaRegistry); $merger->merge(false, true); self::assertFileExists('/tmp/foobar/primitive-type.avsc'); @@ -477,7 +493,8 @@ public function testMergePrimitiveWithOptimizerEnabled() ->willReturn([$schemaTemplate]); $optimizer = $this->getMockBuilder(PrimitiveSchemaOptimizer::class)->getMock(); $optimizer->expects(self::once())->method('optimize')->with($schemaTemplate)->willReturn($schemaTemplate); - $merger = new SchemaMerger($schemaRegistry, '/tmp/foobar'); + $merger = new SchemaMerger('/tmp/foobar'); + $merger->setSchemaRegistry($schemaRegistry); $merger->addOptimizer($optimizer); $merger->merge(true); @@ -517,7 +534,8 @@ public function testMergeWithFilenameOption() ->expects(self::once()) ->method('getRootSchemas') ->willReturn([$schemaTemplate]); - $merger = new SchemaMerger($schemaRegistry, '/tmp/foobar'); + $merger = new SchemaMerger('/tmp/foobar'); + $merger->setSchemaRegistry($schemaRegistry); $merger->merge(true, true); self::assertFileExists('/tmp/foobar/bla.avsc'); @@ -534,7 +552,8 @@ public function testExportSchema() ->willReturn('{"name": "test"}'); $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); $merger->exportSchema($schemaTemplate); self::assertFileExists('/tmp/test.avsc'); @@ -559,13 +578,39 @@ public function testExportSchemaPrimitiveWithWrongOptions() ->willReturn('test.avsc'); $schemaRegistry = $this->getMockForAbstractClass(SchemaRegistryInterface::class); - $merger = new SchemaMerger($schemaRegistry); + $merger = new SchemaMerger(); + $merger->setSchemaRegistry($schemaRegistry); $merger->exportSchema($schemaTemplate, true); self::assertFileExists('/tmp/test.avsc'); unlink('/tmp/test.avsc'); } + public function testMergeWithoutRegistry() + { + self::expectException(\RuntimeException::class); + self::expectExceptionMessage('Please set a SchemaRegistery for the merger'); + $merger = new SchemaMerger(); + $refObject = new \ReflectionObject($merger); + $refProperty = $refObject->getProperty('schemaRegistry'); + $refProperty->setAccessible( true ); + $refProperty->setValue($merger, null); + + $merger->merge(); + } + + public function testGetResolvedSchemaTemplateWithoutRegistry() + { + self::expectException(\RuntimeException::class); + self::expectExceptionMessage('Please set a SchemaRegistery for the merger'); + $merger = new SchemaMerger(); + $refObject = new \ReflectionObject($merger); + $refProperty = $refObject->getProperty('schemaRegistry'); + $refProperty->setAccessible( true ); + $refProperty->setValue($merger, null); + $merger->getResolvedSchemaTemplate($this->getMockForAbstractClass(SchemaTemplateInterface::class)); + } + private function reformatJsonString(string $jsonString): string { return json_encode(json_decode($jsonString, false, JSON_THROW_ON_ERROR), JSON_THROW_ON_ERROR); diff --git a/tests/Unit/Parser/ClassPropertyParserTest.php b/tests/Unit/Parser/ClassPropertyParserTest.php new file mode 100644 index 0000000..5566342 --- /dev/null +++ b/tests/Unit/Parser/ClassPropertyParserTest.php @@ -0,0 +1,59 @@ +getMockBuilder(Doc::class)->disableOriginalConstructor()->getMock(); + $varId = $this->getMockBuilder(VarLikeIdentifier::class)->disableOriginalConstructor()->getMock(); + $varId->name = 'bla'; + $identifier = $this->getMockBuilder(Identifier::class)->disableOriginalConstructor()->getMock(); + $identifier->name = 'int'; + $ut = $this->getMockBuilder(UnionType::class)->disableOriginalConstructor()->getMock(); + $ut->types = [$identifier]; + $propertyProperty = $this->getMockBuilder(PropertyProperty::class)->disableOriginalConstructor()->getMock(); + $propertyProperty->name = $varId; + $doc->expects(self::once())->method('getText')->willReturn('bla'); + $docParser = $this->getMockForAbstractClass(DocCommentParserInterface::class); + $property1 = $this->getMockBuilder(Property::class)->disableOriginalConstructor()->getMock(); + $property1->expects(self::once())->method('getAttributes')->willReturn(['comments' => [$doc]]); + $property1->props = [$propertyProperty]; + $property1->type = $identifier; + $property2 = $this->getMockBuilder(Property::class)->disableOriginalConstructor()->getMock(); + $property2->type = 'string'; + $property2->props = [$propertyProperty]; + $property3 = $this->getMockBuilder(Property::class)->disableOriginalConstructor()->getMock(); + $property3->type = $ut; + $property3->props = [$propertyProperty]; + $cpp = new ClassPropertyParser($docParser); + + self::assertInstanceOf(PhpClassPropertyInterface::class, $cpp->parseProperty($property1)); + self::assertInstanceOf(PhpClassPropertyInterface::class, $cpp->parseProperty($property2)); + self::assertInstanceOf(PhpClassPropertyInterface::class, $cpp->parseProperty($property3)); + } + + public function testParsePropertyExceptionOnNonProperty(): void + { + self::expectException(\RuntimeException::class); + self::expectExceptionMessage('Property must be of type: PhpParser\Node\Stmt\Property'); + $docParser = $this->getMockForAbstractClass(DocCommentParserInterface::class); + $cpp = new ClassPropertyParser($docParser); + + $cpp->parseProperty(1); + } +} \ No newline at end of file diff --git a/tests/Unit/PhpClass/PhpClassPropertyTest.php b/tests/Unit/PhpClass/PhpClassPropertyTest.php index 1efe2fa..4a59531 100644 --- a/tests/Unit/PhpClass/PhpClassPropertyTest.php +++ b/tests/Unit/PhpClass/PhpClassPropertyTest.php @@ -14,10 +14,12 @@ class PhpClassPropertyTest extends TestCase { public function testGetters() { - $property = new PhpClassProperty('propertyName', 'array', 'string'); + $property = new PhpClassProperty('propertyName', 'array', 'default', 'doc', 'logicalType'); self::assertEquals('propertyName', $property->getPropertyName()); self::assertEquals('array', $property->getPropertyType()); - self::assertEquals('string', $property->getPropertyArrayType()); + self::assertEquals('default', $property->getPropertyDefault()); + self::assertEquals('doc', $property->getPropertyDoc()); + self::assertEquals('logicalType', $property->getPropertyLogicalType()); } } diff --git a/tests/Unit/PhpClassConverterTest.php b/tests/Unit/PhpClassConverterTest.php new file mode 100644 index 0000000..154971f --- /dev/null +++ b/tests/Unit/PhpClassConverterTest.php @@ -0,0 +1,68 @@ +getMockForAbstractClass(PhpClassPropertyInterface::class); + $property1->expects(self::once())->method('getPropertyType')->willReturn(1); + $property2 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property2->expects(self::exactly(2))->method('getPropertyType')->willReturn('string|array|int[]|mixed[]'); + $property3 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property3->expects(self::exactly(2))->method('getPropertyType')->willReturn('string'); + $property4 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property4->expects(self::exactly(2))->method('getPropertyType')->willReturn('object|XYZ|UC'); + $property5 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property5->expects(self::exactly(2))->method('getPropertyType')->willReturn('mixed'); + $property6 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property6->expects(self::exactly(2))->method('getPropertyType')->willReturn('array|mixed[]'); + + + $parser = $this->getMockForAbstractClass(ClassParserInterface::class); + $parser->expects(self::once())->method('setCode')->with('some class stuff'); + $parser->expects(self::exactly(2))->method('getClassName')->willReturn('foo'); + $parser->expects(self::once())->method('getProperties')->willReturn( + [$property1, $property2, $property3, $property4, $property5, $property6] + ); + $parser->expects(self::exactly(2))->method('getUsedClasses')->willReturn(['XYZ' => 'a\\b\\ZYX']); + $parser->expects(self::exactly(3))->method('getNamespace')->willReturn('x\\y'); + + $converter = new PhpClassConverter($parser); + self::assertInstanceOf(PhpClassInterface::class, $converter->convert('some class stuff')); + } + + public function testConvertWithNoNamesace(): void + { + $property1 = $this->getMockForAbstractClass(PhpClassPropertyInterface::class); + $property1->expects(self::exactly(2))->method('getPropertyType')->willReturn('ABC'); + + + $parser = $this->getMockForAbstractClass(ClassParserInterface::class); + $parser->expects(self::once())->method('setCode')->with('some class stuff'); + $parser->expects(self::exactly(2))->method('getClassName')->willReturn('foo'); + $parser->expects(self::once())->method('getProperties')->willReturn([$property1]); + $parser->expects(self::exactly(1))->method('getUsedClasses')->willReturn([]); + $parser->expects(self::exactly(2))->method('getNamespace')->willReturn(null); + + $converter = new PhpClassConverter($parser); + self::assertInstanceOf(PhpClassInterface::class, $converter->convert('some class stuff')); + } + + public function testConvertOfNonClass(): void + { + $parser = $this->getMockForAbstractClass(ClassParserInterface::class); + $parser->expects(self::once())->method('getClassName')->willReturn(null); + $converter = new PhpClassConverter($parser); + self::assertNull($converter->convert('some class stuff')); + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/CommandServiceProviderTest.php b/tests/Unit/ServiceProvider/CommandServiceProviderTest.php new file mode 100644 index 0000000..f135972 --- /dev/null +++ b/tests/Unit/ServiceProvider/CommandServiceProviderTest.php @@ -0,0 +1,33 @@ +getMockForAbstractClass(ClassRegistryInterface::class); + $container[SchemaGeneratorInterface::class] = $this->getMockForAbstractClass(SchemaGeneratorInterface::class); + $container[SchemaMergerInterface::class] = $this->getMockForAbstractClass(SchemaMergerInterface::class); + $container[SchemaRegistryInterface::class] = $this->getMockForAbstractClass(SchemaRegistryInterface::class); + + (new CommandServiceProvider())->register($container); + + self::assertTrue(isset($container['console.commands'])); + self::assertEquals(2, count($container['console.commands'])); + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/ConverterServiceProviderTest.php b/tests/Unit/ServiceProvider/ConverterServiceProviderTest.php new file mode 100644 index 0000000..e02505e --- /dev/null +++ b/tests/Unit/ServiceProvider/ConverterServiceProviderTest.php @@ -0,0 +1,28 @@ +getMockForAbstractClass(ClassParserInterface::class); + + (new ConverterServiceProvider())->register($container); + + self::assertTrue(isset($container[PhpClassConverterInterface::class])); + self::assertInstanceOf(PhpClassConverterInterface::class, $container[PhpClassConverterInterface::class]); + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/GeneratorServiceProviderTest.php b/tests/Unit/ServiceProvider/GeneratorServiceProviderTest.php new file mode 100644 index 0000000..cca783c --- /dev/null +++ b/tests/Unit/ServiceProvider/GeneratorServiceProviderTest.php @@ -0,0 +1,26 @@ +register($container); + + self::assertTrue(isset($container[SchemaGeneratorInterface::class])); + self::assertInstanceOf(SchemaGeneratorInterface::class, $container[SchemaGeneratorInterface::class]); + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/MergerServiceProviderTest.php b/tests/Unit/ServiceProvider/MergerServiceProviderTest.php new file mode 100644 index 0000000..419b13b --- /dev/null +++ b/tests/Unit/ServiceProvider/MergerServiceProviderTest.php @@ -0,0 +1,26 @@ +register($container); + + self::assertTrue(isset($container[SchemaMergerInterface::class])); + self::assertInstanceOf(SchemaMergerInterface::class, $container[SchemaMergerInterface::class]); + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/ParserServiceProviderTest.php b/tests/Unit/ServiceProvider/ParserServiceProviderTest.php new file mode 100644 index 0000000..89ba158 --- /dev/null +++ b/tests/Unit/ServiceProvider/ParserServiceProviderTest.php @@ -0,0 +1,38 @@ +getMockBuilder(ParserFactory::class)->getMock(); + $container[Parser::class] = $this->getMockForAbstractClass(Parser::class); + + + (new ParserServiceProvider())->register($container); + + self::assertTrue(isset($container[DocCommentParserInterface::class])); + self::assertInstanceOf(DocCommentParserInterface::class, $container[DocCommentParserInterface::class]); + self::assertTrue(isset($container[ClassPropertyParserInterface::class])); + self::assertInstanceOf(ClassPropertyParserInterface::class, $container[ClassPropertyParserInterface::class]); + self::assertTrue(isset($container[ClassParserInterface::class])); + self::assertInstanceOf(ClassParserInterface::class, $container[ClassParserInterface::class]); + + } +} \ No newline at end of file diff --git a/tests/Unit/ServiceProvider/RegistryServiceProviderTest.php b/tests/Unit/ServiceProvider/RegistryServiceProviderTest.php new file mode 100644 index 0000000..d8344a8 --- /dev/null +++ b/tests/Unit/ServiceProvider/RegistryServiceProviderTest.php @@ -0,0 +1,31 @@ +getMockForAbstractClass(PhpClassConverterInterface::class); + + (new RegistryServiceProvider())->register($container); + + self::assertTrue(isset($container[ClassRegistryInterface::class])); + self::assertInstanceOf(ClassRegistryInterface::class, $container[ClassRegistryInterface::class]); + self::assertTrue(isset($container[SchemaRegistryInterface::class])); + self::assertInstanceOf(SchemaRegistryInterface::class, $container[SchemaRegistryInterface::class]); + } +} \ No newline at end of file