From d45a420afa88449530231811ac942a152d5fb8f9 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sat, 31 Oct 2026 12:38:20 -0500 Subject: [PATCH 1/3] config-show functionality Assisted-by: Claude Code --- src/Command/Config/ShowCommand.php | 88 ++++++++++++++++++++--- src/Command/Make/ModelCommand.php | 109 +++++++++++++++++++++++++++-- src/Generator/ModelNames.php | 12 +++- src/Project/Config.php | 5 +- 4 files changed, 196 insertions(+), 18 deletions(-) diff --git a/src/Command/Config/ShowCommand.php b/src/Command/Config/ShowCommand.php index f5294a4..9fa5fab 100644 --- a/src/Command/Config/ShowCommand.php +++ b/src/Command/Config/ShowCommand.php @@ -14,14 +14,21 @@ namespace Crest\Command\Config; use Crest\Command\ProjectCommand; +use Crest\Console\Exceptions\Exception; use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; +use Crest\Generator\ModelNames; use Crest\Project\Config; use Crest\Project\Flavor; use Crest\Project\Locator; +use function is_array; use function ksort; +use function sort; +use function var_export; + +use const SORT_STRING; /** * The configuration crest resolved for this project. @@ -29,6 +36,10 @@ * Printing the values without saying where each came from would answer the * easy half of the question: the useful part is knowing which of them the * project actually asked for, and which took a default. + * + * In an MVC project, the report also shows the models key (#71): camelCase + * with its origin, and each line of models.classes. A wrong key gives a row + * with the error, so the report never stops on it. */ final class ShowCommand extends ProjectCommand { @@ -50,17 +61,24 @@ public function handle(Input $input, Output $output): int $runtime = $config->runtime(); - $output->table( - ['ITEM', 'VALUE', 'ORIGIN'], - [ - ['root', $config->root(), Locator::FILENAME], - ['flavor', $config->flavor()->value, $this->origin($config, 'flavor')], - ['namespace', $config->namespace(), $this->origin($config, 'namespace')], - ['bootstrap', $config->bootstrap() ?? 'none', $this->origin($config, 'bootstrap')], - ['runtime', $runtime->type, $this->origin($config, 'runtime')], - ['service', $runtime->service, $this->origin($config, 'runtime.service')], - ] - ); + $items = [ + ['root', $config->root(), Locator::FILENAME], + ['flavor', $config->flavor()->value, $this->origin($config, 'flavor')], + ['namespace', $config->namespace(), $this->origin($config, 'namespace')], + ['bootstrap', $config->bootstrap() ?? 'none', $this->origin($config, 'bootstrap')], + ['runtime', $runtime->type, $this->origin($config, 'runtime')], + ['service', $runtime->service, $this->origin($config, 'runtime.service')], + ]; + + $classes = []; + + if (Flavor::MVC === $config->flavor()) { + [$row, $classes] = $this->models($config); + + $items[] = $row; + } + + $output->table(['ITEM', 'VALUE', 'ORIGIN'], $items); $paths = $config->paths(); ksort($paths); @@ -73,6 +91,11 @@ public function handle(Input $input, Output $output): int $output->line(); $output->table(['PATH', 'LOCATION', 'ORIGIN'], $rows); + if ([] !== $classes) { + $output->line(); + $output->table(['TABLE', 'CLASS'], $classes); + } + return 0; } @@ -81,6 +104,49 @@ protected function flavors(): array return Flavor::cases(); } + /** + * The models key of an MVC project (#71): the camelCase row, and a row + * for each line of models.classes, sorted by table. The origin comes from + * the key, because ModelNames does not keep it. A wrong key gives one + * models row with the error, and no lines: make:model stops on the error, + * but config:show does not. + * + * @return array{list, list>} + */ + private function models(Config $config): array + { + $models = $config->models(); + + try { + $names = ModelNames::of($models); + } catch (Exception $exception) { + return [['models', $exception->getMessage(), self::DECLARED], []]; + } + + // mapped() gives each table as a string: PHP makes a numeric table an + // integer key. + $tables = $names->mapped(); + sort($tables, SORT_STRING); + + $rows = []; + + foreach ($tables as $table) { + $rows[] = [$table, $names->classOf($table)]; + } + + // A null value counts as no value, as in ModelNames::of() (#53). + $declared = true === is_array($models) && true === isset($models['camelCase']); + + return [ + [ + 'camelCase', + var_export($names->camelCase(), true), + true === $declared ? self::DECLARED : self::DEFAULTED, + ], + $rows, + ]; + } + private function origin(Config $config, string $key): string { return true === $config->isDeclared($key) ? self::DECLARED : self::DEFAULTED; diff --git a/src/Command/Make/ModelCommand.php b/src/Command/Make/ModelCommand.php index 974a26e..24838f2 100644 --- a/src/Command/Make/ModelCommand.php +++ b/src/Command/Make/ModelCommand.php @@ -18,7 +18,9 @@ use Crest\Console\Input; use Crest\Console\Output; use Crest\Console\Parsing\Definition; +use Crest\Generator\ArtifactWriter; use Crest\Generator\BaseAttributes; +use Crest\Generator\ClassName; use Crest\Generator\ForeignKeys; use Crest\Generator\ModelNames; use Crest\Generator\ModelProperties; @@ -39,13 +41,17 @@ use function array_values; use function count; use function explode; +use function file_get_contents; use function implode; use function in_array; +use function is_file; use function max; use function sprintf; +use function str_contains; use function str_pad; use function strlen; use function strrpos; +use function strtolower; use function substr; use function trim; use function var_export; @@ -71,7 +77,8 @@ * a PropertyName attribute on the base, or models.camelCase. A property that * does not have the name of its column gives a columnMap() in the base. When * a property name changes, crest says so, and names the tables whose - * relations use it. + * relations use it. When a line of models.classes gives a table a new class, + * the old model and base stay, and crest names them in a Note line (#71). * * The work for one table has two phases. The read phase reads the columns, * the base, the names and the relations, and changes no file. The write phase @@ -94,6 +101,7 @@ * map: array, * names: array, * namespace: string, + * note: string|null, * properties: array, * relations: ModelRelations, * renamed: array|null, @@ -116,6 +124,15 @@ final class ModelCommand extends ProjectCommand private const NO_KEYS = 'No relations for %s: the table has no foreign keys, and no foreign keys point to it. ' . 'Relations come only from foreign keys.'; + /** + * The Note line for the old files of a table whose class now comes from + * models.classes (#71): with only the old base, and with the old model + * and the old base. The last %s is the end of the line. + */ + private const OLD_FILE = 'Note: the old file of %s stays: %s; move your code to %s, delete it%s'; + + private const OLD_FILES = 'Note: the old files of %s stay: %s and %s; move your code to %s, delete them%s'; + /** * The stubs that a base with a columnMap() needs, with the placeholder * of #53 in each one. @@ -166,6 +183,18 @@ private static function bases(ModelNames $names, array $tables, string $path): a return [$names, $bases]; } + /** + * The class of the table, or null for a table that gives no class. + */ + private static function classOrNull(ModelNames $names, string $table): ?string + { + try { + return $names->classOf($table); + } catch (Exception) { + return null; + } + } + /** * The names of --property: column => name (spec 3.1). Each item is split * at its last `=`, and the spaces at the start and the end of each part @@ -217,6 +246,73 @@ private static function mapped(array $names): bool return false; } + /** + * The Note line for the old files of the table (#71), or null. The class + * of the table comes from models.classes, so crest writes a new model and + * a new base, and the old files stay. There is a line only when: + * + * 1. the table gives a default class; + * 2. no table gives the default class, without regard to case. The + * tables are the tables of models.classes and of the database, and + * ForeignKeys::read() puts the table itself in them. When the table + * itself gives the default class, there are no old files: PHP sees + * User and USER as one class, and a file system that ignores case + * sees one file. When another table gives it, the old base can be + * the base of that table; + * 3. the old base exists and has ArtifactWriter::MARKER. + * + * The line also names the related tables that give a class and that this + * run does not write: their bases have relations with the old class. + * + * @param list $written The tables that this run writes + */ + private static function oldFiles( + ModelNames $names, + ForeignKeys $keys, + string $table, + string $class, + string $path, + array $written + ): ?string { + try { + $default = ClassName::fromTable($table); + } catch (Exception) { + return null; + } + + foreach ([...$names->mapped(), ...$keys->tables()] as $other) { + $found = self::classOrNull($names, $other); + + if (null !== $found && strtolower($found) === strtolower($default)) { + return null; + } + } + + // Suppressed: a missing or unreadable old base gives no line. The + // line is information, so it does not stop the command. + $base = self::baseFile($path, $default); + $contents = @file_get_contents($base); + + if (false === $contents || false === str_contains($contents, ArtifactWriter::MARKER)) { + return null; + } + + $run = []; + + foreach (array_diff($keys->related($table), $written) as $other) { + if (null !== self::classOrNull($names, $other)) { + $run[] = $other; + } + } + + $then = [] === $run ? '' : ', then run make:model for ' . Sentence::join($run); + $model = $path . '/' . $default . '.php'; + + return true === is_file($model) + ? sprintf(self::OLD_FILES, $table, $model, $base, $class, $then) + : sprintf(self::OLD_FILE, $table, $base, $class, $then); + } + /** * The keys of the array, as strings: PHP makes a numeric key an integer. * @@ -588,6 +684,7 @@ private function read( 'map' => true === $mapped ? $final : [], 'names' => $final, 'namespace' => $namespace, + 'note' => self::oldFiles($names, $keys, $table, $class, $path, $written), 'properties' => $columns, 'relations' => $relations, 'renamed' => $renamed, @@ -751,11 +848,15 @@ private function write(Config $config, array $parts, bool $force, Output $output if (Outcome::Kept === $outcome) { $output->line(sprintf('Kept %s: it exists; pass --force to replace it', $parts['file'])); - - return $outcome; + } else { + $output->success(sprintf('Created %s', $parts['file'])); } - $output->success(sprintf('Created %s', $parts['file'])); + // The old files of the table stay: the developer moves the code and + // deletes them (#71). + if (null !== $parts['note']) { + $output->line($parts['note']); + } return $outcome; } diff --git a/src/Generator/ModelNames.php b/src/Generator/ModelNames.php index 7e7285a..b44ba1a 100644 --- a/src/Generator/ModelNames.php +++ b/src/Generator/ModelNames.php @@ -242,6 +242,14 @@ public function attributes(string $table, array $columns): array return $attributes; } + /** + * models.camelCase, for config:show (#71). + */ + public function camelCase(): bool + { + return $this->camelCase; + } + /** * The line of models.classes, else ClassName::fromTable(), which stops * for a table that gives no usable class. @@ -295,7 +303,9 @@ public function fieldOf(string $table, string $column): string } /** - * The tables of models.classes, for the notes of --all (ModelTables). + * The tables of models.classes, as strings: for the notes of --all + * (ModelTables), for config:show, and for the old files of make:model + * (#71). * * @return list */ diff --git a/src/Project/Config.php b/src/Project/Config.php index a6e10de..bdf412e 100644 --- a/src/Project/Config.php +++ b/src/Project/Config.php @@ -310,8 +310,9 @@ public function isDeclared(string $key): bool /** * The `models` key of crest.php, as the file has it, or [] (#53). - * make:model checks it (Crest\Generator\ModelNames): Crest\Project does - * not import Crest\Generator (#48), and a bad value stops only make:model. + * make:model and config:show check it (Crest\Generator\ModelNames): + * Crest\Project does not import Crest\Generator (#48), and a bad value + * stops only make:model. */ public function models(): mixed { From e9a9107111371fdc032a3dc45a42113725f40e02 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sat, 31 Oct 2026 12:38:34 -0500 Subject: [PATCH 2/3] tests for the new functionality Assisted-by: Claude Code --- .../Command/Make/ModelCommandOldFilesTest.php | 364 ++++++++++++++++++ .../Migrations/SplitUsersMigration.php | 40 ++ tests/Unit/Command/Config/ShowCommandTest.php | 174 +++++++++ tests/Unit/Generator/ModelNamesTest.php | 9 + 4 files changed, 587 insertions(+) create mode 100644 tests/Database/Command/Make/ModelCommandOldFilesTest.php create mode 100644 tests/Support/Migrations/SplitUsersMigration.php diff --git a/tests/Database/Command/Make/ModelCommandOldFilesTest.php b/tests/Database/Command/Make/ModelCommandOldFilesTest.php new file mode 100644 index 0000000..a404075 --- /dev/null +++ b/tests/Database/Command/Make/ModelCommandOldFilesTest.php @@ -0,0 +1,364 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Database\Command\Make; + +use Crest\Generator\ArtifactWriter; +use Crest\Tests\Database\AbstractDatabaseTestCase; +use Crest\Tests\Support\Migrations\MessagesMigration; +use Crest\Tests\Support\Migrations\SplitUsersMigration; +use Crest\Tests\Support\Migrations\TwoFactorCodesMigration; +use Crest\Tests\Support\Migrations\UserProfilesDashMigration; +use Crest\Tests\Support\Migrations\UsersMigration; +use Crest\Tests\Support\Project\DatabaseFront; +use Crest\Tests\Support\RunsMakeModel; +use Phalcon\Di\Di; + +use function file_get_contents; +use function file_put_contents; +use function str_replace; +use function unlink; + +use const PHP_EOL; + +/** + * The Note line of make:model for the old files of a table whose class now + * comes from models.classes (#71). The tests run on each driver: + * DatabaseFront gives the database of the suite. A test starts with no + * tables. + * + * @group mysql + * @group pgsql + * @group sqlite + */ +final class ModelCommandOldFilesTest extends AbstractDatabaseTestCase +{ + use RunsMakeModel; + + /** + * The end of the line for a table with no keys, after "No relations for ". + */ + private const NO_KEYS = ': the table has no foreign keys, and no foreign keys point to it. ' + . 'Relations come only from foreign keys.'; + + private const USER = "['classes' => ['users' => 'User']]"; + + protected function setUp(): void + { + parent::setUp(); + $this->startScratchProject('make-model-old-files'); + } + + protected function tearDown(): void + { + // A boot makes a DI, and the first DI of the process is the default. + Di::reset(); + $this->endScratchProject(); + parent::tearDown(); + } + + public function testAllGivesTheNoteUnderTheHeadingOfTheTable(): void + { + // The run writes messages too, so the note does not name it. + $this->createTables(UsersMigration::class, MessagesMigration::class); + $this->writeThenMap(['--all'], self::USER); + + $status = $this->runCommand(['--all']); + + $this->assertSame(0, $status); + $this->assertStringEndsWith( + 'Table users' . PHP_EOL + . 'Created ' . $this->model('Base/UserBase.php') . PHP_EOL + . 'Created ' . $this->model('User.php') . PHP_EOL + . $this->note() + . PHP_EOL + . '2 tables: 1 model created, 1 kept, 0 failed.' . PHP_EOL, + $this->readStdout() + ); + } + + public function testAllWithExcludeNamesTheExcludedTable(): void + { + $this->createTables(UsersMigration::class, MessagesMigration::class); + $this->writeThenMap(['--all'], self::USER); + + $status = $this->runCommand(['--all', '--exclude=messages']); + + $this->assertSame(0, $status); + $this->assertSame( + 'Table users' . PHP_EOL + . 'Created ' . $this->model('Base/UserBase.php') . PHP_EOL + . 'Created ' . $this->model('User.php') . PHP_EOL + . $this->note(', then run make:model for messages') + . PHP_EOL + . '1 table: 1 model created, 0 kept, 0 failed.' . PHP_EOL, + $this->readStdout() + ); + } + + public function testASecondRunGivesTheNoteAgain(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], self::USER); + $this->runCommand(['users']); + $this->freshStreams(); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + 'No relations for users' . self::NO_KEYS . PHP_EOL + . 'Kept ' . $this->model('User.php') . ': it exists; pass --force to replace it' . PHP_EOL + . $this->note(), + $this->readStdout() + ); + } + + public function testNoNoteForALineWithTheDefaultClass(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], "['classes' => ['users' => 'Users']]"); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + 'No relations for users' . self::NO_KEYS . PHP_EOL + . 'Kept ' . $this->model('Users.php') . ': it exists; pass --force to replace it' . PHP_EOL, + $this->readStdout() + ); + } + + public function testNoNoteForALineWithTheDefaultClassInAnotherCase(): void + { + // PHP sees USERS and Users as one class. A file system that ignores + // case sees the old files as the new files. + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], "['classes' => ['users' => 'USERS']]"); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + 'Created ' . $this->model('Base/USERSBase.php') . PHP_EOL + . 'No relations for users' . self::NO_KEYS . PHP_EOL + . 'Created ' . $this->model('USERS.php') . PHP_EOL, + $this->readStdout() + ); + } + + public function testNoNoteForATableThatGivesNoClass(): void + { + // user-profiles gives no default class, so it has no old files. + $this->front(DatabaseFront::class, "['classes' => ['user-profiles' => 'UserProfile']]"); + $this->createTables(UserProfilesDashMigration::class); + + $status = $this->runCommand(['user-profiles']); + + $this->assertSame(0, $status); + $this->assertSame( + 'Created ' . $this->model('Base/UserProfileBase.php') . PHP_EOL + . 'No relations for user-profiles' . self::NO_KEYS . PHP_EOL + . 'Created ' . $this->model('UserProfile.php') . PHP_EOL, + $this->readStdout() + ); + } + + public function testNoNoteWhenAnotherLineGivesTheOldClass(): void + { + // UsersBase.php can be the base of members. + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], "['classes' => ['users' => 'User', 'members' => 'Users']]"); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame($this->createdUser(), $this->readStdout()); + } + + public function testNoNoteWhenAnotherTableGivesTheOldClass(): void + { + // us_ers gives UsErs, so UsersBase.php can be its base. + $this->createTables(UsersMigration::class, SplitUsersMigration::class); + $this->writeThenMap(['users'], self::USER); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame($this->createdUser(), $this->readStdout()); + } + + public function testNoNoteWhenTheOldBaseHasNoMarker(): void + { + // A base without the marker belongs to the developer (#46). + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], self::USER); + + $base = $this->model('Base/UsersBase.php'); + file_put_contents( + $base, + str_replace(ArtifactWriter::MARKER, 'Written by hand.', (string) file_get_contents($base)) + ); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame($this->createdUser(), $this->readStdout()); + } + + public function testNoNoteWhenTheOldBaseIsGone(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], self::USER); + unlink($this->model('Base/UsersBase.php')); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame($this->createdUser(), $this->readStdout()); + } + + public function testNoNoteWithoutALineForTheTable(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], "['classes' => ['orders' => 'Order']]"); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + 'No relations for users' . self::NO_KEYS . PHP_EOL + . 'Kept ' . $this->model('Users.php') . ': it exists; pass --force to replace it' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheNoteNamesBothOldFiles(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], self::USER); + + $before = [ + (string) file_get_contents($this->model('Users.php')), + (string) file_get_contents($this->model('Base/UsersBase.php')), + ]; + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame($this->createdUser() . $this->note(), $this->readStdout()); + // crest deletes nothing, and changes nothing in the old files. + $this->assertSame( + $before, + [ + (string) file_get_contents($this->model('Users.php')), + (string) file_get_contents($this->model('Base/UsersBase.php')), + ] + ); + } + + public function testTheNoteNamesOnlyTheBaseWhenTheOldModelIsGone(): void + { + $this->createTables(UsersMigration::class); + $this->writeThenMap(['users'], self::USER); + unlink($this->model('Users.php')); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + $this->createdUser() + . 'Note: the old file of users stays: ' . $this->model('Base/UsersBase.php') + . '; move your code to User, delete it' . PHP_EOL, + $this->readStdout() + ); + } + + public function testTheNoteNamesTheRelatedTables(): void + { + // The base of messages has relations with the class Users. + $this->createTables(UsersMigration::class, MessagesMigration::class); + $this->writeThenMap(['users'], self::USER); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertStringEndsWith( + 'Created ' . $this->model('User.php') . PHP_EOL + . $this->note(', then run make:model for messages'), + $this->readStdout() + ); + } + + public function testTheNoteSkipsARelatedTableThatGivesNoClass(): void + { + // 2fa_codes has no base, and make:model stops for it. + $this->createTables(UsersMigration::class, TwoFactorCodesMigration::class); + $this->writeThenMap(['users'], self::USER); + + $status = $this->runCommand(['users']); + + $this->assertSame(0, $status); + $this->assertSame( + 'Created ' . $this->model('Base/UserBase.php') . PHP_EOL + . 'Skipped the relation hasMany 2fa_codes(user_id) -> users(id): ' + . "'2fa_codes' gives no usable class name; expected a table name like 'users' or 'user_profiles'" + . PHP_EOL + . 'Created ' . $this->model('User.php') . PHP_EOL + . $this->note(), + $this->readStdout() + ); + } + + /** + * The lines of make:model users for the new class User, with no keys. + */ + private function createdUser(): string + { + return 'Created ' . $this->model('Base/UserBase.php') . PHP_EOL + . 'No relations for users' . self::NO_KEYS . PHP_EOL + . 'Created ' . $this->model('User.php') . PHP_EOL; + } + + /** + * A file in the `model` path of the scratch project. + */ + private function model(string $file): string + { + return $this->root . '/src/Models/' . $file; + } + + /** + * The Note line for both old files of users, and the end of the line. + */ + private function note(string $then = ''): string + { + return 'Note: the old files of users stay: ' . $this->model('Users.php') . ' and ' + . $this->model('Base/UsersBase.php') . '; move your code to User, delete them' . $then . PHP_EOL; + } + + /** + * make:model with no models key, so the model and the base of each + * default class exist. Then the models key of the test, and empty + * streams for the next run. + * + * @param list $arguments + */ + private function writeThenMap(array $arguments, string $models): void + { + $this->front(DatabaseFront::class); + $this->runCommand($arguments); + $this->front(DatabaseFront::class, $models); + $this->freshStreams(); + } +} diff --git a/tests/Support/Migrations/SplitUsersMigration.php b/tests/Support/Migrations/SplitUsersMigration.php new file mode 100644 index 0000000..c4ed782 --- /dev/null +++ b/tests/Support/Migrations/SplitUsersMigration.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Crest\Tests\Support\Migrations; + +use Phalcon\Talon\Database\Schema\AbstractSchema; + +/** + * The table us_ers. It gives the class UsErs, and PHP sees UsErs and Users + * as one class (#71). On each driver, us_ers and users are two tables. + */ +final class SplitUsersMigration extends AbstractSchema +{ + protected string $table = 'us_ers'; + + protected function getStatementsMysql(): array + { + return ['CREATE TABLE us_ers (id INT PRIMARY KEY)']; + } + + protected function getStatementsPgsql(): array + { + return ['CREATE TABLE us_ers (id INTEGER PRIMARY KEY)']; + } + + protected function getStatementsSqlite(): array + { + return ['CREATE TABLE us_ers (id INTEGER PRIMARY KEY)']; + } +} diff --git a/tests/Unit/Command/Config/ShowCommandTest.php b/tests/Unit/Command/Config/ShowCommandTest.php index 198edf5..a653515 100644 --- a/tests/Unit/Command/Config/ShowCommandTest.php +++ b/tests/Unit/Command/Config/ShowCommandTest.php @@ -46,6 +46,63 @@ protected function tearDown(): void $this->removeScratchDirectory(); } + /** + * An ADR or CLI project shows no models, also with a wrong key. + * + * @return iterable + */ + public static function otherFlavors(): iterable + { + yield 'adr' => ['adr', "['camelCase' => true, 'classes' => ['users' => 'User']]"]; + yield 'adr, a wrong key' => ['adr', "['camelCase' => 'yes']"]; + yield 'cli' => ['cli', "['camelCase' => true, 'classes' => ['users' => 'User']]"]; + yield 'cli, a wrong key' => ['cli', "['camelCase' => 'yes']"]; + } + + /** + * One case for each message of ModelNames::of(): the models key as PHP + * code, and the message. + * + * @return iterable + */ + public static function wrongModelsKeys(): iterable + { + yield 'not an array' => ["'yes'", 'crest.php: models must be an array']; + yield 'an unknown key' => [ + "['camelcase' => true]", + "crest.php: models has an unknown key 'camelcase'; the keys are classes and camelCase", + ]; + yield 'classes that are not an array' => [ + "['classes' => 'users']", + 'crest.php: models.classes must be an array of table => class', + ]; + yield 'camelCase that is not a boolean' => [ + "['camelCase' => 'yes']", + 'crest.php: models.camelCase must be true or false', + ]; + yield 'a class that is not a string' => [ + "['classes' => ['users' => 1]]", + "crest.php: models.classes: the class of 'users' must be a string", + ]; + yield 'a class that is not a name' => [ + "['classes' => ['user-profiles' => 'User Profile']]", + "crest.php: models.classes: 'user-profiles' gives 'User Profile', which is not a usable class name", + ]; + yield 'a reserved class' => [ + "['classes' => ['lists' => 'LIST']]", + "crest.php: models.classes: 'lists' gives the class LIST, but PHP reserves that name", + ]; + yield 'two tables, one class' => [ + "['classes' => ['users' => 'User', 'accounts' => 'User']]", + 'crest.php: models.classes: the tables accounts and users give the same class User', + ]; + yield 'two tables, one class in two cases' => [ + "['classes' => ['users' => 'User', 'members' => 'USER']]", + 'crest.php: models.classes: the tables members and users give the classes USER and User, ' + . 'which PHP sees as the same class', + ]; + } + public function testADefaultPathIsNotReportedAsDeclared(): void { // Declaring `views` leaves `action` on its default. Marking the whole @@ -65,6 +122,37 @@ public function testADefaultPathIsNotReportedAsDeclared(): void $this->assertStringContainsString('views ' . $this->root . '/templates declared', $output); } + /** + * @dataProvider otherFlavors + */ + public function testAnAdrOrCliProjectShowsNoModels(string $flavor, string $models): void + { + $this->writeModels($models, $flavor); + + $status = $this->runCommand(); + $output = $this->readStdout(); + + $this->assertSame(0, $status); + $this->assertDoesNotMatchRegularExpression('/^(camelCase|models|TABLE) /m', $output); + $this->assertSame('', $this->readStderr()); + } + + public function testAnEmptyClassesKeyGivesNoClassTable(): void + { + $this->writeModels("['camelCase' => false, 'classes' => []]"); + + $this->runCommand(); + + $output = $this->normalized(); + + $this->assertStringContainsString( + 'service app default' . PHP_EOL . 'camelCase false declared' . PHP_EOL, + $output + ); + // Nothing comes after the paths. + $this->assertStringEndsWith('views ' . $this->root . '/themes/default default' . PHP_EOL, $output); + } + public function testAnEmptyCrestPhpShowsTheDefaults(): void { $status = $this->runCommand(); @@ -78,6 +166,41 @@ public function testAnEmptyCrestPhpShowsTheDefaults(): void $this->assertStringContainsString($this->root, $output); } + public function testANullCamelCaseIsTheDefault(): void + { + $this->writeModels("['camelCase' => null]"); + + $this->runCommand(); + + $this->assertStringContainsString( + 'service app default' . PHP_EOL . 'camelCase false default' . PHP_EOL, + $this->normalized() + ); + } + + /** + * @dataProvider wrongModelsKeys + */ + public function testAWrongModelsKeyGivesAModelsRow(string $models, string $message): void + { + // make:model stops on the key; config:show shows the error and goes on. + $this->writeModels($models); + + $status = $this->runCommand(); + $output = $this->normalized(); + + $this->assertSame(0, $status); + $this->assertStringContainsString( + 'service app default' . PHP_EOL + . 'models ' . $message . ' declared' . PHP_EOL + . PHP_EOL + . 'PATH LOCATION ORIGIN' . PHP_EOL, + $output + ); + $this->assertDoesNotMatchRegularExpression('/^(camelCase|TABLE) /m', $output); + $this->assertSame('', $this->readStderr()); + } + public function testDeclaredPathsAreListed(): void { file_put_contents( @@ -152,6 +275,45 @@ public function testTheFrontControllerAndTheRuntimeAreShown(): void $this->assertStringContainsString('service app default' . PHP_EOL, $output); } + public function testTheModelsKeyIsShownForAnMvcProject(): void + { + // The lines are out of order, and PHP makes the tables 9 and 10 + // integer keys. The rows sort as strings, so 10 comes before 9. + $this->writeModels( + "['camelCase' => true, 'classes' => ['users' => 'User', '9' => 'Nine', " + . "'user-profiles' => 'UserProfile', '10' => 'Ten']]" + ); + + $status = $this->runCommand(); + + $expected = 'Source: ' . $this->root . '/crest.php' . PHP_EOL + . PHP_EOL + . 'ITEM VALUE ORIGIN' . PHP_EOL + . 'root ' . $this->root . ' crest.php' . PHP_EOL + . 'flavor mvc declared' . PHP_EOL + . 'namespace App default' . PHP_EOL + . 'bootstrap none default' . PHP_EOL + . 'runtime host default' . PHP_EOL + . 'service app default' . PHP_EOL + . 'camelCase true declared' . PHP_EOL + . PHP_EOL + . 'PATH LOCATION ORIGIN' . PHP_EOL + . 'command ' . $this->root . '/src/Command default' . PHP_EOL + . 'controller ' . $this->root . '/src/Controllers default' . PHP_EOL + . 'model ' . $this->root . '/src/Models default' . PHP_EOL + . 'provider ' . $this->root . '/src/Providers default' . PHP_EOL + . 'views ' . $this->root . '/themes/default default' . PHP_EOL + . PHP_EOL + . 'TABLE CLASS' . PHP_EOL + . '10 Ten' . PHP_EOL + . '9 Nine' . PHP_EOL + . 'user-profiles UserProfile' . PHP_EOL + . 'users User' . PHP_EOL; + + $this->assertSame(0, $status); + $this->assertSame($expected, $this->normalized()); + } + public function testTheWholeReportIsRenderedForADeclaredProject(): void { // Paths are declared out of alphabetical order, so the listing only @@ -177,6 +339,7 @@ public function testTheWholeReportIsRenderedForADeclaredProject(): void . 'bootstrap none default' . PHP_EOL . 'runtime host default' . PHP_EOL . 'service app default' . PHP_EOL + . 'camelCase false default' . PHP_EOL . PHP_EOL . 'PATH LOCATION ORIGIN' . PHP_EOL . 'admin ' . $this->root . '/backend declared' . PHP_EOL @@ -245,4 +408,15 @@ private function runCommand(): int return $kernel->handle(['crest', 'config:show', '--directory', $this->root]); } + + /** + * A crest.php with the flavor and the models key, given as PHP code. + */ + private function writeModels(string $models, string $flavor = 'mvc'): void + { + file_put_contents( + $this->root . '/crest.php', + " '" . $flavor . "', 'models' => " . $models . "];\n" + ); + } } diff --git a/tests/Unit/Generator/ModelNamesTest.php b/tests/Unit/Generator/ModelNamesTest.php index 13ffa1b..5db414e 100644 --- a/tests/Unit/Generator/ModelNamesTest.php +++ b/tests/Unit/Generator/ModelNamesTest.php @@ -266,6 +266,15 @@ public function testCamelCaseGivesTheName(string $column, string $expected): voi $this->assertSame($expected, ModelNames::of(['camelCase' => true])->propertyOf('t', $column)); } + public function testCamelCaseGivesTheValue(): void + { + // A null value counts as no value (#53). + $this->assertTrue(ModelNames::of(['camelCase' => true])->camelCase()); + $this->assertFalse(ModelNames::of(['camelCase' => false])->camelCase()); + $this->assertFalse(ModelNames::of(['camelCase' => null])->camelCase()); + $this->assertFalse(ModelNames::of([])->camelCase()); + } + public function testCamelCaseIsOffByDefault(): void { $this->assertSame('first_name', ModelNames::of([])->propertyOf('t', 'first_name')); From 67cf33df448e4967c85ec8f46f37ebd3e4c5535a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Sat, 31 Oct 2026 12:38:47 -0500 Subject: [PATCH 3/3] documents and changelog update Assisted-by: Claude Code --- CHANGELOG.md | 2 ++ README.md | 3 ++- docs/index.md | 46 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8347308..a4cabb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo - Added `new --database=none|mariadb|mysql|pgsql|sqlite` and its question: a server database gets the service `database` in `docker-compose.yml` (`mysql:8.4`, `mariadb:11.4` or `postgres:18`, with a health check and a named volume), the PDO extension in the Dockerfile, and the connection values in `.env`; `sqlite` gets the file `storage/database.sqlite`. Each flavor gets a `DbProvider` that registers the `db` service, so `make:model` reads the database with no manual step. The service reads each value from `getenv()`, then `$_ENV`, then a default for PHP on the host. Added `Crest\Generator\Project\DatabaseChoice`, the project stubs `project-provider-db` and `project-provider-db-sqlite` (ADR and MVC), and the shared project stubs `project-compose-mysql`, `project-compose-mariadb`, `project-compose-pgsql`, `project-env-database` and `project-env-sqlite`. [#54](https://github.com/phalcon/crest/issues/54) - Added `phalcon/phql` to the `composer.json` of an MVC project with Phalcon v6 and a database. [#54](https://github.com/phalcon/crest/issues/54) - Added the `models` key to the `crest.php` that `new` and `init` write for an MVC project. `new --camel-case` gives `'camelCase' => true`, and `new` asks for it for an MVC project with a database; `init` writes `false`. [#54](https://github.com/phalcon/crest/issues/54) +- Added the `models` key to `config:show` in an MVC project: a `camelCase` row with its origin, and a `TABLE CLASS` table with each line of `models.classes`, sorted by table. A wrong key gives one `models` row with the error; `config:show` still exits with 0, and `make:model` stops on the error. Added `Crest\Generator\ModelNames::camelCase()`. [#71](https://github.com/phalcon/crest/issues/71) +- Added a `Note` line to `make:model` for a table whose class comes from `models.classes`: it names the old model and base, which crest keeps, and the related tables to run `make:model` for again. [#71](https://github.com/phalcon/crest/issues/71) ### Changed diff --git a/README.md b/README.md index 1a0a78d..c33d2cd 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ return [ `models` changes the names that `make:model` gives in an MVC project: `classes` gives a class to a table, and `camelCase` gives camelCase property names. `crest new` and `crest init` write the key for an MVC project, with `'camelCase' => false`; -`crest new --camel-case` gives `true`. See "Names" in [docs/index.md](docs/index.md): +`crest new --camel-case` gives `true`. `crest config:show` shows the key. See +"Names" in [docs/index.md](docs/index.md): ```php return [ diff --git a/docs/index.md b/docs/index.md index 4292d3b..eab5a8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -661,20 +661,50 @@ return [ ]; ``` +In an MVC project, `crest config:show` shows the key: the row `camelCase`, with +its origin, and a table with each line of `classes`, sorted by table: + + ITEM VALUE ORIGIN + ... + service app default + camelCase true declared + + PATH LOCATION ORIGIN + ... + + TABLE CLASS + user-profiles UserProfile + users User + +When the key is not correct, `config:show` shows the error in a `models` row, +and `make:model` stops on it: + + service app default + models crest.php: models.camelCase must be true or false declared + **Classes.** `classes` gives a class to a table. Write a line only for each table whose class you want to change: the other tables keep the class of their -name (`users` gives `Users`). The table name must be exact, with the same case. -A line also gives a class to a table whose name gives none (`user-profiles`), or -gives a name that PHP reserves (`list`), also with `--all`. With `--all`, a line -for a table that the database does not have gives a note: +name. Crest does not make a singular class name: `users` gives `Users`, not +`User`. For `User`, write the line `'users' => 'User'`. The table name must be +exact, with the same case. A line also gives a class to a table whose name gives +none (`user-profiles`), or gives a name that PHP reserves (`list`), also with +`--all`. With `--all`, a line for a table that the database does not have gives a +note: Note: models.classes names the table Users, which the database does not have; did you mean users? When you add a line for a table that has a model, crest writes a new base and a -new model (`UserBase.php`, `User.php`), and keeps the old files. Move your code -to the new model, delete the old files, and run `make:model` for the tables that -have relations with the table. The new base does not get the `PropertyName` -attributes of the old base: give the names again with `--property`. +new model (`UserBase.php`, `User.php`), and keeps the old files. A note names +the old files, and the tables that have relations with the table: + + Note: the old files of users stay: src/Models/Users.php and src/Models/Base/UsersBase.php; move your code to User, delete them, then run make:model for invoices + +Move your code to the new model, delete the old files, and run `make:model` for +the tables of the note. Crest prints the note on each run until you delete the +old files. With `--all`, the note names only the related tables that the run +does not write, such as the tables of `--exclude`. The new base does not get the +`PropertyName` attributes of the old base: give the names again with +`--property`. **camelCase.** With `'camelCase' => true`, each property gets the camelCase form of its column: crest splits the column at each `_`, makes the first letter of