diff --git a/src/Cli.php b/src/Cli.php index 8a299c3b..e8c4bd3d 100644 --- a/src/Cli.php +++ b/src/Cli.php @@ -34,11 +34,19 @@ class Cli extends Application } $definition->addOption( - new InputOption('with-context', null, InputOption::VALUE_NEGATABLE, 'Add context to output messages.') + new InputOption('context', null, InputOption::VALUE_NEGATABLE, 'Add context to output messages.') ); $definition->addOption(new InputOption('trace', null, InputOption::VALUE_NONE, 'Enable tracing mode.')); + $definition->addOption( + new InputOption( + 'output', 'o', InputOption::VALUE_REQUIRED, + sprintf('Output mode. Can be [%s].', implode(', ', Command::DISPLAY_OUTPUT)), + Command::DISPLAY_OUTPUT[0] + ) + ); + return $definition; } } diff --git a/src/Command.php b/src/Command.php index 420c063f..fb21a203 100644 --- a/src/Command.php +++ b/src/Command.php @@ -23,7 +23,7 @@ class Command extends BaseCommand { use LockableTrait; - protected array $outputs = [ + public const DISPLAY_OUTPUT = [ 'table', 'json', 'yaml', @@ -31,11 +31,11 @@ class Command extends BaseCommand protected function execute(InputInterface $input, OutputInterface $output): int { - if ($input->hasOption('with-context') && true === $input->getOption('with-context')) { + if ($input->hasOption('context') && true === $input->getOption('context')) { Config::save('logs.context', true); } - if ($input->hasOption('no-with-context') && true === $input->getOption('no-with-context')) { + if ($input->hasOption('no-context') && true === $input->getOption('no-context')) { Config::save('logs.context', false); } @@ -249,5 +249,19 @@ class Command extends BaseCommand $suggestions->suggestValues($suggest); } + + if ($input->mustSuggestOptionValuesFor('output')) { + $currentValue = $input->getCompletionValue(); + + $suggest = []; + + foreach (self::DISPLAY_OUTPUT as $name) { + if (empty($currentValue) || str_starts_with($name, $currentValue)) { + $suggest[] = $name; + } + } + + $suggestions->suggestValues($suggest); + } } } diff --git a/src/Commands/Backend/Library/ListCommand.php b/src/Commands/Backend/Library/ListCommand.php index 8ce0043d..304e7fa9 100644 --- a/src/Commands/Backend/Library/ListCommand.php +++ b/src/Commands/Backend/Library/ListCommand.php @@ -8,8 +8,6 @@ use App\Command; use App\Libs\Config; use App\Libs\Options; use RuntimeException; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -22,13 +20,6 @@ final class ListCommand extends Command { $this->setName('backend:library:list') ->setDescription('Get Backend libraries list.') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)), - $this->outputs[0], - ) ->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.') ->addArgument('backend', InputArgument::REQUIRED, 'Backend name.'); @@ -102,29 +93,4 @@ final class ListCommand extends Command return self::FAILURE; } } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } } diff --git a/src/Commands/Backend/Library/MismatchCommand.php b/src/Commands/Backend/Library/MismatchCommand.php index 110bcb69..744c0e76 100644 --- a/src/Commands/Backend/Library/MismatchCommand.php +++ b/src/Commands/Backend/Library/MismatchCommand.php @@ -30,16 +30,6 @@ final class MismatchCommand extends Command ->setDescription( 'Find possible mis-identified item in a library. This only works for Media that follow Plex naming format.' ) - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf( - 'Output mode. Can be [%s]. Modes other than table mode gives more info.', - implode(', ', $this->outputs) - ), - $this->outputs[0], - ) ->addOption('percentage', 'p', InputOption::VALUE_OPTIONAL, 'Acceptable percentage.', 50.0) ->addOption( 'method', @@ -269,7 +259,6 @@ final class MismatchCommand extends Command parent::complete($input, $suggestions); $methods = [ - 'output' => 'outputs', 'method' => 'methods', ]; diff --git a/src/Commands/Backend/Library/UnmatchedCommand.php b/src/Commands/Backend/Library/UnmatchedCommand.php index ba2f24c9..d69d6804 100644 --- a/src/Commands/Backend/Library/UnmatchedCommand.php +++ b/src/Commands/Backend/Library/UnmatchedCommand.php @@ -8,8 +8,6 @@ use App\Command; use App\Libs\Config; use App\Libs\Options; use RuntimeException; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -24,16 +22,6 @@ final class UnmatchedCommand extends Command $this->setName('backend:library:unmatched') ->setDescription('Find top level Items in library that has no external ids.') ->addOption('show-all', null, InputOption::VALUE_NONE, 'Show all items regardless of the match status.') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf( - 'Output mode. Can be [%s]. Modes other than table mode gives more info.', - implode(', ', $this->outputs) - ), - $this->outputs[0], - ) ->addOption( 'timeout', null, @@ -151,29 +139,4 @@ final class UnmatchedCommand extends Command return self::SUCCESS; } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } } diff --git a/src/Commands/Backend/Search/IdCommand.php b/src/Commands/Backend/Search/IdCommand.php index 977c5827..b9d5aab6 100644 --- a/src/Commands/Backend/Search/IdCommand.php +++ b/src/Commands/Backend/Search/IdCommand.php @@ -8,8 +8,6 @@ use App\Command; use App\Libs\Config; use App\Libs\Options; use RuntimeException; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -22,13 +20,6 @@ final class IdCommand extends Command { $this->setName('backend:search:id') ->setDescription('Get backend metadata related to specific id.') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)), - $this->outputs[0], - ) ->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.') ->addArgument('backend', InputArgument::REQUIRED, 'Backend name.') @@ -89,29 +80,4 @@ final class IdCommand extends Command return self::FAILURE; } } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } } diff --git a/src/Commands/Backend/Search/QueryCommand.php b/src/Commands/Backend/Search/QueryCommand.php index e0e173a3..e161f7cb 100644 --- a/src/Commands/Backend/Search/QueryCommand.php +++ b/src/Commands/Backend/Search/QueryCommand.php @@ -8,8 +8,6 @@ use App\Command; use App\Libs\Config; use App\Libs\Options; use RuntimeException; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -22,13 +20,6 @@ final class QueryCommand extends Command { $this->setName('backend:search:query') ->setDescription('Search backend libraries for specific title keyword.') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)), - $this->outputs[0], - ) ->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.') ->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit returned results.', 25) ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.') @@ -94,29 +85,4 @@ final class QueryCommand extends Command return self::FAILURE; } } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } } diff --git a/src/Commands/Backend/Users/ListCommand.php b/src/Commands/Backend/Users/ListCommand.php index 4db7eba8..fd34d058 100644 --- a/src/Commands/Backend/Users/ListCommand.php +++ b/src/Commands/Backend/Users/ListCommand.php @@ -8,8 +8,6 @@ use App\Command; use App\Libs\Config; use App\Libs\Options; use RuntimeException; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -23,13 +21,6 @@ final class ListCommand extends Command { $this->setName('backend:users:list') ->setDescription('Get backend users list.') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)), - $this->outputs[0], - ) ->addOption('with-tokens', 't', InputOption::VALUE_NONE, 'Include access tokens in response.') ->addOption('include-raw-response', null, InputOption::VALUE_NONE, 'Include unfiltered raw response.') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.') @@ -108,29 +99,4 @@ final class ListCommand extends Command return self::FAILURE; } } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } } diff --git a/src/Commands/Database/ListCommand.php b/src/Commands/Database/ListCommand.php index 1dc134e4..ecbb36ca 100644 --- a/src/Commands/Database/ListCommand.php +++ b/src/Commands/Database/ListCommand.php @@ -24,7 +24,7 @@ use Symfony\Component\Yaml\Yaml; final class ListCommand extends Command { - public const CHANGEABLE_COLUMNS = [ + private const COLUMNS_CHANGEABLE = [ iFace::COLUMN_WATCHED, iFace::COLUMN_VIA, iFace::COLUMN_TITLE, @@ -34,6 +34,18 @@ final class ListCommand extends Command iFace::COLUMN_UPDATED, ]; + private const COLUMNS_SORTABLE = [ + iFace::COLUMN_ID, + iFace::COLUMN_TYPE, + iFace::COLUMN_UPDATED, + iFace::COLUMN_WATCHED, + iFace::COLUMN_VIA, + iFace::COLUMN_TITLE, + iFace::COLUMN_YEAR, + iFace::COLUMN_SEASON, + iFace::COLUMN_EPISODE, + ]; + private PDO $pdo; public function __construct(private StorageInterface $storage) @@ -51,9 +63,8 @@ final class ListCommand extends Command 'via', null, InputOption::VALUE_REQUIRED, - 'Limit results to this specified server. This filter is not reliable. and changes based on last server query.' + 'Limit results to this specified backend. This filter is not reliable. and changes based on last backend query.' ) - ->addOption('output', null, InputOption::VALUE_REQUIRED, 'Display output as [json, yaml, table]', 'table') ->addOption( 'type', null, @@ -65,14 +76,17 @@ final class ListCommand extends Command ->addOption('episode', null, InputOption::VALUE_REQUIRED, 'Select episode number') ->addOption('year', null, InputOption::VALUE_REQUIRED, 'Select year.') ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Select db record number') - ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'sort order by [id, updated]', 'updated') - ->addOption('asc', null, InputOption::VALUE_NONE, 'Sort records in ascending order.') - ->addOption('desc', null, InputOption::VALUE_NONE, 'Sort records in descending order. (Default)') + ->addOption( + 'sort', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Set sort by columns. for example, --sort season:asc --sort episode:desc', + ) ->addOption( 'metadata-as', null, InputOption::VALUE_REQUIRED, - 'Display metadata from this server instead of latest.' + 'Display metadata from this backend instead of latest.' ) ->setDescription('List Database entries.'); @@ -93,13 +107,19 @@ final class ListCommand extends Command 'metadata', null, InputOption::VALUE_NONE, - 'Search in (metadata) provided by servers JSON Field using (--key, --value) options.' + 'Search in (metadata) provided by backends JSON Field using (--key, --value) options.' ) ->addOption( 'extra', null, InputOption::VALUE_NONE, 'Search in (extra information) JSON Field using (--key, --value) options.' + ) + ->addOption( + 'dump-query', + null, + InputOption::VALUE_NONE, + 'Dump the generated query and exit.' ); } @@ -108,35 +128,35 @@ final class ListCommand extends Command */ protected function runCommand(InputInterface $input, OutputInterface $output): int { - $list = []; - $limit = (int)$input->getOption('limit'); + $es = fn(string $val) => $this->storage->identifier($val); + $params = [ 'limit' => $limit <= 0 ? 20 : $limit, ]; - $where = []; + $sql = $where = []; - $sql = "SELECT * FROM state "; + $sql[] = sprintf('SELECT * FROM %s', $es('state')); if ($input->getOption('id')) { - $where[] = iFace::COLUMN_ID . ' = :id'; + $where[] = $es(iFace::COLUMN_ID) . ' = :id'; $params['id'] = $input->getOption('id'); } if ($input->getOption('via')) { - $where[] = iFace::COLUMN_VIA . ' = :via'; + $where[] = $es(iFace::COLUMN_VIA) . ' = :via'; $params['via'] = $input->getOption('via'); } if ($input->getOption('year')) { - $where[] = iFace::COLUMN_YEAR . ' = :year'; + $where[] = $es(iFace::COLUMN_YEAR) . ' = :year'; $params['year'] = $input->getOption('year'); } if ($input->getOption('type')) { - $where[] = iFace::COLUMN_TYPE . ' = :type'; + $where[] = $es(iFace::COLUMN_TYPE) . ' = :type'; $params['type'] = match ($input->getOption('type')) { iFace::TYPE_MOVIE => iFace::TYPE_MOVIE, default => iFace::TYPE_EPISODE, @@ -144,17 +164,17 @@ final class ListCommand extends Command } if ($input->getOption('title')) { - $where[] = iFace::COLUMN_TITLE . " LIKE '%' || :title || '%'"; + $where[] = $es(iFace::COLUMN_TITLE) . ' LIKE "%" || :title || "%"'; $params['title'] = $input->getOption('title'); } if (null !== $input->getOption('season')) { - $where[] = iFace::COLUMN_SEASON . ' = :season'; + $where[] = $es(iFace::COLUMN_SEASON) . ' = :season'; $params['season'] = $input->getOption('season'); } if (null !== $input->getOption('episode')) { - $where[] = iFace::COLUMN_EPISODE . ' = :episode'; + $where[] = $es(iFace::COLUMN_EPISODE) . ' = :episode'; $params['episode'] = $input->getOption('episode'); } @@ -203,20 +223,53 @@ final class ListCommand extends Command } if (count($where) >= 1) { - $sql .= 'WHERE ' . implode(' AND ', $where); + $sql[] = 'WHERE ' . implode(' AND ', $where); } - $sort = match ($input->getOption('sort')) { - 'id' => iFace::COLUMN_ID, - 'season' => iFace::COLUMN_SEASON, - 'episode' => iFace::COLUMN_EPISODE, - 'type' => iFace::COLUMN_TYPE, - default => iFace::COLUMN_UPDATED, - }; + $sorts = []; - $sortOrder = ($input->getOption('asc')) ? 'ASC' : 'DESC'; + foreach ($input->getOption('sort') as $sort) { + if (1 !== preg_match('/(?P\w+)(:(?P\w+))?/', $sort, $matches)) { + continue; + } - $sql .= " ORDER BY {$sort} {$sortOrder} LIMIT :limit"; + if (null === ($matches['field'] ?? null) || false === in_array($matches['field'], self::COLUMNS_SORTABLE)) { + continue; + } + + $sorts[] = sprintf( + '%s %s', + $es($matches['field']), + match (strtolower($matches['dir'] ?? 'desc')) { + default => 'DESC', + 'asc' => 'ASC', + } + ); + } + + if (count($sorts) < 1) { + $sorts[] = sprintf('%s DESC', $es('updated')); + } + + $sql[] = 'ORDER BY ' . implode(', ', $sorts) . ' LIMIT :limit'; + $sql = implode(' ', $sql); + + if ($input->getOption('dump-query')) { + $arr = [ + 'query' => $sql, + 'parameters' => $params, + 'raw' => $this->storage->getRawSQLString($sql, $params), + ]; + + if ('table' === $input->getOption('output')) { + $arr['parameters'] = arrayToString($params); + unset($arr['raw']); + $arr = [$arr]; + } + + $this->displayContent($arr, $output, $input->getOption('output')); + return self::SUCCESS; + } $stmt = $this->pdo->prepare($sql); $stmt->execute($params); @@ -262,15 +315,16 @@ final class ListCommand extends Command if (null !== ($via = $input->getOption('metadata-as'))) { $path = $row[iFace::COLUMN_META_DATA][$via] ?? []; - foreach (self::CHANGEABLE_COLUMNS as $column) { + foreach (self::COLUMNS_CHANGEABLE as $column) { if (null === ($path[$column] ?? null)) { continue; } $row[$column] = 'int' === get_debug_type($row[$column]) ? (int)$path[$column] : $path[$column]; } - if (null !== ($row[iFace::COLUMN_EXTRA][$via][iFace::COLUMN_EXTRA_DATE] ?? null)) { - $row[iFace::COLUMN_UPDATED] = $row[iFace::COLUMN_EXTRA][$via][iFace::COLUMN_EXTRA_DATE]; + + if (null !== ($dateFromBackend = $path[iFace::COLUMN_META_DATA_PLAYED_AT] ?? $path[iFace::COLUMN_META_DATA_ADDED_AT] ?? null)) { + $row[iFace::COLUMN_UPDATED] = $dateFromBackend; } } @@ -280,44 +334,37 @@ final class ListCommand extends Command unset($row); - if ('json' === $input->getOption('output')) { - $output->writeln( - json_encode( - 1 === count($rows) ? $rows[0] : $rows, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE - ) - ); - } elseif ('yaml' === $input->getOption('output')) { - $output->writeln(Yaml::dump(1 === count($rows) ? $rows[0] : $rows, 8, 2)); - } else { - $x = 0; + if ('table' === $input->getOption('output')) { + $list = []; foreach ($rows as $row) { $row[iFace::COLUMN_UPDATED] = $row[iFace::COLUMN_UPDATED]->getTimestamp(); $row[iFace::COLUMN_WATCHED] = (int)$row[iFace::COLUMN_WATCHED]; $entity = Container::get(iFace::class)->fromArray($row); - $x++; - - $list[] = [ - $entity->id, - ucfirst($entity->type), - $entity->getName(), - $entity->via ?? '??', - makeDate($entity->updated)->format('Y-m-d H:i:s T'), - $entity->isWatched() ? 'Yes' : 'No', - ag($entity->extra[$entity->via] ?? [], iFace::COLUMN_EXTRA_EVENT, '-'), + $item = [ + 'id' => $entity->id, + 'Type' => ucfirst($entity->type), + 'Title' => $entity->getName(), + 'Via (Last)' => $entity->via ?? '??', + 'Date' => makeDate($entity->updated)->format('Y-m-d H:i:s T'), + 'Played' => $entity->isWatched() ? 'Yes' : 'No', + 'Via (Event)' => ag($entity->extra[$entity->via] ?? [], iFace::COLUMN_EXTRA_EVENT, '-'), ]; - if ($x < $rowCount) { - $list[] = new TableSeparator(); - } + $list[] = $item; + $list[] = new TableSeparator(); } $rows = null; - (new Table($output))->setHeaders(['Id', 'Type', 'Title', 'Via (Last)', 'Date', 'Played', 'Via Event']) - ->setStyle('box')->setRows($list)->render(); + if (count($list) >= 2) { + array_pop($list); + } + + (new Table($output))->setHeaders(array_keys($list[0] ?? []))->setStyle('box')->setRows($list)->render(); + } else { + $this->displayContent($rows, $output, $input->getOption('output')); } return self::SUCCESS; @@ -355,14 +402,16 @@ final class ListCommand extends Command $suggestions->suggestValues($suggest); } - if ($input->mustSuggestOptionValuesFor('output')) { + if ($input->mustSuggestOptionValuesFor('sort')) { $currentValue = $input->getCompletionValue(); $suggest = []; - foreach (['json', 'yaml', 'table'] as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; + foreach (self::COLUMNS_SORTABLE as $name) { + foreach ([$name . ':desc', $name . ':asc'] as $subName) { + if (empty($currentValue) || true === str_starts_with($subName, $currentValue)) { + $suggest[] = $subName; + } } } diff --git a/src/Commands/System/EnvCommand.php b/src/Commands/System/EnvCommand.php index 45ea6122..9088ab35 100644 --- a/src/Commands/System/EnvCommand.php +++ b/src/Commands/System/EnvCommand.php @@ -5,10 +5,7 @@ declare(strict_types=1); namespace App\Commands\System; use App\Command; -use Symfony\Component\Console\Completion\CompletionInput; -use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; final class EnvCommand extends Command @@ -16,13 +13,6 @@ final class EnvCommand extends Command protected function configure(): void { $this->setName('system:env') - ->addOption( - 'output', - 'o', - InputOption::VALUE_OPTIONAL, - sprintf('Output mode. Can be [%s].', implode(', ', $this->outputs)), - $this->outputs[0], - ) ->setDescription('Dump loaded environment variables.'); } @@ -53,30 +43,4 @@ final class EnvCommand extends Command return self::SUCCESS; } - - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - parent::complete($input, $suggestions); - - $methods = [ - 'output' => 'outputs', - ]; - - foreach ($methods as $key => $of) { - if ($input->mustSuggestOptionValuesFor($key)) { - $currentValue = $input->getCompletionValue(); - - $suggest = []; - - foreach ($this->{$of} as $name) { - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; - } - } - - $suggestions->suggestValues($suggest); - } - } - } - } diff --git a/src/Libs/Storage/PDO/PDOAdapter.php b/src/Libs/Storage/PDO/PDOAdapter.php index e9d39b7d..93387372 100644 --- a/src/Libs/Storage/PDO/PDOAdapter.php +++ b/src/Libs/Storage/PDO/PDOAdapter.php @@ -33,8 +33,15 @@ final class PDOAdapter implements StorageInterface 'update' => null, ]; + private string $driver = 'sqlite'; + public function __construct(private LoggerInterface $logger, private PDO $pdo) { + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if (is_string($driver)) { + $this->driver = $driver; + } } public function setOptions(array $options): self @@ -515,13 +522,45 @@ final class PDOAdapter implements StorageInterface */ public function getRawSQLString(string $sql, array $parameters): string { - $keys = $replace = []; + $replacer = []; foreach ($parameters as $key => $val) { - $keys[] = '/(\:' . preg_quote($key, '/') . ')(?:\b|\,)/'; - $replace[] = ctype_digit((string)$val) ? $val : "'{$val}'"; + $replacer['/(\:' . preg_quote($key, '/') . ')(?:\b|\,)/'] = ctype_digit( + (string)$val + ) ? (int)$val : '"' . $val . '"'; } - return preg_replace($keys, $replace, $sql); + return preg_replace(array_keys($replacer), array_values($replacer), $sql); } + + public function identifier(string $text, bool $quote = true): string + { + // table or column has to be valid ASCII name. + // this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name. + if (!\preg_match('#\w#', $text)) { + throw new \RuntimeException( + sprintf( + 'Invalid identifier "%s": Column/table must be valid ASCII code.', + $text + ) + ); + } + + // The first character cannot be [0-9]: + if (\preg_match('/^\d/', $text)) { + throw new \RuntimeException( + sprintf( + 'Invalid identifier "%s": Must begin with a letter or underscore.', + $text + ) + ); + } + + return !$quote ? $text : match ($this->driver) { + 'mssql' => '[' . $text . ']', + 'mysql' => '`' . $text . '`', + default => '"' . $text . '"', + }; + } + }