setDescription('Describe models');
$this->setHelp('This command will add neccessary @property annotations to SimpleORMap classes in a folder');
$this->addArgument(
'folder',
InputArgument::OPTIONAL,
'Folder to scan (will default to lib/models)',
'lib/models'
);
$this->addOption(
'recursive',
'r',
InputOption::VALUE_NEGATABLE,
'Scan into subfolders recursively'
);
$this->addOption(
'bootstrap',
'b',
InputOption::VALUE_OPTIONAL,
'Execute bootstrap file before scanning folder'
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$bootstrap = $input->getOption('bootstrap');
if ($bootstrap) {
if (!file_exists($bootstrap)) {
throw new Exception("Invalid bootstrap file {$bootstrap} provided");
}
require_once $bootstrap;
}
$recursive = $input->getOption('recursive') ?? false;
$folder = $input->getArgument('folder');
$iterator = $this->getFolderIterator($folder, $recursive, ['php']);
$this->progress = new ProgressBar($output, iterator_count($iterator));
$this->progress->start();
foreach ($iterator as $file) {
$filename = $file->getFilename();
if (!is_writable($file->getRealPath())) {
$this->outputForFile(
$output,
'Skipping not writable file ' . $this->relativeFilePath($file->getPathname()) . ''
);
continue;
}
$class_name = str_replace('.class.php', '.php', $filename);
$class_name = pathinfo($class_name, PATHINFO_FILENAME);
if (!class_exists($class_name)) {
$class_name = $this->getClassNameFromFile($file->getPathname()) ?? $class_name;
}
if (!class_exists($class_name) || !is_subclass_of($class_name, SimpleORMap::class)) {
$this->outputForFile(
$output,
"Skipping invalid class file {$filename} (class {$class_name})",
OutputInterface::VERBOSITY_VERBOSE
);
continue;
}
try {
$this->reflection = new ReflectionClass($class_name);
} catch (Error $e) {
$this->outputForFile(
$output,
"Could not get reflection for class {$class_name} ({$e->getMessage()})"
);
continue;
}
if ($this->reflection->isAbstract()) {
$this->outputForFile(
$output,
"Skipping abstract class {$class_name}",
OutputInterface::VERBOSITY_VERBOSE
);
continue;
}
$model = $this->reflection->newInstance();
// Get current properties
$current_properties = $this->getPropertiesFromDocBlock();
// Get configuration for class
$config_property = $this->reflection->getProperty('config');
$config_property->setAccessible(true);
$model_config = $config_property->getValue()[$class_name];
// Get table metadata
$meta = $model->getTableMetaData();
$properties = [];
if (!isset($meta['fields']['id']) && count($meta['pk']) > 0) {
$properties['id'] = [
'type' => count($meta['pk']) > 1 ? 'array' : $this->getPHPType($meta['pk'][0], $meta['fields'][$meta['pk'][0]]),
'description' => $current_properties['id']['description'] ?? 'alias for pk',
];
}
foreach ($meta['fields'] as $field => $info) {
$name = mb_strtolower($field);
$type = $this->getPHPType($field, $info, $model_config);
if ($type === 'int' && isset($current_properties[$field]) && $current_properties[$field]['type'] === 'bool') {
$type = 'bool';
}
$properties[$name] = [
'type' => $type,
'description' => $current_properties[$field]['description'] ?? 'database column',
];
$alias = array_search($name, $meta['alias_fields']);
if ($alias) {
$properties[$alias] = [
'type' => $type,
'description' => $current_properties[$field]['description'] ?? "alias column for {$name}",
];
}
}
foreach ($meta['relations'] as $relation) {
$options = $model->getRelationOptions($relation);
$related_class_name = $options['class_name'];
if (in_array($options['type'], ['has_many', 'has_and_belongs_to_many'])) {
$related_type = sprintf(
'%s<%s>',
$this->adjustNamespaceForClass(SimpleORMapCollection::class),
$this->adjustNameSpaceForClass($related_class_name),
);
} else {
$related_type = $this->adjustNamespaceForClass($related_class_name);
if (
$options['foreign_key'] !== 'id'
&& !is_callable($options['foreign_key'])
&& isset($meta['fields'][$options['foreign_key']])
&& $meta['fields'][$options['foreign_key']]['null'] === 'YES'
) {
$related_type .= '|null';
} elseif (preg_match('/^find(One?)By/', $options['assoc_func'])) {
$related_type .= '|null';
}
}
$properties[$relation] = [
'type' => $related_type,
'description' => $current_properties[$relation]['description'] ?? ("{$options['type']} " . $this->adjustNamespaceForClass($related_class_name)),
];
}
foreach ($meta['additional_fields'] as $field => $definition) {
if ($field === 'id') {
continue;
}
$property_type = null;
if (isset($definition['get']) && !isset($definition['set'])) {
$property_type = 'property-read';
} elseif (!isset($definition['get']) && isset($definition['set'])) {
$property_type = 'property-write';
}
$properties[$field] = [
'property_type' => $property_type,
'type' => $current_properties[$field]['type'] ?? $this->getAdditionFieldType($definition),
'description' => $current_properties[$field]['description'] ?? 'additional field',
];
}
if ($this->updateDocBlockOfClass($properties)) {
$this->outputForFile(
$output,
'Updated ' . $this->relativeFilePath($file->getPathname()) . ''
);
} else {
$this->outputForFile(
$output,
'No changes in ' . $this->relativeFilePath($file->getPathname()),
OutputInterface::VERBOSITY_VERBOSE
);
}
}
$this->progress->clear();
return Command::SUCCESS;
}
private function outputForFile($output, ...$args)
{
$this->progress->advance();
$this->progress->clear();
$output->writeln(...$args);
$this->progress->display();
}
private function getPHPType(string $field, array $info, array $config = []): string
{
$field = strtolower($field);
$type = [];
if (isset($config['serialized_fields'][$field])) {
$type[] = $this->adjustNamespaceForClass($config['serialized_fields'][$field]);
} elseif (isset($config['i18n_fields'][$field])) {
$type[] = $this->adjustNamespaceForClass(I18NString::class);
} elseif (preg_match('/^(?:tiny|small|medium|big)?int(?:eger)?/i', $info['type'])) {
$type[] = 'int';
} elseif (preg_match('/^(?:decimal|double|float|numeric)/i', $info['type'])) {
$type[] = 'float';
} else {
$type[] = 'string';
}
if ($info['null'] === 'YES') {
$type[] = 'null';
}
return implode('|', $type);
}
private function updateDocBlockOfClass(array $properties): bool
{
$has_docblock = (bool) $this->reflection->getDocComment();
$docblock = $this->reflection->getDocComment() ?: $this->getDefaultDocblock();
$docblock_lines = array_map('rtrim', explode("\n", $docblock));
$properties_started = false;
$docblock_lines = array_filter($docblock_lines, function ($line) use (&$properties_started) {
$line = ltrim($line, '* ');
if ($properties_started) {
return $line === '/';
}
$properties_started = str_starts_with($line, '@property ');
return !$properties_started;
});
$docblock_lines = array_reverse($docblock_lines);
while ($docblock_lines[1] === ' *') {
array_splice($docblock_lines, 1, 1);
}
$docblock_lines = array_reverse($docblock_lines);
$properties = array_map(function ($variable, $property) {
return sprintf(
' * @%s %s $%s %s',
$property['property_type'] ?? 'property',
$property['type'],
$variable,
$property['description']
);
}, array_keys($properties), array_values($properties));
array_unshift($properties, ' *');
array_splice($docblock_lines, -1, 0, $properties);
$new_docblock = implode("\n", $docblock_lines);
if ($docblock === $new_docblock) {
return false;
}
$contents = file_get_contents($this->reflection->getFileName());
if ($has_docblock) {
$contents = str_replace($docblock, $new_docblock, $contents);
} else {
$contents = preg_replace(
'/^class/m',
$new_docblock . "\nclass",
$contents,
1
);
}
file_put_contents($this->reflection->getFileName(), $contents);
return true;
}
private function getDefaultDocBlock(): string
{
return implode("\n", [
'/**',
' * @license GPL2 or any later version',
' */',
]);
}
/**
* @see https://stackoverflow.com/a/14250011
*/
private function getClassNameFromFile(string $filename): ?string
{
$code = file_get_contents($filename);
$tokens = token_get_all($code);
$namespace = '';
for ($i = 0, $l = count($tokens); $i < $l; $i += 1) {
if ($tokens[$i][0] === T_NAMESPACE) {
for ($j= $i + 1; $j < $l; $j += 1) {
if ($tokens[$j][0] === T_STRING) {
$namespace .= "\\" . $tokens[$j][1];
} elseif ($tokens[$j] === '{' || $tokens[$j] === ';') {
break;
}
}
}
if ($tokens[$i][0] === T_CLASS) {
for ($j = $i + 1; $j < $l; $j += 1) {
if ($tokens[$j] === '{') {
return ltrim($namespace . "\\" . $tokens[$i + 2][1], "\\");
}
}
}
}
return null;
}
private function adjustNamespaceForClass(string $class): string
{
if (!$this->reflection->inNamespace()) {
return $class;
}
$namespace = $this->reflection->getNamespaceName();
$class = "\\{$class}";
if (str_starts_with($class, "\\{$namespace}")) {
$class = substr($class, strlen($namespace) + 2);
}
return $class;
}
private function getAdditionFieldType($definition): string
{
// TODO: Try to get a reasonable field type
// $definition may either be an array with definition or a boolean value
return 'mixed';
}
private function getPropertiesFromDocBlock(): array
{
$docblock = $this->reflection->getDocComment();
if (!trim($docblock)) {
return [];
}
$config = new ParserConfig([]);
$lexer = new Lexer($config);
$constExprParser = new ConstExprParser($config);
$typeParser = new TypeParser($config, $constExprParser);
$phpDocParser = new PhpDocParser($config, $typeParser, $constExprParser);
$tokens = new TokenIterator($lexer->tokenize($docblock));
$parsed = $phpDocParser->parse($tokens);
$properties = [
...$parsed->getPropertyTagValues(),
...$parsed->getPropertyReadTagValues(),
...$parsed->getPropertyWriteTagValues(),
];
$result = [];
foreach ($properties as $property) {
$key = substr($property->propertyName, 1);
$result[$key] = [
'type' => (string) $property->type,
'description' => $this->cleanupDescription($property->description),
];
}
return $result;
}
private function cleanupDescription(string $description): ?string
{
if (
in_array($description, ['database column', 'additional field', 'alias for pk'])
|| preg_match('/^(has_one|has_many|has_and_belongs_to_many|belongs_to) \\S+/', $description)
|| preg_match('/^alias column for \\S+/', $description)
) {
return null;
}
return $description;
}
}