From d12f9baaecd12666fd7417d36410d2cbb957f203 Mon Sep 17 00:00:00 2001 From: Damien Debin Date: Wed, 15 Mar 2023 18:13:35 +0100 Subject: [PATCH 1/3] Adds/test compatibility with PHP 8.2. --- .github/workflows/main.yml | 4 ++-- .php-cs-fixer.php | 4 ++++ composer.json | 9 +++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 589085b..8228838 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] + php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] name: PHP ${{ matrix.php }} steps: - uses: actions/checkout@v3 @@ -32,7 +32,7 @@ jobs: # see https://github.com/shivammathur/setup-php - uses: shivammathur/setup-php@v2 with: - php-version: 7.1 + php-version: '7.1' coverage: none - run: composer update --no-progress --prefer-lowest - run: composer phpstan diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index ffd0d20..e33341e 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -10,8 +10,12 @@ $config = new Config(); return $config->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, '@PhpCsFixer' => true, '@PhpCsFixer:risky' => true, + '@PHP71Migration' => true, + '@PHP71Migration:risky' => true, 'array_syntax' => ['syntax' => 'short'], 'php_unit_test_class_requires_covers' => false, 'backtick_to_shell_exec' => true, diff --git a/composer.json b/composer.json index 53bf31e..f2cef42 100644 --- a/composer.json +++ b/composer.json @@ -47,9 +47,14 @@ } }, "scripts": { - "phpstan": "@php phpstan analyse -l max lib tests examples .php-cs-fixer.php", + "phpstan": "@php phpstan analyse -l max -c phpstan.neon lib tests examples .php-cs-fixer.php", "php-cs-fixer": "@php php-cs-fixer fix --allow-risky=yes", "php-cs-fixer-dry-run": "@php php-cs-fixer fix --dry-run --allow-risky=yes", - "phpunit": "@php phpunit" + "phpunit": "@php phpunit", + "test": [ + "@php-cs-fixer-dry-run", + "@phpstan", + "@phpunit" + ] } } From 77769a1eeab85278a495b33c23722646bf5aee9c Mon Sep 17 00:00:00 2001 From: Damien Debin Date: Tue, 13 Jun 2023 14:12:32 +0200 Subject: [PATCH 2/3] Typos --- .gitignore | 1 + composer.json | 4 ++-- examples/callback_fields.php | 4 ++-- examples/simple.php | 2 +- lib/MC/Parser/Def/Regex.php | 2 +- lib/MC/Parser/Def/Word.php | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 26fe71e..d0a4c20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/.idea/ /.*.cache /tests-report-html/ /composer.lock diff --git a/composer.json b/composer.json index f2cef42..49d9d08 100644 --- a/composer.json +++ b/composer.json @@ -38,12 +38,12 @@ }, "autoload": { "psr-4": { - "MC\\": "lib/MC" + "MC\\": "lib/MC/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests" + "Tests\\": "tests/" } }, "scripts": { diff --git a/examples/callback_fields.php b/examples/callback_fields.php index 05aea08..4d89894 100644 --- a/examples/callback_fields.php +++ b/examples/callback_fields.php @@ -35,7 +35,7 @@ function most_common(array $row): ?string 'pill' => ['field' => 'pill', 'type' => 'number'], 'iud' => ['field' => 'iud', 'type' => 'number'], 'condom' => ['field' => 'condom', 'type' => 'number'], - 'sterile_total' => ['field' => 'steril_total', 'type' => 'number'], + 'sterile_total' => ['field' => 'sterile_total', 'type' => 'number'], 'other_modern' => ['field' => 'other_modern', 'type' => 'number'], 'traditional' => ['field' => 'traditional', 'type' => 'number'], 'most_common' => [ @@ -54,7 +54,7 @@ function most_common(array $row): ?string Callback fields visualization example - + diff --git a/lib/MC/Parser/Def/Regex.php b/lib/MC/Parser/Def/Regex.php index 8d3ec34..8a66e4e 100644 --- a/lib/MC/Parser/Def/Regex.php +++ b/lib/MC/Parser/Def/Regex.php @@ -8,7 +8,7 @@ use MC\Parser\ParseError; /** - * Generic grammar rule for matching a regular expresion. + * Generic grammar rule for matching a regular expression. */ class Regex extends Def { diff --git a/lib/MC/Parser/Def/Word.php b/lib/MC/Parser/Def/Word.php index d8b0086..e8c04ac 100644 --- a/lib/MC/Parser/Def/Word.php +++ b/lib/MC/Parser/Def/Word.php @@ -11,7 +11,7 @@ class Word extends Regex { /** * @param string $firstChars the characters allowed as the first character in the word - * @param null|string $restChars the characters allwed as the rest of the word - defaults to same as $first_chars + * @param null|string $restChars the characters allowed as the rest of the word - defaults to same as $first_chars */ public function __construct(string $firstChars, string $restChars = null) { From 8852acc78200cb45d878dcb8fa4de1e64cc88bfc Mon Sep 17 00:00:00 2001 From: Damien Debin Date: Tue, 13 Jun 2023 16:11:37 +0200 Subject: [PATCH 3/3] More typing --- lib/MC/Google/Visualization.php | 93 +++++++++++++++++++++++---------- lib/MC/Parser.php | 8 +-- lib/MC/Parser/Def.php | 10 ++-- lib/MC/Parser/Def/OneOf.php | 3 ++ lib/MC/Parser/Def/Set.php | 6 +-- lib/MC/Parser/Token.php | 12 +++-- lib/MC/Parser/Token/Group.php | 11 ++-- tests/ExampleTest.php | 24 +++++++++ tests/VisualizationTest.php | 3 ++ 9 files changed, 122 insertions(+), 48 deletions(-) diff --git a/lib/MC/Google/Visualization.php b/lib/MC/Google/Visualization.php index 02a139e..fa269d7 100644 --- a/lib/MC/Google/Visualization.php +++ b/lib/MC/Google/Visualization.php @@ -22,7 +22,32 @@ * * @see \Tests\VisualizationTest * - * @phpstan-type FieldSpec array{callback?:callable,field?:string,extra?:array,fields?:string[],sort_field?:string,type?:string,join?:string} + * @phpstan-type FieldSpec array{ + * type: string, + * field?: string, + * fields?: string[], + * callback?: callable, + * extra?: mixed[], + * sort_field?: string, + * join?: string + * } + * @phpstan-type QueryParsed array{ + * select?: array, + * from?: string, + * where?: null|array, + * groupby?: string[], + * pivot?: string[], + * orderby?: string[], + * limit?: string, + * offset?: string, + * label?: string + * } + * @phpstan-type Entity array{ + * table: string, + * fields: array, + * joins: string[], + * where?: string + * } */ class Visualization { @@ -37,7 +62,7 @@ class Visualization /** * The entity schema that defines which tables are exposed to visualization clients, along with their fields, joins, and callbacks. * - * @var array + * @var array */ protected $entities = []; @@ -60,7 +85,7 @@ class Visualization /** * If a format string is not provided by the query, these will be used to format values by default. * - * @var array + * @var array */ protected $defaultFormat = [ 'date' => 'm/d/Y', @@ -156,8 +181,8 @@ public function setDefaultFormat(string $type, string $format): void * Handle the entire request, pulling the query from the $_GET variables and printing the results directly * if not specified otherwise. * - * @param bool $echo print response and set header - * @param null|array $queryParams query parameters + * @param bool $echo print response and set header + * @param null|array{tq:string, tqx:string, responseHandler?:string} $queryParams query parameters * * @return string the javascript response * @@ -199,14 +224,14 @@ public function handleRequest(bool $echo = true, ?array $queryParams = null): st /** * Handle a specific query. Use this if you want to gather the query parameters yourself instead of using handleRequest(). * - * @param string $query the visualization query to parse and execute - * @param array $params all extra params sent along with the query - must include at least "reqId" key + * @param string $query the visualization query to parse and execute + * @param array $params all extra params sent along with the query - must include at least "reqId" key * * @return string the javascript response */ public function handleQuery(string $query, array $params): string { - $reqId = null; + $reqId = -1; $response = ''; try { @@ -214,7 +239,7 @@ public function handleQuery(string $query, array $params): string throw new Visualization_Error('You must pass a PDO connection to the MC Google Visualization Server if you want to let the server handle the entire request'); } - $reqId = $params['reqId']; + $reqId = (int) $params['reqId']; $queryParsed = $this->parseQuery($query); $meta = $this->generateMetadata($queryParsed); $sql = $this->generateSQL($meta); @@ -236,13 +261,13 @@ public function handleQuery(string $query, array $params): string $response .= $this->getSuccessClose(); } catch (Visualization_Error $visualizationError) { - $response .= $this->handleError($reqId, $visualizationError->getMessage(), $params['responseHandler'], $visualizationError->type, $visualizationError->summary); + $response .= $this->handleError($reqId, $visualizationError->getMessage(), (string) $params['responseHandler'], $visualizationError->type, $visualizationError->summary); } catch (PDOException $pdoException) { - $response .= $this->handleError($reqId, $pdoException->getMessage(), $params['responseHandler'], 'invalid_query', 'Invalid Query - PDO exception'); + $response .= $this->handleError($reqId, $pdoException->getMessage(), (string) $params['responseHandler'], 'invalid_query', 'Invalid Query - PDO exception'); } catch (ParseError $parseError) { - $response .= $this->handleError($reqId, $parseError->getMessage(), $params['responseHandler'], 'invalid_query', 'Invalid Query - Parse Error'); + $response .= $this->handleError($reqId, $parseError->getMessage(), (string) $params['responseHandler'], 'invalid_query', 'Invalid Query - Parse Error'); } catch (Exception $exception) { - $response .= $this->handleError($reqId, $exception->getMessage(), $params['responseHandler']); + $response .= $this->handleError($reqId, $exception->getMessage(), (string) $params['responseHandler']); } return $response; @@ -251,9 +276,11 @@ public function handleQuery(string $query, array $params): string /** * Given the results of parseQuery(), introspect against the entity definitions provided and return the metadata array used to generate the SQL. * - * @param array $query the visualization query broken up into sections + * @param array $query the visualization query broken up into sections * - * @return array the metadata array from merging the query with the entity table definitions + * @phpstan-param QueryParsed $query + * + * @return array the metadata array from merging the query with the entity table definitions * * @throws Visualization_QueryError * @throws Visualization_Error @@ -704,7 +731,9 @@ public function getGrammar(): Def * * @param string $str the query string to parse * - * @return array the parsed query as an array, keyed by each part of the query (select, from, where, groupby, pivot, orderby, limit, offset, label, format, options + * @return array the parsed query as an array, keyed by each part of the query (select, from, where, groupby, pivot, orderby, limit, offset, label, format, options + * + * @phpstan-return QueryParsed * * @throws ParseError * @throws Visualization_QueryError @@ -727,8 +756,10 @@ public function parseQuery(string $str): array break; case 'from': - $vals = $token->getValues(); - $query['from'] = $vals[1]; + $from = $token->getValues(); + $from = $from[1]; + assert(null !== $from); + $query['from'] = $from; break; @@ -744,7 +775,7 @@ public function parseQuery(string $str): array $groupby = $token->getValues(); array_shift($groupby); array_shift($groupby); - $query['groupby'] = $groupby; + $query['groupby'] = array_filter($groupby); break; @@ -754,7 +785,7 @@ public function parseQuery(string $str): array } $pivot = $token->getValues(); array_shift($pivot); - $query['pivot'] = $pivot; + $query['pivot'] = array_filter($pivot); break; @@ -784,6 +815,7 @@ public function parseQuery(string $str): array case 'limit': $limit = $token->getValues(); $limit = $limit[1]; + assert(null !== $limit); $query['limit'] = $limit; break; @@ -791,6 +823,7 @@ public function parseQuery(string $str): array case 'offset': $offset = $token->getValues(); $offset = $offset[1]; + assert(null !== $offset); $query['offset'] = $offset; break; @@ -803,6 +836,7 @@ public function parseQuery(string $str): array $count = count($labels); for ($i = 0; $i < $count; $i += 2) { $field = $labels[$i]; + assert(null !== $labels[$i + 1]); $label = trim($labels[$i + 1], '\'"'); $queryLabels[$field] = $label; } @@ -818,6 +852,7 @@ public function parseQuery(string $str): array $count = count($formats); for ($i = 0; $i < $count; $i += 2) { $field = $formats[$i]; + assert(null !== $formats[$i + 1]); $queryFormats[$field] = trim($formats[$i + 1], '\'"'); } $query['formats'] = $queryFormats; @@ -1290,15 +1325,18 @@ protected function getFieldSQL(string $name, array $spec, bool $alias = false, s /** * Recursively process the dependant fields for callback entity fields. * - * @param array $field the spec array for the field to add (must have a "callback" key) - * @param array $entity the spec array for the entity to pull other fields from - * @param array $meta the query metadata array to append the results + * @param array $fieldSpec the spec array for the field to add (must have a "callback" key) + * @param array $entity the spec array for the entity to pull other fields from + * @param array $meta the query metadata array to append the results + * + * @phpstan-param FieldSpec $fieldSpec + * @phpstan-param Entity $entity * * @throws Visualization_Error */ - protected function addDependantCallbackFields(array $field, array $entity, array &$meta): void + protected function addDependantCallbackFields(array $fieldSpec, array $entity, array &$meta): void { - foreach ($field['fields'] as $dependant) { + foreach ($fieldSpec['fields'] ?? [] as $dependant) { if (!isset($entity['fields'][$dependant])) { throw new Visualization_Error('Unknown callback required field "'.$dependant.'"'); } @@ -1335,6 +1373,7 @@ protected function parseFieldTokens(Token $token, array &$fields = null): void if ($token->hasChildren()) { if ('function' === $token->name) { $field = $token->getValues(); + assert(null !== $field[0]); $field[0] = strtolower($field[0]); $fields[] = $field; } else { @@ -1350,8 +1389,8 @@ protected function parseFieldTokens(Token $token, array &$fields = null): void /** * Helper method for the query parser to recursively scan and flatten the where clause's conditions. * - * @param Token $token the token or token group to parse - * @param null|array $where the collector array of tokens that make up the where clause + * @param Token $token the token or token group to parse + * @param null|array $where the collector array of tokens that make up the where clause */ protected function parseWhereTokens(Token $token, array &$where = null): void { diff --git a/lib/MC/Parser.php b/lib/MC/Parser.php index 0282d93..291ad9b 100644 --- a/lib/MC/Parser.php +++ b/lib/MC/Parser.php @@ -32,17 +32,17 @@ class Parser /** * Return a Set with the function arguments as the subexpressions. */ - public function set(): Set + public function set(Def ...$args): Set { - return new Set(func_get_args()); + return new Set($args); } /** * Return a OneOf with the function arguments as the possible expressions. */ - public function oneOf(): OneOf + public function oneOf(Def ...$args): OneOf { - return new OneOf(func_get_args()); + return new OneOf($args); } /** diff --git a/lib/MC/Parser/Def.php b/lib/MC/Parser/Def.php index cd9f1b4..fe94492 100644 --- a/lib/MC/Parser/Def.php +++ b/lib/MC/Parser/Def.php @@ -28,7 +28,7 @@ public function parse(string $str): Token $str = ltrim($str); [$loc, $tok] = $this->parsePart($str, 0); - if ($loc !== strlen($str)) { + if ((null === $tok) || ($loc !== strlen($str))) { throw new ParseError('An error occurred: "'.substr($str, $loc).'"', $str, $loc); } @@ -38,7 +38,7 @@ public function parse(string $str): Token /** * Parse a string, cleaning up whitespace when we're done. * - * @return array A two-item array of the string location where parsing stopped, and the MC_Token instance that matches the grammar conditions + * @return array{int, null|Token} A two-item array of the string location where parsing stopped and the MC_Token instance that matches the grammar conditions * * @throws ParseError */ @@ -61,7 +61,7 @@ public function parsePart(string $str, int $loc): array * @param string $str the string to parse * @param int $loc the index to start parsing * - * @return array A two-item array of the string location where parsing stopped, and the MC_Token instance that matches the grammar conditions + * @return array{int, null|Token} A two-item array of the string location where parsing stopped, and the Token instance that matches the grammar conditions * * @throws ParseError */ @@ -103,11 +103,9 @@ public function suppress(): self /** * Return a token instance, copying over this Def's name and flagging suppression. * - * @param mixed $value - * * @return null|Token the new token, or null if the token should be suppressed */ - public function token($value): ?Token + public function token(?string $value): ?Token { if ($this->suppress) { return null; diff --git a/lib/MC/Parser/Def/OneOf.php b/lib/MC/Parser/Def/OneOf.php index 164bbc0..4e2c11b 100644 --- a/lib/MC/Parser/Def/OneOf.php +++ b/lib/MC/Parser/Def/OneOf.php @@ -15,6 +15,9 @@ class OneOf extends Def /** @var Def[] */ public $exprs = []; + /** + * @param Def[] $exprs + */ public function __construct(array $exprs = []) { $this->exprs = $exprs; diff --git a/lib/MC/Parser/Def/Set.php b/lib/MC/Parser/Def/Set.php index 9d8c840..6f1320f 100644 --- a/lib/MC/Parser/Def/Set.php +++ b/lib/MC/Parser/Def/Set.php @@ -11,11 +11,11 @@ */ class Set extends Def { - /** @var array */ + /** @var Def[] */ public $exprs = []; /** - * Set constructor. + * @param Def[] $exprs */ public function __construct(array $exprs = []) { @@ -31,7 +31,7 @@ public function _parse(string $str, int $loc): array $res = $this->tokenGroup(); foreach ($this->exprs as $expr) { [$loc, $toks] = $expr->parsePart($str, $loc); - if ((null !== $toks) && (!is_array($toks) || (count($toks) > 0))) { + if (null !== $toks) { $res->append($toks); } } diff --git a/lib/MC/Parser/Token.php b/lib/MC/Parser/Token.php index a199a96..d0ea72b 100644 --- a/lib/MC/Parser/Token.php +++ b/lib/MC/Parser/Token.php @@ -12,25 +12,29 @@ class Token /** @var null|string */ public $name; - /** @var mixed */ + /** @var null|string */ public $value; /** * Token constructor. - * - * @param mixed $value */ - public function __construct($value, ?string $name) + public function __construct(?string $value, ?string $name) { $this->value = $value; $this->name = $name; } + /** + * @return array + */ public function getValues(): array { return [$this->value]; } + /** + * @return array + */ public function getNameValues(): array { return [[$this->name, $this->value]]; diff --git a/lib/MC/Parser/Token/Group.php b/lib/MC/Parser/Token/Group.php index 2aa0fb3..341936d 100644 --- a/lib/MC/Parser/Token/Group.php +++ b/lib/MC/Parser/Token/Group.php @@ -14,10 +14,7 @@ class Group extends Token implements Countable /** @var Token[] */ public $subtoks = []; - /** - * @param null|string $name - */ - public function __construct($name) + public function __construct(?string $name) { parent::__construct(null, $name); } @@ -43,6 +40,9 @@ public function count(): int return count($this->subtoks); } + /** + * @return array + */ public function getValues(): array { $values = []; @@ -53,6 +53,9 @@ public function getValues(): array return $values; } + /** + * @return array + */ public function getNameValues(): array { $values = []; diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php index 1756644..d787d34 100644 --- a/tests/ExampleTest.php +++ b/tests/ExampleTest.php @@ -86,6 +86,30 @@ public function testQuerySimple(): void self::assertStringEqualsFile(__DIR__.'/result2.js', $output); } + /** + * @throws Visualization_Error + */ + public function testQueryError(): void + { + $db = new PDO('sqlite:'.__DIR__.'/../examples/example.db'); + $vis = new Visualization($db); + $vis->addEntity('countries', [ + 'fields' => [ + 'id' => ['field' => 'id', 'type' => 'number'], + 'name' => ['field' => 'name', 'type' => 'text'], + ], + ]); + + $parameters = [ + 'tq' => 'select id, name from', + 'tqx' => 'reqId:2', + ]; + + $output = $vis->handleRequest(false, $parameters); + + self::assertSame('google.visualization.Query.setResponse({version:"0.5",reqId:"2",status:"error",errors:[{reason:"invalid_query",message:"Invalid Query - Parse Error",detailed_message:"An error occurred: \"from\""}]});', $output); + } + /** * @throws Visualization_Error */ diff --git a/tests/VisualizationTest.php b/tests/VisualizationTest.php index a168214..493ba68 100644 --- a/tests/VisualizationTest.php +++ b/tests/VisualizationTest.php @@ -359,6 +359,9 @@ public function testIsNull(): void self::assertSame('SELECT id AS id FROM orders WHERE (product IS NOT NULL)', $sql); } + /** + * @param array $row + */ public static function callbackTest(array $row): string { return 'callback-'.$row['field'];