|
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Str;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
|
|
/**
|
|
* JSON-to-DTO Generator Command
|
|
*
|
|
* Analyzes JSON API response fixtures and generates JMS Serializer-compatible PHP DTOs.
|
|
*
|
|
* Features:
|
|
* - Detects types, nullability, nested objects, arrays, and dynamic maps
|
|
* - Merges same-named classes across endpoints
|
|
* - Generates proper #[Type] annotations for JMS Serializer
|
|
* - Creates Response DTOs (extend ApiResponse) and Model DTOs
|
|
*
|
|
* @see https://gist.github.com/your-username/your-gist-id
|
|
*/
|
|
class GenerateJmsDtosCommand extends Command
|
|
{
|
|
protected $signature = 'dto:generate
|
|
{path : Path to JSON fixtures directory (relative to base_path)}
|
|
{--output= : Output directory for generated DTOs (required)}
|
|
{--namespace= : Base namespace for generated DTOs (required)}
|
|
{--analyze : Only analyze and print schema, do not generate DTOs}';
|
|
|
|
protected $description = 'Analyze JSON fixtures and generate JMS Serializer-compatible DTOs';
|
|
|
|
private string $outputDir;
|
|
private string $baseNamespace;
|
|
|
|
// Global class registry: className => [properties, totalOccurrences, isResponse]
|
|
private array $classRegistry = [];
|
|
private int $totalFilesAcrossAllEndpoints = 0;
|
|
|
|
public function handle(): int
|
|
{
|
|
$basePath = base_path($this->argument('path'));
|
|
|
|
if (!is_dir($basePath)) {
|
|
$this->error("Directory not found: $basePath");
|
|
return 1;
|
|
}
|
|
|
|
$analyzeOnly = $this->option('analyze');
|
|
|
|
if (!$analyzeOnly) {
|
|
$this->outputDir = $this->option('output');
|
|
$this->baseNamespace = $this->option('namespace');
|
|
|
|
if (!$this->outputDir || !$this->baseNamespace) {
|
|
$this->error('Both --output and --namespace are required unless using --analyze');
|
|
$this->line('');
|
|
$this->line('Example usage:');
|
|
$this->line(' php artisan dto:generate temp/api-fixtures --analyze');
|
|
$this->line(' php artisan dto:generate temp/api-fixtures --output=app/SDK/MyApi --namespace="App\SDK\MyApi"');
|
|
return 1;
|
|
}
|
|
|
|
$this->outputDir = base_path($this->outputDir);
|
|
}
|
|
|
|
$endpoints = $this->findEndpoints($basePath);
|
|
|
|
if (empty($endpoints)) {
|
|
$this->error("No JSON files found in: $basePath");
|
|
return 1;
|
|
}
|
|
|
|
// Count total files across all endpoints
|
|
foreach ($endpoints as $jsonFiles) {
|
|
$this->totalFilesAcrossAllEndpoints += count($jsonFiles);
|
|
}
|
|
|
|
$this->info("Found " . count($endpoints) . " endpoint(s) with {$this->totalFilesAcrossAllEndpoints} JSON file(s)");
|
|
|
|
foreach ($endpoints as $endpoint => $jsonFiles) {
|
|
$this->info("\n" . str_repeat('=', 60));
|
|
$this->info("Endpoint: /$endpoint");
|
|
$this->info("Files: " . count($jsonFiles));
|
|
$this->info(str_repeat('=', 60));
|
|
|
|
$schema = $this->buildSchemaFromFiles($jsonFiles);
|
|
|
|
if ($analyzeOnly) {
|
|
$this->printSchema($schema, 0, count($jsonFiles));
|
|
} else {
|
|
$className = $this->endpointToClassName($endpoint);
|
|
$this->registerClass($className, $schema, count($jsonFiles), true);
|
|
}
|
|
}
|
|
|
|
if (!$analyzeOnly) {
|
|
$this->writeAllClasses();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Endpoint Discovery
|
|
// =========================================================================
|
|
|
|
private function findEndpoints(string $basePath): array
|
|
{
|
|
$endpoints = [];
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($basePath, RecursiveDirectoryIterator::SKIP_DOTS),
|
|
RecursiveIteratorIterator::SELF_FIRST
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile() && $file->getExtension() === 'json') {
|
|
$dir = dirname($file->getPathname());
|
|
$endpoint = str_replace($basePath . '/', '', $dir);
|
|
|
|
if (!isset($endpoints[$endpoint])) {
|
|
$endpoints[$endpoint] = [];
|
|
}
|
|
$endpoints[$endpoint][] = $file->getPathname();
|
|
}
|
|
}
|
|
|
|
ksort($endpoints);
|
|
return $endpoints;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Schema Building
|
|
// =========================================================================
|
|
|
|
private function buildSchemaFromFiles(array $jsonFiles): array
|
|
{
|
|
$mergedSchema = [];
|
|
$totalFiles = count($jsonFiles);
|
|
|
|
foreach ($jsonFiles as $file) {
|
|
$content = json_decode(file_get_contents($file), true);
|
|
if ($content === null) {
|
|
continue;
|
|
}
|
|
|
|
$this->mergeSchema($mergedSchema, $content, $totalFiles);
|
|
}
|
|
|
|
return $mergedSchema;
|
|
}
|
|
|
|
private function mergeSchema(array &$schema, mixed $data, int $totalFiles): void
|
|
{
|
|
if (!is_array($data) || !$this->isAssoc($data)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (is_numeric($key)) {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($schema[$key])) {
|
|
$schema[$key] = [
|
|
'types' => [],
|
|
'count' => 0,
|
|
'hasNull' => false,
|
|
'children' => [],
|
|
'isDynamicMap' => false,
|
|
'nestedChildren' => [],
|
|
'totalFiles' => $totalFiles,
|
|
];
|
|
}
|
|
|
|
$schema[$key]['count']++;
|
|
|
|
if ($value === null) {
|
|
$schema[$key]['hasNull'] = true;
|
|
$schema[$key]['types']['null'] = true;
|
|
continue;
|
|
}
|
|
|
|
$type = $this->getBaseType($value);
|
|
$schema[$key]['types'][$type] = true;
|
|
|
|
if (is_array($value)) {
|
|
if ($this->isAssoc($value)) {
|
|
if ($this->isDynamicMap($value)) {
|
|
$schema[$key]['isDynamicMap'] = true;
|
|
foreach ($value as $mapValue) {
|
|
if (is_array($mapValue) && $this->isAssoc($mapValue)) {
|
|
$this->mergeSchema($schema[$key]['children'], $mapValue, $totalFiles);
|
|
}
|
|
}
|
|
} else {
|
|
$this->mergeSchema($schema[$key]['children'], $value, $totalFiles);
|
|
}
|
|
} elseif (!empty($value)) {
|
|
$this->analyzeArrayItems($schema[$key], $value, $totalFiles);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function analyzeArrayItems(array &$fieldSchema, array $items, int $totalFiles): void
|
|
{
|
|
$itemTypes = [];
|
|
|
|
foreach ($items as $item) {
|
|
$itemType = $this->getBaseType($item);
|
|
$itemTypes[$itemType] = true;
|
|
|
|
if (is_array($item)) {
|
|
if ($this->isAssoc($item)) {
|
|
$this->mergeSchema($fieldSchema['children'], $item, $totalFiles);
|
|
} elseif (!empty($item)) {
|
|
$this->analyzeNestedArray($fieldSchema, $item, $totalFiles);
|
|
}
|
|
}
|
|
}
|
|
|
|
unset($fieldSchema['types']['array']);
|
|
if (!empty($itemTypes)) {
|
|
$typeStr = 'array<' . implode('|', array_keys($itemTypes)) . '>';
|
|
$fieldSchema['types'][$typeStr] = true;
|
|
} else {
|
|
$fieldSchema['types']['array<unknown>'] = true;
|
|
}
|
|
}
|
|
|
|
private function analyzeNestedArray(array &$fieldSchema, array $items, int $totalFiles): void
|
|
{
|
|
foreach ($items as $item) {
|
|
if (is_array($item) && $this->isAssoc($item)) {
|
|
$this->mergeSchema($fieldSchema['nestedChildren'], $item, $totalFiles);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function isDynamicMap(array $value): bool
|
|
{
|
|
if (empty($value)) {
|
|
return false;
|
|
}
|
|
|
|
$keys = array_keys($value);
|
|
$specialCharCount = 0;
|
|
$allValuesAreObjects = true;
|
|
|
|
foreach ($keys as $key) {
|
|
if (preg_match('/[;=]/', $key) || strlen($key) > 30) {
|
|
$specialCharCount++;
|
|
}
|
|
}
|
|
|
|
foreach ($value as $v) {
|
|
if (!is_array($v) || !$this->isAssoc($v)) {
|
|
$allValuesAreObjects = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $allValuesAreObjects && ($specialCharCount > 0 || count($keys) > 3);
|
|
}
|
|
|
|
private function getBaseType(mixed $value): string
|
|
{
|
|
if ($value === null) {
|
|
return 'null';
|
|
}
|
|
if (is_bool($value)) {
|
|
return 'bool';
|
|
}
|
|
if (is_int($value)) {
|
|
return 'int';
|
|
}
|
|
if (is_float($value)) {
|
|
return 'float';
|
|
}
|
|
if (is_string($value)) {
|
|
return 'string';
|
|
}
|
|
if (is_array($value)) {
|
|
if (empty($value)) {
|
|
return 'array';
|
|
}
|
|
return $this->isAssoc($value) ? 'object' : 'array';
|
|
}
|
|
return 'mixed';
|
|
}
|
|
|
|
private function isAssoc(array $arr): bool
|
|
{
|
|
if (empty($arr)) {
|
|
return false;
|
|
}
|
|
return array_keys($arr) !== range(0, count($arr) - 1);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Class Registration (merging same-named classes)
|
|
// =========================================================================
|
|
|
|
private function registerClass(string $className, array $schema, int $filesForThisEndpoint, bool $isResponse): void
|
|
{
|
|
if (!isset($this->classRegistry[$className])) {
|
|
$this->classRegistry[$className] = [
|
|
'properties' => [],
|
|
'totalOccurrences' => 0,
|
|
'isResponse' => $isResponse,
|
|
];
|
|
}
|
|
|
|
$this->classRegistry[$className]['totalOccurrences'] += $filesForThisEndpoint;
|
|
|
|
foreach ($schema as $key => $info) {
|
|
if (is_numeric($key)) {
|
|
continue;
|
|
}
|
|
|
|
$this->registerProperty($className, $key, $info, $filesForThisEndpoint);
|
|
}
|
|
}
|
|
|
|
private function registerProperty(string $className, string $key, array $info, int $filesForThisEndpoint): void
|
|
{
|
|
$registry = &$this->classRegistry[$className]['properties'];
|
|
|
|
if (!isset($registry[$key])) {
|
|
$registry[$key] = [
|
|
'types' => [],
|
|
'count' => 0,
|
|
'hasNull' => false,
|
|
'children' => [],
|
|
'isDynamicMap' => false,
|
|
'nestedChildren' => [],
|
|
];
|
|
}
|
|
|
|
// Merge types
|
|
foreach ($info['types'] as $type => $_) {
|
|
$registry[$key]['types'][$type] = true;
|
|
}
|
|
|
|
// Merge counts
|
|
$registry[$key]['count'] += $info['count'];
|
|
|
|
// Merge hasNull
|
|
if ($info['hasNull']) {
|
|
$registry[$key]['hasNull'] = true;
|
|
}
|
|
|
|
// Merge isDynamicMap
|
|
if ($info['isDynamicMap']) {
|
|
$registry[$key]['isDynamicMap'] = true;
|
|
}
|
|
|
|
// Handle nested objects - register as separate class
|
|
if (!empty($info['children'])) {
|
|
// Check if this is an array of objects - if so, singularize the class name
|
|
$isArrayOfObjects = false;
|
|
foreach ($info['types'] as $type => $_) {
|
|
if (str_starts_with($type, 'array<')) {
|
|
$isArrayOfObjects = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$baseName = $this->toPascalCase($key);
|
|
$nestedClassName = $isArrayOfObjects ? $this->singularize($baseName) : $baseName;
|
|
$this->registerClass($nestedClassName, $info['children'], $info['count'], false);
|
|
$registry[$key]['nestedClassName'] = $nestedClassName;
|
|
}
|
|
|
|
// Handle nested array children (array<array<object>>)
|
|
if (!empty($info['nestedChildren'])) {
|
|
$baseName = $this->toPascalCase($key);
|
|
$nestedClassName = $this->singularize($baseName);
|
|
$this->registerClass($nestedClassName, $info['nestedChildren'], $filesForThisEndpoint, false);
|
|
$registry[$key]['nestedArrayClassName'] = $nestedClassName;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// DTO Generation
|
|
// =========================================================================
|
|
|
|
private function endpointToClassName(string $endpoint): string
|
|
{
|
|
$parts = explode('/', trim($endpoint, '/'));
|
|
$lastPart = end($parts);
|
|
return $this->toPascalCase($lastPart) . 'Response';
|
|
}
|
|
|
|
private function toPascalCase(string $str): string
|
|
{
|
|
$str = str_replace(['-', '_'], ' ', $str);
|
|
return str_replace(' ', '', ucwords($str));
|
|
}
|
|
|
|
private function singularize(string $str): string
|
|
{
|
|
return Str::singular($str);
|
|
}
|
|
|
|
private function writeAllClasses(): void
|
|
{
|
|
$this->info("\n" . str_repeat('=', 60));
|
|
$this->info("Writing classes...");
|
|
$this->info(str_repeat('=', 60));
|
|
|
|
// Write ApiResponse.php to Response folder first
|
|
$this->writeApiResponse();
|
|
|
|
$written = 0;
|
|
|
|
foreach ($this->classRegistry as $className => $classInfo) {
|
|
$isResponse = $classInfo['isResponse'];
|
|
$folder = $isResponse ? 'Response' : 'Model';
|
|
$namespace = $this->baseNamespace . '\\' . $folder;
|
|
|
|
$properties = $this->buildProperties($classInfo['properties'], $classInfo['totalOccurrences']);
|
|
|
|
$this->writeClass($namespace, $className, $properties, $folder, $isResponse);
|
|
$written++;
|
|
}
|
|
|
|
$this->info("\nGenerated $written DTO classes + ApiResponse.php");
|
|
$this->info("Output: {$this->outputDir}");
|
|
}
|
|
|
|
private function writeApiResponse(): void
|
|
{
|
|
$dir = $this->outputDir . '/Response';
|
|
$filePath = $dir . '/ApiResponse.php';
|
|
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$namespace = $this->baseNamespace . '\\Response';
|
|
|
|
$content = <<<PHP
|
|
<?php
|
|
|
|
namespace $namespace;
|
|
|
|
use Illuminate\Http\Client\Response;
|
|
use JMS\Serializer\Naming\IdenticalPropertyNamingStrategy;
|
|
use JMS\Serializer\SerializerBuilder;
|
|
|
|
abstract class ApiResponse
|
|
{
|
|
public static function parse(Response \$response): static
|
|
{
|
|
\$data = \$response->body();
|
|
\$serializer = SerializerBuilder::create()
|
|
->setPropertyNamingStrategy(new IdenticalPropertyNamingStrategy())
|
|
->build();
|
|
|
|
return \$serializer->deserialize(\$data, static::class, 'json');
|
|
}
|
|
}
|
|
|
|
PHP;
|
|
|
|
file_put_contents($filePath, $content);
|
|
$this->line(" Response/ApiResponse.php");
|
|
}
|
|
|
|
private function buildProperties(array $registeredProps, int $totalOccurrences): array
|
|
{
|
|
$properties = [];
|
|
|
|
foreach ($registeredProps as $key => $info) {
|
|
$types = array_keys($info['types']);
|
|
$types = array_filter($types, fn($t) => $t !== 'null');
|
|
|
|
if (empty($types)) {
|
|
$types = ['null'];
|
|
}
|
|
|
|
$phpType = $this->resolvePhpType($types);
|
|
$jmsType = null;
|
|
|
|
// Field is missing in some responses -> nullable WITH default null
|
|
$isMissingInSome = $info['count'] < $totalOccurrences;
|
|
|
|
// Field always present but sometimes has explicit null -> nullable WITHOUT default (fail if missing)
|
|
$hasExplicitNull = $info['hasNull'];
|
|
|
|
// Handle nested objects
|
|
if (isset($info['nestedClassName'])) {
|
|
$nestedClassName = $info['nestedClassName'];
|
|
$modelNamespace = $this->baseNamespace . '\\Model';
|
|
$nestedFqcn = $modelNamespace . '\\' . $nestedClassName;
|
|
|
|
if ($info['isDynamicMap']) {
|
|
$phpType = 'array';
|
|
$jmsType = "array<string, $nestedFqcn>";
|
|
} elseif ($this->isArrayType($types)) {
|
|
$phpType = 'array';
|
|
$jmsType = "array<$nestedFqcn>";
|
|
} else {
|
|
$phpType = $nestedClassName;
|
|
$jmsType = $nestedFqcn;
|
|
}
|
|
}
|
|
|
|
// Handle nested array children (array<array<object>>)
|
|
if (isset($info['nestedArrayClassName'])) {
|
|
$nestedClassName = $info['nestedArrayClassName'];
|
|
$modelNamespace = $this->baseNamespace . '\\Model';
|
|
$nestedFqcn = $modelNamespace . '\\' . $nestedClassName;
|
|
|
|
$phpType = 'array';
|
|
$jmsType = "array<array<$nestedFqcn>>";
|
|
}
|
|
|
|
// Handle simple array types
|
|
if ($jmsType === null && $this->isArrayType($types)) {
|
|
$itemType = $this->extractArrayItemType($types);
|
|
if ($itemType && $itemType !== 'object') {
|
|
$jmsType = "array<$itemType>";
|
|
}
|
|
}
|
|
|
|
$properties[] = [
|
|
'name' => $key,
|
|
'phpType' => $phpType,
|
|
'isNullable' => $isMissingInSome || $hasExplicitNull,
|
|
'hasDefault' => $isMissingInSome, // only missing fields get default null
|
|
'jmsType' => $jmsType,
|
|
];
|
|
}
|
|
|
|
return $properties;
|
|
}
|
|
|
|
private function resolvePhpType(array $types): string
|
|
{
|
|
$baseTypes = [];
|
|
foreach ($types as $type) {
|
|
if (str_starts_with($type, 'array<')) {
|
|
return 'array';
|
|
}
|
|
$baseTypes[] = $type;
|
|
}
|
|
|
|
if (in_array('int', $baseTypes) && in_array('float', $baseTypes)) {
|
|
return 'float';
|
|
}
|
|
|
|
if (in_array('object', $baseTypes)) {
|
|
return 'object';
|
|
}
|
|
|
|
if (count($baseTypes) === 1) {
|
|
return match ($baseTypes[0]) {
|
|
'string' => 'string',
|
|
'int' => 'int',
|
|
'float' => 'float',
|
|
'bool' => 'bool',
|
|
'array' => 'array',
|
|
'null' => 'mixed',
|
|
default => 'mixed',
|
|
};
|
|
}
|
|
|
|
return 'mixed';
|
|
}
|
|
|
|
private function isArrayType(array $types): bool
|
|
{
|
|
foreach ($types as $type) {
|
|
if (str_starts_with($type, 'array')) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function extractArrayItemType(array $types): ?string
|
|
{
|
|
foreach ($types as $type) {
|
|
if (preg_match('/^array<(.+)>$/', $type, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function writeClass(string $namespace, string $className, array $properties, string $folder, bool $isResponse = false): void
|
|
{
|
|
$dir = $this->outputDir . '/' . $folder;
|
|
$filePath = $dir . '/' . $className . '.php';
|
|
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$content = $this->buildClassContent($namespace, $className, $properties, $isResponse);
|
|
file_put_contents($filePath, $content);
|
|
|
|
$relativePath = str_replace($this->outputDir . '/', '', $filePath);
|
|
$this->line(" $relativePath");
|
|
}
|
|
|
|
private function buildClassContent(string $namespace, string $className, array $properties, bool $isResponse = false): string
|
|
{
|
|
$lines = ["<?php", "", "namespace $namespace;", ""];
|
|
|
|
$imports = [];
|
|
|
|
// Collect Model class imports for properties that use them
|
|
$modelNamespace = $this->baseNamespace . '\\Model';
|
|
foreach ($properties as $prop) {
|
|
$phpType = $prop['phpType'];
|
|
// If phpType is a class name (not a primitive), it's a Model class
|
|
if (!in_array($phpType, ['string', 'int', 'float', 'bool', 'array', 'object', 'mixed'], true)) {
|
|
$imports[$modelNamespace . '\\' . $phpType] = true;
|
|
}
|
|
}
|
|
|
|
// Determine if we need JMS Type import (also needed for mixed, array, object types)
|
|
$needsJmsType = false;
|
|
foreach ($properties as $prop) {
|
|
if ($prop['jmsType'] !== null || in_array($prop['phpType'], ['mixed', 'array', 'object'], true)) {
|
|
$needsJmsType = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Sort and write imports
|
|
$importKeys = array_keys($imports);
|
|
sort($importKeys);
|
|
|
|
foreach ($importKeys as $import) {
|
|
$lines[] = "use $import;";
|
|
}
|
|
|
|
if ($needsJmsType) {
|
|
$lines[] = "use JMS\\Serializer\\Annotation\\Type;";
|
|
}
|
|
|
|
if (!empty($importKeys) || $needsJmsType) {
|
|
$lines[] = "";
|
|
}
|
|
|
|
// Class declaration - Response classes extend ApiResponse
|
|
if ($isResponse) {
|
|
$lines[] = "class $className extends ApiResponse";
|
|
} else {
|
|
$lines[] = "class $className";
|
|
}
|
|
$lines[] = "{";
|
|
|
|
foreach ($properties as $i => $prop) {
|
|
if ($i > 0) {
|
|
$lines[] = "";
|
|
}
|
|
|
|
$phpType = $prop['phpType'];
|
|
$isNullable = $prop['isNullable'];
|
|
|
|
// Determine type declaration
|
|
// mixed already includes null, so don't use ?mixed
|
|
if ($phpType === 'mixed') {
|
|
$typeDecl = 'mixed';
|
|
} elseif ($isNullable) {
|
|
$typeDecl = "?$phpType";
|
|
} else {
|
|
$typeDecl = $phpType;
|
|
}
|
|
|
|
// JMS Type annotation (required for mixed, array, and object types too)
|
|
if ($prop['jmsType'] !== null) {
|
|
$lines[] = " #[Type('{$prop['jmsType']}')]";
|
|
} elseif ($phpType === 'mixed') {
|
|
$lines[] = " #[Type('mixed')]";
|
|
} elseif ($phpType === 'array') {
|
|
$lines[] = " #[Type('array')]";
|
|
} elseif ($phpType === 'object') {
|
|
$lines[] = " #[Type('array')]"; // JMS doesn't support 'object', use array
|
|
}
|
|
|
|
// Only add default value if field is missing in some responses
|
|
// (explicit null values = nullable but NO default, so it fails if field missing)
|
|
// mixed is always nullable so give it default too
|
|
$hasDefault = $prop['hasDefault'] ?? false;
|
|
if ($hasDefault || $phpType === 'mixed') {
|
|
$lines[] = " public $typeDecl \${$prop['name']} = null;";
|
|
} else {
|
|
$lines[] = " public $typeDecl \${$prop['name']};";
|
|
}
|
|
}
|
|
|
|
$lines[] = "}";
|
|
$lines[] = "";
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Schema Printing (for --analyze mode)
|
|
// =========================================================================
|
|
|
|
private function printSchema(array $schema, int $indent, int $totalFiles): void
|
|
{
|
|
$prefix = str_repeat('│ ', $indent);
|
|
$keys = array_keys($schema);
|
|
$lastKey = end($keys);
|
|
|
|
foreach ($schema as $key => $info) {
|
|
$isLast = ($key === $lastKey);
|
|
$branch = $isLast ? '└── ' : '├── ';
|
|
|
|
$isNullable = $info['count'] < $totalFiles;
|
|
$nullableMarker = $isNullable ? '?' : '';
|
|
|
|
$types = array_keys($info['types']);
|
|
$types = array_filter($types, fn($t) => $t !== 'null');
|
|
|
|
if (empty($types)) {
|
|
$types = ['null'];
|
|
}
|
|
|
|
$typeStr = implode('|', $types);
|
|
|
|
if ($info['isDynamicMap']) {
|
|
$typeStr = 'map<string, object>';
|
|
}
|
|
|
|
$line = $prefix . $branch . $key . ': ' . $nullableMarker . $typeStr;
|
|
$this->line($line);
|
|
|
|
if (!empty($info['children'])) {
|
|
$childTotalFiles = $info['isDynamicMap'] ? $info['count'] : $totalFiles;
|
|
$this->printSchema($info['children'], $indent + 1, $childTotalFiles);
|
|
}
|
|
|
|
if (!empty($info['nestedChildren'])) {
|
|
$this->line($prefix . '│ ' . '└── []: object');
|
|
$this->printSchema($info['nestedChildren'], $indent + 2, $totalFiles);
|
|
}
|
|
}
|
|
}
|
|
}
|