diff --git a/src/CodeGen/ColumnTypeExtractor.php b/src/CodeGen/ColumnTypeExtractor.php new file mode 100644 index 00000000..0c504cef --- /dev/null +++ b/src/CodeGen/ColumnTypeExtractor.php @@ -0,0 +1,225 @@ + + */ + protected array $columnTypes = []; + + /** + * @var bool + */ + protected bool $inInitialize = false; + + /** + * Constructor + */ + public function __construct() + { + $version = PhpVersion::fromComponents(8, 1); + $this->parser = (new ParserFactory())->createForVersion($version); + } + + /** + * Extracts column type mappings from initialize method code + * + * @param string $code The initialize method code + * @return array Map of column names to type expressions + */ + public function extract(string $code): array + { + $this->columnTypes = []; + $this->inInitialize = false; + + try { + // Wrap code in a dummy class if needed for parsing + $wrappedCode = "parser->parse($wrappedCode); + + $traverser = new NodeTraverser(); + $traverser->addVisitor($this); + $traverser->traverse($ast); + } catch (Exception $e) { + // If parsing fails, return empty array + return []; + } + + return $this->columnTypes; + } + + /** + * @inheritDoc + */ + public function enterNode(Node $node) + { + // Check if we're entering the initialize method + if ($node instanceof Node\Stmt\ClassMethod && $node->name->name === 'initialize') { + $this->inInitialize = true; + + return null; + } + + // Only process nodes within initialize method + if (!$this->inInitialize) { + return null; + } + + // Look for $this->getSchema()->setColumnType() calls + if ($node instanceof Expression && $node->expr instanceof MethodCall) { + $this->processMethodCall($node->expr); + } elseif ($node instanceof MethodCall) { + $this->processMethodCall($node); + } + + return null; + } + + /** + * @inheritDoc + */ + public function leaveNode(Node $node) + { + if ($node instanceof Node\Stmt\ClassMethod && $node->name->name === 'initialize') { + $this->inInitialize = false; + } + + return null; + } + + /** + * Process a method call to check if it's setColumnType + * + * @param \PhpParser\Node\Expr\MethodCall $methodCall The method call to process + * @return void + */ + protected function processMethodCall(MethodCall $methodCall): void + { + // Check if this is a setColumnType call + if ($methodCall->name instanceof Node\Identifier && $methodCall->name->name === 'setColumnType') { + // Check if it's called on getSchema() + if ( + $methodCall->var instanceof MethodCall && + $methodCall->var->name instanceof Node\Identifier && + $methodCall->var->name->name === 'getSchema' && + $methodCall->var->var instanceof Variable && + $methodCall->var->var->name === 'this' + ) { + // Extract the column name and type expression + if (count($methodCall->args) >= 2) { + $columnArg = $methodCall->args[0]->value; + $typeArg = $methodCall->args[1]->value; + + // Get column name + $columnName = $this->getStringValue($columnArg); + if ($columnName === null) { + return; + } + + // Get the type expression as a string + $typeExpression = $this->getTypeExpression($typeArg); + if ($typeExpression !== null) { + $this->columnTypes[$columnName] = $typeExpression; + } + } + } + } + } + + /** + * Get string value from a node + * + * @param \PhpParser\Node $node The node to extract string from + * @return string|null The string value or null + */ + protected function getStringValue(Node $node): ?string + { + if ($node instanceof Node\Scalar\String_) { + return $node->value; + } + + return null; + } + + /** + * Convert a type expression node to string representation + * + * @param \PhpParser\Node $node The type expression node + * @return string|null String representation of the type expression + */ + protected function getTypeExpression(Node $node): ?string + { + // Handle EnumType::from() calls + if ( + $node instanceof Node\Expr\StaticCall && + $node->class instanceof Node\Name && + $node->name instanceof Node\Identifier + ) { + $className = $node->class->toString(); + $methodName = $node->name->name; + + // Handle EnumType::from() calls + if ($className === 'EnumType' || str_ends_with($className, '\\EnumType')) { + if ($methodName === 'from' && count($node->args) > 0) { + // Extract the enum class name + $arg = $node->args[0]->value; + if ($arg instanceof Node\Expr\ClassConstFetch) { + if ( + $arg->class instanceof Node\Name && + $arg->name instanceof Node\Identifier && + $arg->name->name === 'class' + ) { + $enumClass = $arg->class->toString(); + // Return the full EnumType::from() expression + return 'EnumType::from(' . $enumClass . '::class)'; + } + } + } + } + } + + // Handle simple string types + if ($node instanceof Node\Scalar\String_) { + return '"' . $node->value . '"'; + } + + return null; + } +} diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 6605d23f..4d68e1d9 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -16,6 +16,7 @@ */ namespace Bake\Command; +use Bake\CodeGen\ColumnTypeExtractor; use Bake\CodeGen\FileBuilder; use Bake\Utility\Model\EnumParser; use Bake\Utility\TableScanner; @@ -1210,11 +1211,24 @@ public function bakeTable(Table $model, array $data, Arguments $args, ConsoleIo $filename = $path . 'Table' . DS . $name . 'Table.php'; $parsedFile = null; + $customColumnTypes = []; if ($args->getOption('update')) { $parsedFile = $this->parseFile($filename); + // Extract custom column types from existing file + if ($parsedFile && isset($parsedFile->class->methods['initialize'])) { + $customColumnTypes = $this->extractCustomColumnTypes($parsedFile->class->methods['initialize']); + } } $entity = $this->_entityName($model->getAlias()); + $enums = $this->enums($model, $entity, $namespace); + + // Merge custom column types with generated enums + // Remove custom types that are now handled by enums + foreach ($enums as $field => $enumClass) { + unset($customColumnTypes[$field]); + } + $data += [ 'plugin' => $this->plugin, 'pluginPath' => $pluginPath, @@ -1228,7 +1242,8 @@ public function bakeTable(Table $model, array $data, Arguments $args, ConsoleIo 'validation' => [], 'rulesChecker' => [], 'behaviors' => [], - 'enums' => $this->enums($model, $entity, $namespace), + 'enums' => $enums, + 'customColumnTypes' => $customColumnTypes, 'connection' => $this->connection, 'fileBuilder' => new FileBuilder($io, "{$namespace}\Model\Table", $parsedFile), ]; @@ -1593,4 +1608,17 @@ protected function createAssociationAlias(array $association): string return $this->_modelNameFromKey($foreignKey); } + + /** + * Extract custom column type mappings from existing initialize method + * + * @param string $initializeMethod The initialize method code + * @return array Map of column names to type expressions + */ + protected function extractCustomColumnTypes(string $initializeMethod): array + { + $extractor = new ColumnTypeExtractor(); + + return $extractor->extract($initializeMethod); + } } diff --git a/templates/bake/Model/table.twig b/templates/bake/Model/table.twig index 3a9624af..3156bd9d 100644 --- a/templates/bake/Model/table.twig +++ b/templates/bake/Model/table.twig @@ -63,6 +63,12 @@ class {{ name }}Table extends Table{{ fileBuilder.classBuilder.implements ? ' im $this->getSchema()->setColumnType('{{ name }}', \Cake\Database\Type\EnumType::from(\{{ className }}::class)); {%~ endfor %} {% endif %} +{% if customColumnTypes is defined and customColumnTypes %} + + {%~ for columnName, typeExpression in customColumnTypes %} + $this->getSchema()->setColumnType('{{ columnName }}', {{ typeExpression|raw }}); + {%~ endfor %} +{% endif %} {% if behaviors %} {%~ for behavior, behaviorData in behaviors %} diff --git a/tests/TestCase/CodeGen/ColumnTypeExtractorTest.php b/tests/TestCase/CodeGen/ColumnTypeExtractorTest.php new file mode 100644 index 00000000..af8867fc --- /dev/null +++ b/tests/TestCase/CodeGen/ColumnTypeExtractorTest.php @@ -0,0 +1,176 @@ +extractor = new ColumnTypeExtractor(); + } + + /** + * Test extracting enum column types + * + * @return void + */ + public function testExtractEnumTypes(): void + { + $code = <<<'PHP' + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('issue_activities'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->getSchema()->setColumnType('activity_type', \Cake\Database\Type\EnumType::from(\App\Model\Enum\IssueActivityTypes::class)); + $this->getSchema()->setColumnType('status', \Cake\Database\Type\EnumType::from(\App\Model\Enum\StatusEnum::class)); + + $this->belongsTo('Issues', [ + 'foreignKey' => 'issue_id', + 'joinType' => 'INNER', + ]); + } +PHP; + + $result = $this->extractor->extract($code); + + $expected = [ + 'activity_type' => 'EnumType::from(App\Model\Enum\IssueActivityTypes::class)', + 'status' => 'EnumType::from(App\Model\Enum\StatusEnum::class)', + ]; + + $this->assertEquals($expected, $result); + } + + /** + * Test extracting with fully qualified class names + * + * @return void + */ + public function testExtractWithFullyQualifiedNames(): void + { + $code = <<<'PHP' + public function initialize(array $config): void + { + parent::initialize($config); + + $this->getSchema()->setColumnType('type', \Cake\Database\Type\EnumType::from(\My\App\Model\Enum\TypeEnum::class)); + } +PHP; + + $result = $this->extractor->extract($code); + + $expected = [ + 'type' => 'EnumType::from(My\App\Model\Enum\TypeEnum::class)', + ]; + + $this->assertEquals($expected, $result); + } + + /** + * Test extracting no column types from empty method + * + * @return void + */ + public function testExtractEmptyMethod(): void + { + $code = <<<'PHP' + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('users'); + $this->setDisplayField('name'); + $this->setPrimaryKey('id'); + } +PHP; + + $result = $this->extractor->extract($code); + + $this->assertEquals([], $result); + } + + /** + * Test extracting with mixed content + * + * @return void + */ + public function testExtractMixedContent(): void + { + $code = <<<'PHP' + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('articles'); + + // Custom column type mapping + $this->getSchema()->setColumnType('status', \Cake\Database\Type\EnumType::from(\App\Model\Enum\ArticleStatus::class)); + + $this->addBehavior('Timestamp'); + + // Another custom mapping + $this->getSchema()->setColumnType('priority', \Cake\Database\Type\EnumType::from(\App\Model\Enum\PriorityEnum::class)); + + $this->belongsTo('Users'); + } +PHP; + + $result = $this->extractor->extract($code); + + $expected = [ + 'status' => 'EnumType::from(App\Model\Enum\ArticleStatus::class)', + 'priority' => 'EnumType::from(App\Model\Enum\PriorityEnum::class)', + ]; + + $this->assertEquals($expected, $result); + } + + /** + * Test extracting with invalid code returns empty array + * + * @return void + */ + public function testExtractInvalidCode(): void + { + $code = 'this is not valid PHP code {'; + + $result = $this->extractor->extract($code); + + $this->assertEquals([], $result); + } +}