From dd702f872742b0d3f843873c77b2bfecb1ba7114 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 8 Jun 2026 14:43:36 +0800 Subject: [PATCH] refactor: migrate `db:table` as a modern command --- system/Commands/Database/ShowTableInfo.php | 398 ++++++++---------- .../Database/ShowTableInfoMockIOTest.php | 4 +- .../Commands/Database/ShowTableInfoTest.php | 99 ++++- user_guide_src/source/changelogs/v4.8.0.rst | 2 + 4 files changed, 271 insertions(+), 232 deletions(-) diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php index d05159b1b10a..3a4fa60f401b 100644 --- a/system/Commands/Database/ShowTableInfo.php +++ b/system/Commands/Database/ShowTableInfo.php @@ -13,311 +13,286 @@ namespace CodeIgniter\Commands\Database; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database; /** - * Get table data if it exists in the database. - * - * @see \CodeIgniter\Commands\Database\ShowTableInfoTest + * Retrieves information on the selected table. */ -class ShowTableInfo extends BaseCommand +#[Command( + name: 'db:table', + description: 'Retrieves information on the selected table.', + group: 'Database', +)] +class ShowTableInfo extends AbstractCommand { - /** - * The group the command is lumped under - * when listing commands. - * - * @var string - */ - protected $group = 'Database'; + private ?BaseConnection $db = null; /** - * The Command's name - * - * @var string + * @var string The sort order for table rows. */ - protected $name = 'db:table'; + private string $sortOrder = 'ASC'; - /** - * the Command's short description - * - * @var string - */ - protected $description = 'Retrieves information on the selected table.'; + private string $dbPrefix; - /** - * the Command's usage - * - * @var string - */ - protected $usage = <<<'EOL' - db:table [] [options] - - Examples: - db:table --show - db:table --metadata - db:table my_table --metadata - db:table my_table - db:table my_table --limit-rows 5 --limit-field-value 10 --desc - EOL; + protected function configure(): void + { + $this + ->addArgument(new Argument( + name: 'table_name', + description: 'The table name to show info.', + default: '', + )) + ->addOption(new Option( + name: 'show', + description: 'Lists the names of all database tables.', + )) + ->addOption(new Option( + name: 'metadata', + description: 'Retrieves list containing field information.', + )) + ->addOption(new Option( + name: 'desc', + description: 'Sorts the table rows in DESC order.', + )) + ->addOption(new Option( + name: 'limit-rows', + description: 'Limits the number of rows.', + requiresValue: true, + default: '10', + valueLabel: 'rows', + )) + ->addOption(new Option( + name: 'limit-field-value', + description: 'Limits the length of field values.', + requiresValue: true, + default: '15', + valueLabel: 'value', + )) + ->addOption(new Option( + name: 'dbgroup', + description: 'Database group to show.', + requiresValue: true, + default: '', + valueLabel: 'group', + )) + ->addUsage('db:table --show') + ->addUsage('db:table --metadata') + ->addUsage('db:table my_table --metadata') + ->addUsage('db:table my_table') + ->addUsage('db:table my_table --limit-rows 5 --limit-field-value 10 --desc'); + } - /** - * The Command's arguments - * - * @var array - */ - protected $arguments = [ - 'table_name' => 'The table name to show info', - ]; + protected function interact(array &$arguments, array &$options): void + { + if ($this->hasUnboundOption('show', $options)) { + return; + } - /** - * The Command's options - * - * @var array - */ - protected $options = [ - '--show' => 'Lists the names of all database tables.', - '--metadata' => 'Retrieves list containing field information.', - '--desc' => 'Sorts the table rows in DESC order.', - '--limit-rows' => 'Limits the number of rows. Default: 10.', - '--limit-field-value' => 'Limits the length of field values. Default: 15.', - '--dbgroup' => 'Database group to show.', - ]; + try { + $db = Database::connect($this->resolveDbGroup($this->getUnboundOption('dbgroup', $options))); + } catch (InvalidArgumentException) { + return; + } - /** - * @var list> Table Data. - */ - private array $tbody; + $tables = $db->listTables(); - private ?BaseConnection $db = null; + if ($tables === false || $tables === []) { + return; + } - /** - * @var bool Sort the table rows in DESC order or not. - */ - private bool $sortDesc = false; + while (! in_array($arguments[0] ?? '', $tables, true)) { + $tableKey = CLI::promptByKey( + ['Here is the list of your database tables:', 'Which table do you want to see?'], + $tables, + 'required', + ); + CLI::newLine(); - private string $DBPrefix; + $arguments[0] = $tables[$tableKey] ?? ''; + } + } - public function run(array $params) + protected function execute(array $arguments, array $options): int { - $dbGroup = $params['dbgroup'] ?? CLI::getOption('dbgroup'); - try { - $this->db = Database::connect($dbGroup); + $this->db = Database::connect($this->resolveDbGroup($options['dbgroup'])); } catch (InvalidArgumentException $e) { CLI::error($e->getMessage()); return EXIT_ERROR; } - $this->DBPrefix = $this->db->getPrefix(); + $this->dbPrefix = $this->db->getPrefix(); - $this->showDBConfig(); + $this->showDbConfig(); $tables = $this->db->listTables(); - if (array_key_exists('desc', $params)) { - $this->sortDesc = true; - } + $this->sortOrder = $options['desc'] === true ? 'DESC' : 'ASC'; - if ($tables === []) { + if ($tables === false || $tables === []) { CLI::error('Database has no tables!', 'light_gray', 'red'); - CLI::newLine(); return EXIT_ERROR; } - if (array_key_exists('show', $params)) { + if ($options['show'] === true) { $this->showAllTables($tables); - return EXIT_ERROR; + return EXIT_SUCCESS; } - $tableName = $params[0] ?? null; - $limitRows = (int) ($params['limit-rows'] ?? 10); - $limitFieldValue = (int) ($params['limit-field-value'] ?? 15); + $tableName = $arguments['table_name']; + assert(is_string($tableName)); - while (! in_array($tableName, $tables, true)) { - $tableNameNo = CLI::promptByKey( - ['Here is the list of your database tables:', 'Which table do you want to see?'], - $tables, - 'required', + if (! in_array($tableName, $tables, true)) { + CLI::error( + $tableName === '' + ? 'No table name was specified.' + : sprintf('Table "%s" was not found in the database.', $tableName), + 'light_gray', + 'red', ); - CLI::newLine(); - $tableName = $tables[$tableNameNo] ?? null; + return EXIT_ERROR; } - if (array_key_exists('metadata', $params)) { + if ($options['metadata'] === true) { $this->showFieldMetaData($tableName); return EXIT_SUCCESS; } - $this->showDataOfTable($tableName, $limitRows, $limitFieldValue); + $limitRows = $options['limit-rows']; + $limitFieldValue = $options['limit-field-value']; + assert(is_string($limitRows) && is_string($limitFieldValue)); + + $this->showDataOfTable($tableName, (int) $limitRows, (int) $limitFieldValue); return EXIT_SUCCESS; } - private function showDBConfig(): void + private function resolveDbGroup(mixed $group): ?string + { + return is_string($group) && $group !== '' ? $group : null; + } + + private function showDbConfig(): void { - $data = [[ - 'hostname' => $this->db->hostname, - 'database' => $this->db->getDatabase(), - 'username' => $this->db->username, - 'DBDriver' => $this->db->getPlatform(), - 'DBPrefix' => $this->DBPrefix, - 'port' => $this->db->port, - ]]; - CLI::table( - $data, - ['hostname', 'database', 'username', 'DBDriver', 'DBPrefix', 'port'], - ); + CLI::table([[ + $this->db->hostname, + $this->db->getDatabase(), + $this->db->username, + $this->db->getPlatform(), + $this->dbPrefix, + $this->db->port, + ]], ['Hostname', 'Database', 'Username', 'DB Driver', 'DB Prefix', 'Port']); } - private function removeDBPrefix(): void + private function removeDbPrefix(): void { $this->db->setPrefix(''); } - private function restoreDBPrefix(): void + private function restoreDbPrefix(): void { - $this->db->setPrefix($this->DBPrefix); + $this->db->setPrefix($this->dbPrefix); } - /** - * Show Data of Table - * - * @return void - */ - private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue) + private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue): void { - CLI::write("Data of Table \"{$tableName}\":", 'black', 'yellow'); + CLI::write(sprintf('Data of "%s" table:', $tableName), 'black', 'yellow'); CLI::newLine(); - $this->removeDBPrefix(); - $thead = $this->db->getFieldNames(TableName::fromActualName($this->db->DBPrefix, $tableName)); - $this->restoreDBPrefix(); + $this->removeDbPrefix(); + + $table = TableName::fromActualName($this->db->getPrefix(), $tableName); + $fieldNames = $this->db->getFieldNames($table); // If there is a field named `id`, sort by it. - $sortField = null; - if (in_array('id', $thead, true)) { - $sortField = 'id'; + $sortField = in_array('id', $fieldNames, true) ? 'id' : ''; + + $builder = $this->db->table($table)->limit($limitRows); + + if ($sortField !== '') { + $builder->orderBy($sortField, $this->sortOrder); } - $this->tbody = $this->makeTableRows($tableName, $limitRows, $limitFieldValue, $sortField); - CLI::table($this->tbody, $thead); - } + $rows = $builder->get()->getResultArray(); - /** - * Show All Tables - * - * @param list $tables - * - * @return void - */ - private function showAllTables(array $tables) - { - CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow'); - CLI::newLine(); + $this->restoreDbPrefix(); - $thead = ['ID', 'Table Name', 'Num of Rows', 'Num of Fields']; - $this->tbody = $this->makeTbodyForShowAllTables($tables); + $thead = array_map(ucfirst(...), $fieldNames); - CLI::table($this->tbody, $thead); - CLI::newLine(); + $tbody = []; + + foreach ($rows as $row) { + $tbody[] = array_map( + static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue + ? mb_substr((string) $item, 0, $limitFieldValue) . '...' + : (string) $item, + $row, + ); + } + + if ($sortField === '' && $this->sortOrder === 'DESC') { + $tbody = array_reverse($tbody); + } + + CLI::table($tbody, $thead); } /** - * Make body for table - * * @param list $tables - * - * @return list> */ - private function makeTbodyForShowAllTables(array $tables): array + private function showAllTables(array $tables): void { - $this->tbody = []; + CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow'); + CLI::newLine(); - $this->removeDBPrefix(); + $this->removeDbPrefix(); - foreach ($tables as $id => $tableName) { - $table = $this->db->protectIdentifiers($tableName); - $db = $this->db->query("SELECT * FROM {$table}"); + $tbody = []; - $this->tbody[] = [ + foreach ($tables as $id => $tableName) { + $tbody[] = [ $id + 1, $tableName, - $db->getNumRows(), - $db->getFieldCount(), + $this->db->table($tableName)->countAllResults(), + count($this->db->getFieldData($tableName)), ]; } - $this->restoreDBPrefix(); + $this->restoreDbPrefix(); - if ($this->sortDesc) { - krsort($this->tbody); - } + $thead = ['Id', 'Table Name', 'Num of Rows', 'Num of Fields']; - return $this->tbody; - } - - /** - * Make table rows - * - * @return list> - */ - private function makeTableRows( - string $tableName, - int $limitRows, - int $limitFieldValue, - ?string $sortField = null, - ): array { - $this->tbody = []; - - $this->removeDBPrefix(); - $builder = $this->db->table(TableName::fromActualName($this->db->DBPrefix, $tableName)); - $builder->limit($limitRows); - if ($sortField !== null) { - $builder->orderBy($sortField, $this->sortDesc ? 'DESC' : 'ASC'); - } - $rows = $builder->get()->getResultArray(); - $this->restoreDBPrefix(); - - foreach ($rows as $row) { - $row = array_map( - static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue - ? mb_substr((string) $item, 0, $limitFieldValue) . '...' - : (string) $item, - $row, - ); - $this->tbody[] = $row; - } - - if ($sortField === null && $this->sortDesc) { - krsort($this->tbody); - } - - return $this->tbody; + CLI::table($this->sortOrder === 'DESC' ? array_reverse($tbody) : $tbody, $thead); } private function showFieldMetaData(string $tableName): void { - CLI::write("List of Metadata Information in Table \"{$tableName}\":", 'black', 'yellow'); + CLI::write(sprintf('List of metadata information in "%s" table:', $tableName), 'black', 'yellow'); CLI::newLine(); - $thead = ['Field Name', 'Type', 'Max Length', 'Nullable', 'Default', 'Primary Key']; + $thead = ['Field Name', 'Type', 'Max Length', 'Nullable?', 'Default', 'Primary Key?']; - $this->removeDBPrefix(); + $this->removeDbPrefix(); $fields = $this->db->getFieldData($tableName); - $this->restoreDBPrefix(); + $this->restoreDbPrefix(); + + $tbody = []; foreach ($fields as $row) { - $this->tbody[] = [ + $tbody[] = [ $row->name, $row->type, $row->max_length, @@ -327,22 +302,13 @@ private function showFieldMetaData(string $tableName): void ]; } - if ($this->sortDesc) { - krsort($this->tbody); - } - - CLI::table($this->tbody, $thead); + CLI::table($this->sortOrder === 'DESC' ? array_reverse($tbody) : $tbody, $thead); } - /** - * @param bool|int|string|null $fieldValue - */ - private function setYesOrNo($fieldValue): string + private function setYesOrNo(mixed $fieldValue): string { - if ((bool) $fieldValue) { - return CLI::color('Yes', 'green'); - } - - return CLI::color('No', 'red'); + return filter_var($fieldValue, FILTER_VALIDATE_BOOL) + ? CLI::color('Yes', 'green') + : CLI::color('No', 'red'); } } diff --git a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php index 8b4c433ee535..06bae7c59173 100644 --- a/tests/system/Commands/Database/ShowTableInfoMockIOTest.php +++ b/tests/system/Commands/Database/ShowTableInfoMockIOTest.php @@ -82,11 +82,11 @@ public function testDbTableWithInputs(): void $result, ); $this->assertMatchesRegularExpression( - '/Data of Table "db_migrations"\:/', + '/Data of "db_migrations" table:/', $result, ); $this->assertMatchesRegularExpression( - '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/', + '/\| Id[[:blank:]]+\| Version[[:blank:]]+\| Class[[:blank:]]+\| Group[[:blank:]]+\| Namespace[[:blank:]]+\| Time[[:blank:]]+\| Batch \|/', $result, ); } diff --git a/tests/system/Commands/Database/ShowTableInfoTest.php b/tests/system/Commands/Database/ShowTableInfoTest.php index d6f99bd4fc16..7ca76014c33b 100644 --- a/tests/system/Commands/Database/ShowTableInfoTest.php +++ b/tests/system/Commands/Database/ShowTableInfoTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use CodeIgniter\Test\StreamFilterTrait; +use Config\Database; use PHPUnit\Framework\Attributes\Group; use Tests\Support\Database\Seeds\CITestSeeder; @@ -60,10 +61,10 @@ public function testDbTable(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_migrations":'; + $expected = 'Data of "db_migrations" table:'; $this->assertStringContainsString($expected, $result); - $expectedPattern = '/\| id[[:blank:]]+\| version[[:blank:]]+\| class[[:blank:]]+\| group[[:blank:]]+\| namespace[[:blank:]]+\| time[[:blank:]]+\| batch \|/'; + $expectedPattern = '/\| Id[[:blank:]]+\| Version[[:blank:]]+\| Class[[:blank:]]+\| Group[[:blank:]]+\| Namespace[[:blank:]]+\| Time[[:blank:]]+\| Batch \|/'; $this->assertMatchesRegularExpression($expectedPattern, $result); } @@ -83,7 +84,7 @@ public function testDbTableShowsDBConfig(): void $result = $this->getNormalizedResult(); - $expectedPattern = '/\| hostname[[:blank:]]+\| database[[:blank:]]+\| username[[:blank:]]+\| DBDriver[[:blank:]]+\| DBPrefix[[:blank:]]+\| port[[:blank:]]+\|/'; + $expectedPattern = '/\| Hostname[[:blank:]]+\| Database[[:blank:]]+\| Username[[:blank:]]+\| DB Driver[[:blank:]]+\| DB Prefix[[:blank:]]+\| Port[[:blank:]]+\|/'; $this->assertMatchesRegularExpression($expectedPattern, $result); } @@ -98,10 +99,13 @@ public function testDbTableShow(): void $expected = <<<'EOL' +----+---------------------------+-------------+---------------+ - | ID | Table Name | Num of Rows | Num of Fields | + | Id | Table Name | Num of Rows | Num of Fields | +----+---------------------------+-------------+---------------+ EOL; $this->assertStringContainsString($expected, $result); + + // The seeded `db_user` table has 4 rows and 7 fields. + $this->assertMatchesRegularExpression('/\|\s+db_user\s+\|\s+4\s+\|\s+7\s+\|/', $result); } public function testDbTableMetadata(): void @@ -110,12 +114,12 @@ public function testDbTableMetadata(): void $result = $this->getNormalizedResult(); - $expected = 'List of Metadata Information in Table "db_migrations":'; + $expected = 'List of metadata information in "db_migrations" table:'; $this->assertStringContainsString($expected, $result); $result = preg_replace('/\s+/', ' ', $result); $expected = <<<'EOL' - | Field Name | Type | Max Length | Nullable | Default | Primary Key | + | Field Name | Type | Max Length | Nullable? | Default | Primary Key? | EOL; $this->assertStringContainsString($expected, (string) $result); } @@ -126,12 +130,12 @@ public function testDbTableDesc(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+--------------------+--------------------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+--------------------+--------------------+---------+------------+------------+------------+ | 4 | Chris Martin | chris@world.com | UK | | | | | 3 | Richard A Cause... | richard@world.c... | US | | | | @@ -148,12 +152,12 @@ public function testDbTableLimitFieldValueLength(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+----------+----------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+----------+----------+---------+------------+------------+------------+ | 1 | Derek... | derek... | US | | | | | 2 | Ahmad... | ahmad... | Iran | | | | @@ -170,12 +174,12 @@ public function testDbTableLimitRows(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+-------------+--------------------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+-------------+--------------------+---------+------------+------------+------------+ | 1 | Derek Jones | derek@world.com | US | | | | | 2 | Ahmadinejad | ahmadinejad@wor... | Iran | | | | @@ -190,12 +194,12 @@ public function testDbTableAllOptions(): void $result = $this->getNormalizedResult(); - $expected = 'Data of Table "db_user":'; + $expected = 'Data of "db_user" table:'; $this->assertStringContainsString($expected, $result); $expected = <<<'EOL' +----+----------+----------+---------+------------+------------+------------+ - | id | name | email | country | created_at | updated_at | deleted_at | + | Id | Name | Email | Country | Created_at | Updated_at | Deleted_at | +----+----------+----------+---------+------------+------------+------------+ | 4 | Chris... | chris... | UK | | | | | 3 | Richa... | richa... | US | | | | @@ -203,4 +207,71 @@ public function testDbTableAllOptions(): void EOL; $this->assertStringContainsString($expected, $result); } + + public function testDbTableWithInvalidDBGroupSkipsThePrompt(): void + { + command('db:table --dbgroup invalid'); + + $this->assertStringContainsString( + '"invalid" is not a valid database connection group.', + $this->getNormalizedResult(), + ); + } + + public function testDbTableErrorsWhenNoTableSpecifiedAndNonInteractive(): void + { + $exitCode = service('commands')->runCommand('db:table', [], ['no-interaction' => null]); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('No table name was specified.', $this->getNormalizedResult()); + } + + public function testDbTableErrorsWhenTableNotFoundAndNonInteractive(): void + { + $exitCode = service('commands')->runCommand('db:table', ['missing_table'], ['no-interaction' => null]); + + $this->assertSame(EXIT_ERROR, $exitCode); + $this->assertStringContainsString('Table "missing_table" was not found in the database.', $this->getNormalizedResult()); + } + + public function testDbTableReportsNoTablesWhenDatabaseIsEmpty(): void + { + // A fresh in-memory SQLite database has no tables, regardless of the + // driver the suite runs against. Route the `default` group to it. + $original = $this->getPrivateProperty(Database::class, 'instances'); + $empty = Database::connect(['DBDriver' => 'SQLite3', 'database' => ':memory:', 'DBPrefix' => '']); + $this->setPrivateProperty(Database::class, 'instances', ['default' => $empty] + $original); + + try { + command('db:table --dbgroup default'); + + $this->assertStringContainsString('Database has no tables!', $this->getNormalizedResult()); + } finally { + $this->setPrivateProperty(Database::class, 'instances', $original); + $empty->close(); + } + } + + public function testDbTableSortsDescWhenTableHasNoIdColumn(): void + { + // `db_team_members` has a composite key and no `id` column, so --desc + // reverses the seeded rows (person_id 33 before 22) instead of adding an + // ORDER BY clause. + command('db:table db_team_members --desc'); + + $result = $this->getNormalizedResult(); + + $expected = 'Data of "db_team_members" table:'; + $this->assertStringContainsString($expected, $result); + + $expected = <<<'EOL' + +---------+-----------+--------+--------+------------+ + | Team_id | Person_id | Role | Status | Created_at | + +---------+-----------+--------+--------+------------+ + | 1 | 33 | mentor | active | | + | 1 | 22 | member | active | | + +---------+-----------+--------+--------+------------+ + EOL; + $this->assertStringContainsString($expected, $result); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index f1c369184f3b..ede7fd99fb46 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -34,6 +34,8 @@ Behavior Changes - **Commands:** Declining the ``key:generate`` overwrite prompt interactively now returns ``EXIT_SUCCESS`` instead of ``EXIT_ERROR``. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating. - **Commands:** The ``migrate:rollback`` command no longer accepts the undocumented ``-g`` (database group) option. It never had any effect, since ``MigrationRunner::regress()`` ignores the group, and the modern command pipeline now rejects unknown options. Remove ``-g`` from any ``migrate:rollback`` invocation. - **Commands:** The ``logs:clear`` command now returns ``EXIT_SUCCESS`` (previously ``EXIT_ERROR``) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. Output messages have also been reworded to distinguish cancellation (interactive ``n``) from abort (non-interactive without ``--force``), and the resolved log directory path is now included in the prompt, success, and failure messages. +- **Commands:** The ``db:table`` command now returns ``EXIT_SUCCESS`` from ``db:table --show`` (previously ``EXIT_ERROR``), and reports an error instead of prompting when run non-interactively (``--no-interaction`` or piped input) without a valid table. CI/automation that branches on the ``--show`` exit code will need updating. +- **Commands:** The ``db:table`` output was reworded: the data and metadata column headers are now capitalized (e.g. ``Id``, ``Created_at``), the connection summary headers read ``Hostname``, ``Database``, ``Username``, ``DB Driver``, ``DB Prefix``, and ``Port``, and the section titles changed (e.g. ``Data of "users" table:``). Scripts that grep the previous output will need updating. - **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating. - **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method (e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match.