diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 7bc6baa7..8ff67292 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -10,6 +10,8 @@ jobs: build: runs-on: ubuntu-latest + env: + XDEBUG_MODE: off steps: - uses: actions/checkout@v2 @@ -17,14 +19,18 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate - - name: Cache Composer packages + - name: Get Composer Cache Directory id: composer-cache - uses: actions/cache@v2 + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache Composer packages + uses: actions/cache@v4 with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php- + ${{ runner.os }}-composer- - name: Install dependencies if: steps.composer-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 6ab938cb..87311635 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -1,29 +1,34 @@ -# This is a basic workflow to help you get started with Actions - name: Psalm -# Controls when the action will run. on: - # Triggers the workflow on push or pull request events but only for the master branch push: branches: [ master ] pull_request: branches: [ master ] - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" build: - # The type of runner that the job will run on runs-on: ubuntu-latest - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v2 + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install --prefer-dist --no-progress --no-suggest - - name: Psalm – Static Analysis for PHP - uses: psalm/psalm-github-actions@1.1.2 + - name: Run test suite + run: vendor/bin/psalm --shepherd diff --git a/README.md b/README.md index 8dbfd09f..d327a0cf 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ PHP MySQL Engine works by providing a subclass of [PDO](https://www.php.net/manu You can instantiate the subclass as you would `PDO`, and use dependency injection or similar to provide that instance to your application code. ```php -// use a class specific to your cuurrent PHP version (APIs changed in major versions) +// use a class specific to your current PHP version (APIs changed in major versions) $pdo = new \Vimeo\MysqlEngine\Php8\FakePdo($dsn, $user, $password); // currently supported attributes $pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_LOWER); diff --git a/composer.json b/composer.json index 4a19c9d7..41008e5a 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ } }, "require-dev": { - "vimeo/psalm": "^4.3", + "vimeo/psalm": "^4.6", "squizlabs/php_codesniffer": "^3.5", "phpunit/phpunit": "^9.5" } diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 31eda778..045646be 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + $value @@ -7,16 +7,8 @@ $column_default - - $existing_row[$field] - $existing_row[$key] - $new_row[$key] - $new_row[$key] - $new_row[$key] - - + $column_default - $key $row[$column_name] $value @@ -162,14 +154,6 @@ - - \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_OFFSET_CAPTURE - - - $tokens - $tokens - $tokens - $tokens @@ -178,24 +162,6 @@ $next === null - - $cache - - - self::$cache - - - self::$cache[$sql] - - - self::$cache[$sql] - - - SelectQuery|InsertQuery|UpdateQuery|TruncateQuery|DeleteQuery|DropTableQuery|ShowTablesQuery - - - self::$cache[$sql] - $out array<int, Token> @@ -206,11 +172,6 @@ $type - - - $token === null - - $token === null @@ -231,9 +192,7 @@ $stmt->values $stmt->values - - $conn->getDatabaseName() - $conn->getDatabaseName() + $stmt->decimals $stmt->length $stmt->length @@ -671,11 +630,20 @@ $values + + + $this->getDefault() + + - - $character_set - $collation - + + $this->getDefault() + + + + + $this->getDefault() + @@ -685,11 +653,36 @@ getDefault + + + $this->getDefault() + + + + + $this->getDefault() + + $mysql_default + + + $this->getDefault() + + + + + $this->getDefault() + + + + + $this->getDefault() + + $mysql_default @@ -700,10 +693,19 @@ $mysql_default + + + $this->getDefault() + + + + + $this->getDefault() + + - + $autoIncrementOffsets - $columns diff --git a/src/DataIntegrity.php b/src/DataIntegrity.php index 0a470967..677de218 100644 --- a/src/DataIntegrity.php +++ b/src/DataIntegrity.php @@ -63,7 +63,7 @@ public static function ensureColumnsPresent( ) { foreach ($table_definition->columns as $column_name => $column) { $php_type = $column->getPhpType(); - $column_nullable = $column->isNullable; + $column_nullable = $column->isNullable(); $column_default = $column instanceof Schema\Column\Defaultable ? $column->getDefault() : null; @@ -83,7 +83,7 @@ public static function ensureColumnsPresent( if ($column_nullable) { continue; } else { - $row[$column_name] = self::coerceValueToColumn($column, null); + $row[$column_name] = self::coerceValueToColumn($conn, $column, null); } } else { switch ($php_type) { @@ -135,7 +135,7 @@ public static function coerceToSchema( $column = $table_definition->columns[$column_name]; - $row[$column_name] = self::coerceValueToColumn($column, $value); + $row[$column_name] = self::coerceValueToColumn($conn, $column, $value); } return $row; @@ -145,15 +145,64 @@ public static function coerceToSchema( * @return false|float|int|null|string */ public static function coerceValueToColumn( + FakePdoInterface $conn, Schema\Column $column, $value ) { $php_type = $column->getPhpType(); - if ($column->isNullable && $value === null) { + if ($column->isNullable() && $value === null) { return null; } + if ($column instanceof Schema\Column\NumberColumn) { + if ($value === '') { + $value = 0; + } elseif (\is_bool($value)) { + $value = (int) $value; + } + + if (!\is_numeric($value)) { + if ($value === null && !$conn->useStrictMode()) { + $value = 0; + } else { + throw new Processor\InvalidValueException( + 'Number column expects a numeric value, but saw ' . var_export($value, true) + ); + } + } + + if ((float) $value > $column->getMaxValue()) { + if ($conn->useStrictMode()) { + throw new Processor\InvalidValueException('Value ' . $value . ' out of acceptable range'); + } + + $value = $column->getMaxValue(); + } elseif ((float) $value < $column->getMinValue()) { + if ($conn->useStrictMode()) { + throw new Processor\InvalidValueException('Value ' . $value . ' out of acceptable range'); + } + + $value = $column->getMinValue(); + } + } + + if ($column instanceof Schema\Column\CharacterColumn) { + if (!is_string($value)) { + $value = (string) $value; + } + + if (\strlen($value) > $column->getMaxStringLength()) { + if ($conn->useStrictMode()) { + throw new Processor\InvalidValueException( + 'String length for ' . $value . ' larger than expected ' . $column->getMaxStringLength() + ); + } + + $value = \substr($value, 0, $column->getMaxStringLength()); + } + } + switch ($php_type) { case 'int': return (int) $value; @@ -166,7 +215,7 @@ public static function coerceValueToColumn( $value .= ' 00:00:00'; } - if ($value[0] === '-' || $value === '') { + if ($value === '' || $value[0] === '-') { $value = '0000-00-00 00:00:00'; } elseif (\preg_match( '/^([0-9]{2,4}-[0-1][0-9]-[0-3][0-9]|[0-9]+)$/', diff --git a/src/DataType.php b/src/DataType.php index be213841..876b0963 100644 --- a/src/DataType.php +++ b/src/DataType.php @@ -9,6 +9,7 @@ final class DataType const TINYINT = 'TINYINT'; const SMALLINT = 'SMALLINT'; const INT = 'INT'; + const INTEGER = 'INTEGER'; const BIT = 'BIT'; const MEDIUMINT = 'MEDIUMINT'; const BIGINT = 'BIGINT'; diff --git a/src/FakePdo.php b/src/FakePdo.php new file mode 100644 index 00000000..6774a1a3 --- /dev/null +++ b/src/FakePdo.php @@ -0,0 +1,27 @@ + $options any options + * @return PDO + */ + public static function getFakePdo( + string $connection_string, + string $username, + string $password, + array $options + ): PDO { + if (\PHP_MAJOR_VERSION === 8) { + return new Php8\FakePdo($connection_string, $username, $password, $options); + } + + return new Php7\FakePdo($connection_string, $username, $password, $options); + } +} diff --git a/src/FakePdoInterface.php b/src/FakePdoInterface.php index e42fc94b..0c523d17 100644 --- a/src/FakePdoInterface.php +++ b/src/FakePdoInterface.php @@ -17,4 +17,6 @@ public function shouldLowercaseResultKeys(): bool; * @param string $seqname */ public function lastInsertId($seqname = null) : string; + + public function useStrictMode() : bool; } diff --git a/src/FakePdoStatementTrait.php b/src/FakePdoStatementTrait.php index 50629ca2..ce33ca36 100644 --- a/src/FakePdoStatementTrait.php +++ b/src/FakePdoStatementTrait.php @@ -1,6 +1,8 @@ boundValues[$key] = $value; if ($this->realStatement) { - $this->realStatement->bindValue($key, $value, $type); + return $this->realStatement->bindValue($key, $value, $type); } + return true; + } + + /** + * @param string|int $key + * @param scalar $value + * @param int $type + * @param int $maxLength + * @param mixed $driverOptions + * @return bool + */ + #[\ReturnTypeWillChange] + public function bindParam($key, &$value, $type = PDO::PARAM_STR, $maxLength = null, $driverOptions = null): bool + { + if (\is_string($key) && $key[0] !== ':') { + $key = ':' . $key; + } elseif (\is_int($key)) { + // Parameter offsets start at 1, which is weird. + --$key; + } + $this->boundValues[$key] = &$value; + if ($this->realStatement) { + /** + * @psalm-suppress PossiblyNullArgument + */ + return $this->realStatement->bindParam($key, $value, $type, $maxLength, $driverOptions); + } + return true; } /** @@ -99,7 +131,7 @@ public function universalExecute(?array $params = null) if ($this->realStatement) { if ($this->realStatement->execute($params) === false) { var_dump($this->sql); - throw new \UnexpectedValueException($this->realStatement->errorInfo()[2]); + throw new \UnexpectedValueException((string)$this->realStatement->errorInfo()[2]); } } @@ -107,7 +139,14 @@ public function universalExecute(?array $params = null) $create_queries = (new Parser\CreateTableParser())->parse($sql); foreach ($create_queries as $create_query) { - Processor\CreateProcessor::process($this->conn, $create_query); + $this->conn->getServer()->addTableDefinition( + $this->conn->getDatabaseName(), + $create_query->name, + Processor\CreateProcessor::makeTableDefinition( + $create_query, + $this->conn->getDatabaseName() + ) + ); } return true; @@ -151,7 +190,7 @@ public function universalExecute(?array $params = null) ); } - $this->result = self::processResult($raw_result); + $this->result = self::processResult($this->conn, $raw_result); if ($this->realStatement) { $fake_result = $this->result; @@ -213,18 +252,14 @@ function ($row) { break; case Query\TruncateQuery::class: - $this->conn->getServer()->resetTable( - $this->conn->getDatabaseName(), - $parsed_query->table - ); + [$databaseName, $tableName] = Processor\Processor::parseTableName($this->conn, $parsed_query->table); + $this->conn->getServer()->resetTable($databaseName, $tableName); break; case Query\DropTableQuery::class: - $this->conn->getServer()->dropTable( - $this->conn->getDatabaseName(), - $parsed_query->table - ); + [$databaseName, $tableName] = Processor\Processor::parseTableName($this->conn, $parsed_query->table); + $this->conn->getServer()->dropTable($databaseName, $tableName); break; @@ -240,6 +275,17 @@ function ($row) { break; + case Query\ShowIndexQuery::class: + $this->result = self::processResult( + $this->conn, + Processor\ShowIndexProcessor::process( + $this->conn, + new Processor\Scope(array_merge($params ?? [], $this->boundValues)), + $parsed_query + ) + ); + break; + default: throw new \UnexpectedValueException('Unsupported operation type ' . $sql); } @@ -250,7 +296,7 @@ function ($row) { /** * @psalm-return array> */ - private static function processResult(Processor\QueryResult $raw_result): array + private static function processResult(FakePdoInterface $conn, Processor\QueryResult $raw_result): array { $result = []; @@ -260,7 +306,7 @@ private static function processResult(Processor\QueryResult $raw_result): array * @psalm-suppress MixedAssignment */ $result[$i][\substr($key, 0, 255) ?: ''] = \array_key_exists($key, $raw_result->columns) - ? DataIntegrity::coerceValueToColumn($raw_result->columns[$key], $value) + ? DataIntegrity::coerceValueToColumn($conn, $raw_result->columns[$key], $value) : $value; } } @@ -287,12 +333,13 @@ public function rowCount() : int * @param int $cursor_orientation * @param int $cursor_offset */ + #[\ReturnTypeWillChange] public function fetch( $fetch_style = -123, $cursor_orientation = \PDO::FETCH_ORI_NEXT, $cursor_offset = 0 ) { - if ($fetch_style === -123) { + if ($fetch_style === -123 || (defined('PDO::FETCH_DEFAULT') && $fetch_style === \PDO::FETCH_DEFAULT)) { $fetch_style = $this->fetchMode; } @@ -322,6 +369,12 @@ public function fetch( return \array_values($row); } + if ($fetch_style === \PDO::FETCH_COLUMN) { + $this->resultCursor++; + + return \array_values($row)[0] ?? null; + } + if ($fetch_style === \PDO::FETCH_BOTH) { $this->resultCursor++; @@ -337,13 +390,32 @@ public function fetch( throw new \Exception('not implemented'); } + /** + * @param int $column + * @return null|scalar + */ + #[\ReturnTypeWillChange] + public function fetchColumn($column = 0) + { + /** @var array|false $row */ + $row = $this->fetch(\PDO::FETCH_NUM); + if ($row === false) { + return $row; + } + if (!\array_key_exists($column, $row)) { + throw new \PDOException('SQLSTATE[HY000]: General error: Invalid column index'); + } + + return $row[$column] ?? null; + } + /** * @param int $fetch_style * @param mixed $args */ public function universalFetchAll(int $fetch_style = -123, ...$args) : array { - if ($fetch_style === -123) { + if ($fetch_style === -123 || (defined('PDO::FETCH_DEFAULT') && $fetch_style === \PDO::FETCH_DEFAULT)) { $fetch_style = $this->fetchMode; $fetch_argument = $this->fetchArgument; $ctor_args = $this->fetchConstructorArgs; @@ -367,8 +439,6 @@ function ($row) { }, $this->result ?: [] ); - - return $this->result ?: []; } if ($fetch_style === \PDO::FETCH_NUM) { @@ -565,4 +635,12 @@ private function getExecutedSql(?array $params) : string return $sql; } + + /** + * @return array{0: null|string, 1: int|null, 2: null|string, 3?: mixed, 4?: mixed} + */ + public function errorInfo(): array + { + return ['00000', 0, 'PHP MySQL Engine: errorInfo() not supported.']; + } } diff --git a/src/FakePdoTrait.php b/src/FakePdoTrait.php index 887a048c..c206f2d4 100644 --- a/src/FakePdoTrait.php +++ b/src/FakePdoTrait.php @@ -28,12 +28,23 @@ trait FakePdoTrait */ public $lowercaseResultKeys = false; + /** @var ?int */ + private $defaultFetchMode = null; + + /** + * @var bool + */ + public $strict_mode = false; + /** * @var ?string * @readonly */ public $databaseName = null; + /** + * @param array $options + */ public function __construct(string $dsn, string $username = '', string $passwd = '', array $options = []) { //$this->real = new \PDO($dsn, $username, $passwd, $options); @@ -45,9 +56,14 @@ public function __construct(string $dsn, string $username = '', string $passwd = $this->databaseName = $matches[1]; } + // do a quick check for this string – hacky but fast + $this->strict_mode = \array_key_exists(\PDO::MYSQL_ATTR_INIT_COMMAND, $options) + && \strpos($options[\PDO::MYSQL_ATTR_INIT_COMMAND], 'STRICT_ALL_TABLES'); + $this->server = Server::getOrCreate('primary'); } + #[\ReturnTypeWillChange] public function setAttribute($key, $value) { if ($key === \PDO::ATTR_EMULATE_PREPARES) { @@ -58,6 +74,13 @@ public function setAttribute($key, $value) $this->lowercaseResultKeys = true; } + if ($key === \PDO::ATTR_DEFAULT_FETCH_MODE) { + if (!is_int($value)) { + throw new \PDOException("SQLSTATE[HY000]: General error: invalid fetch mode type"); + } + $this->defaultFetchMode = $value; + } + if ($this->real && $key !== \PDO::ATTR_STATEMENT_CLASS) { return $this->real->setAttribute($key, $value); } @@ -105,20 +128,103 @@ public function lastInsertId($seqname = null) : string return $this->lastInsertId; } + public function useStrictMode() : bool + { + return $this->strict_mode; + } + + #[\ReturnTypeWillChange] public function beginTransaction() { + if (Server::hasSnapshot('transaction')) { + return false; + } + Server::snapshot('transaction'); return true; } + #[\ReturnTypeWillChange] public function commit() { return Server::deleteSnapshot('transaction'); } + #[\ReturnTypeWillChange] public function rollback() { + if (!Server::hasSnapshot('transaction')) { + return false; + } + Server::restoreSnapshot('transaction'); return true; } + + #[\ReturnTypeWillChange] + public function inTransaction() + { + return Server::hasSnapshot('transaction'); + } + + /** + * @param string $statement + * @return int|false + */ + #[\ReturnTypeWillChange] + public function exec($statement) + { + $statement = trim($statement); + + if (strpos($statement, 'SET ')===0) { + return false; + } + + $sth = $this->prepare($statement); + + if ($sth->execute()) { + return $sth->rowCount(); + } + + return false; + } + + /** + * @param string $string + * @param int $parameter_type + * @return string + */ + #[\ReturnTypeWillChange] + public function quote($string, $parameter_type = \PDO::PARAM_STR) + { + // @see https://github.com/php/php-src/blob/php-8.0.2/ext/mysqlnd/mysqlnd_charset.c#L860-L878 + $quoted = strtr($string, [ + "\0" => '\0', + "\n" => '\n', + "\r" => '\r', + "\\" => '\\\\', + "\'" => '\\\'', + "\"" => '\\"', + "\032" => '\Z', + ]); + + // @see https://github.com/php/php-src/blob/php-8.0.2/ext/pdo_mysql/mysql_driver.c#L307-L320 + $quotes = ['\'', '\'']; + /** @psalm-suppress MixedOperand */ + if (defined('PDO::PARAM_STR_NATL') && + (constant('PDO::PARAM_STR_NATL') & $parameter_type) === constant('PDO::PARAM_STR_NATL') + ) { + $quotes[0] = 'N\''; + } + + return "{$quotes[0]}{$quoted}{$quotes[1]}"; + } + + /** + * @return array{0: null|string, 1: int|null, 2: null|string, 3?: mixed, 4?: mixed} + */ + public function errorInfo(): array + { + return ['00000', 0, 'PHP MySQL Engine: errorInfo() not supported.']; + } } diff --git a/src/Parser/CreateTableParser.php b/src/Parser/CreateTableParser.php index 4d9c4840..6d24a881 100644 --- a/src/Parser/CreateTableParser.php +++ b/src/Parser/CreateTableParser.php @@ -117,7 +117,7 @@ private function lexImpl(string $sql) } continue; } - $match = \preg_match('!(\d+\.?\d*|\.\d+)!A', $sql, $matches, 0, $pos); + \preg_match('!(\d+\.?\d*|\.\d+)!A', $sql, $matches, 0, $pos); if ($matches) { $source_map[] = [$pos, \strlen($matches[0])]; $pos += \strlen($matches[0]); @@ -159,7 +159,7 @@ private static function walk(array $tokens, string $sql, array $source_map) $temp = []; $start = 0; - foreach ($tokens as $i => $t) { + foreach ($tokens as $i => $_t) { $t = $tokens[$i]; if ($t === ';') { if (\count($temp)) { @@ -293,12 +293,8 @@ private static function parseCreateDefinition(array &$tokens) */ private static function parseFieldOrKey(array &$tokens, array &$fields, array &$indexes) { - $has_constraint = false; - $constraint = null; if ($tokens[0] === 'CONSTRAINT') { - $has_constraint = true; - if ($tokens[1] === 'PRIMARY KEY' || $tokens[1] === 'UNIQUE' || $tokens[1] === 'UNIQUE KEY' @@ -308,7 +304,7 @@ private static function parseFieldOrKey(array &$tokens, array &$fields, array &$ \array_shift($tokens); } else { \array_shift($tokens); - $constraint = \array_shift($tokens); + \array_shift($tokens); } } @@ -673,7 +669,8 @@ private function extractTokens(string $sql, array $source_map): array $i = 0; $len = \count($source_map); while ($i < $len) { - $token = \substr($sql, $source_map[$i][0], $source_map[$i][1]) ?: ''; + $token = \substr($sql, $source_map[$i][0], $source_map[$i][1]); + $token = $token !== false ? $token : ''; $tokenUpper = \strtoupper($token); if (\array_key_exists($tokenUpper, $maps)) { $found = false; diff --git a/src/Parser/ExpressionParser.php b/src/Parser/ExpressionParser.php index 862c24c8..9abb2b0b 100644 --- a/src/Parser/ExpressionParser.php +++ b/src/Parser/ExpressionParser.php @@ -291,7 +291,6 @@ function ($token) { public function build() { $token = $this->nextToken(); - $break_while = false; while ($token !== null) { switch ($token->type) { case TokenType::PAREN: @@ -303,7 +302,6 @@ public function build() } $this->pointer = $close; - $expr = new StubExpression(); if ($arg_tokens[0]->value === 'SELECT') { $subquery_sql = \implode( diff --git a/src/Parser/FromParser.php b/src/Parser/FromParser.php index 8f746117..da70a328 100644 --- a/src/Parser/FromParser.php +++ b/src/Parser/FromParser.php @@ -185,7 +185,7 @@ private function getSubquery() throw new ParserException("Empty parentheses found"); } $this->pointer = $close; - $expr = new StubExpression(); + $subquery_sql = \implode( ' ', \array_map( @@ -295,7 +295,26 @@ private function buildJoin(string $left_table, Token $token) } } + /* + * Unlike other clauses (e.g., FROM), the buildJoin advances the pointer to the specified keyword (e.g., FORCE). + * Therefore, the pointer needs to be adjusted. + * For instance, in "FROM a FORCE INDEX ...", processing for other clauses ends just before the identifier (a), + * but for the JOIN clause, the pointer advances to "FORCE". + * To address this issue, we adjusted the pointer before and after calling SQLParser::skipIndexHints(), + * and modified the code to advance the pointer to the closing parenthesis ')' if necessary. + */ + $this->pointer--; $this->pointer = SQLParser::skipIndexHints($this->pointer, $this->tokens); + $this->pointer++; + if ($this->tokens[$this->pointer]->type === TokenType::SEPARATOR + && $this->tokens[$this->pointer]->value === ")") { + $this->pointer++; + } + $next = $this->tokens[$this->pointer] ?? null; + if ($next === null) { + /** @psalm-suppress LessSpecificReturnStatement */ + return $table; + } if ($table['join_type'] === JoinType::NATURAL || $table['join_type'] === JoinType::CROSS) { return $table; diff --git a/src/Parser/InsertParser.php b/src/Parser/InsertParser.php index b78c1545..295573b3 100644 --- a/src/Parser/InsertParser.php +++ b/src/Parser/InsertParser.php @@ -77,7 +77,6 @@ public function parse() $query = new InsertQuery($token->value, $this->sql, $ignore_dupes); $count = \count($this->tokens); $needs_comma = false; - $end_of_set = false; while ($this->pointer < $count) { $token = $this->tokens[$this->pointer]; @@ -221,7 +220,6 @@ protected function parseValues(array $tokens) $count = \count($tokens); $expressions = []; $needs_comma = false; - $end_of_set = false; while ($pointer < $count) { $token = $tokens[$pointer]; switch ($token->type) { @@ -238,7 +236,7 @@ protected function parseValues(array $tokens) ); } $expression_parser = new ExpressionParser($tokens, $pointer - 1); - $start = $pointer; + list($pointer, $expression) = $expression_parser->buildWithPointer(); $expressions[] = $expression; $needs_comma = true; diff --git a/src/Parser/SQLParser.php b/src/Parser/SQLParser.php index eb0b27fe..b30b3ed7 100644 --- a/src/Parser/SQLParser.php +++ b/src/Parser/SQLParser.php @@ -2,15 +2,14 @@ namespace Vimeo\MysqlEngine\Parser; use Vimeo\MysqlEngine\TokenType; -use Vimeo\MysqlEngine\Query\{ - SelectQuery, +use Vimeo\MysqlEngine\Query\{SelectQuery, DeleteQuery, + ShowIndexQuery, TruncateQuery, InsertQuery, UpdateQuery, DropTableQuery, - ShowTablesQuery -}; + ShowTablesQuery}; final class SQLParser { @@ -35,6 +34,7 @@ final class SQLParser 'VALUES' => true, 'DROP' => true, 'SHOW' => true, + 'TRUNCATE' => true, ]; /** @@ -141,10 +141,11 @@ final class SQLParser 'TABLES' => true, ]; + /** @var array */ private static $cache = []; /** - * @return SelectQuery|InsertQuery|UpdateQuery|TruncateQuery|DeleteQuery|DropTableQuery|ShowTablesQuery + * @return SelectQuery|InsertQuery|UpdateQuery|TruncateQuery|DeleteQuery|DropTableQuery|ShowTablesQuery|ShowIndexQuery */ public static function parse(string $sql) { @@ -156,7 +157,7 @@ public static function parse(string $sql) } /** - * @return SelectQuery|InsertQuery|UpdateQuery|TruncateQuery|DeleteQuery|DropTableQuery|ShowTablesQuery + * @return SelectQuery|InsertQuery|UpdateQuery|TruncateQuery|DeleteQuery|DropTableQuery|ShowTablesQuery|ShowIndexQuery */ private static function parseImpl(string $sql) { @@ -168,7 +169,7 @@ private static function parseImpl(string $sql) $tokens = \array_slice($tokens, 1, $close - 1); $token = $tokens[0]; } - if ($token->type !== TokenType::CLAUSE && $token->value !== 'TRUNCATE') { + if ($token->type !== TokenType::CLAUSE) { throw new ParserException("Unexpected {$token->value}"); } switch ($token->value) { @@ -196,7 +197,6 @@ private static function parseImpl(string $sql) default: throw new ParserException("Unexpected {$token->value}"); } - throw new ParserException("Parse error: unexpected end of input"); } /** @@ -399,7 +399,6 @@ public static function findMatchingParen(int $pointer, array $tokens) { $paren_count = 0; $remaining_tokens = \array_slice($tokens, $pointer); - $token_count = \count($remaining_tokens); foreach ($remaining_tokens as $i => $token) { if ($token->type === TokenType::PAREN) { $paren_count++; @@ -424,7 +423,6 @@ public static function findMatchingEnd(int $pointer, array $tokens) { $paren_count = 0; $remaining_tokens = \array_slice($tokens, $pointer); - $token_count = \count($remaining_tokens); foreach ($remaining_tokens as $i => $token) { if ($token->type === TokenType::OPERATOR && $token->value === 'CASE' diff --git a/src/Parser/SelectParser.php b/src/Parser/SelectParser.php index 506f67ce..6a84418b 100644 --- a/src/Parser/SelectParser.php +++ b/src/Parser/SelectParser.php @@ -71,7 +71,6 @@ public function parse() : SelectQuery if (\array_key_exists($this->pointer, $this->tokens)) { $next = $this->tokens[$this->pointer] ?? null; - $val = $next ? $next->value : 'null'; while ($next !== null && ($next->value === 'UNION' || $next->value === 'INTERSECT' || $next->value === 'EXCEPT') ) { @@ -180,7 +179,7 @@ private function parseMainSelect() : SelectQuery $this->pointer++; $next = $this->tokens[$this->pointer] ?? null; $expressions = []; - $sort_directions = []; + if ($next === null || $next->value !== 'BY') { throw new ParserException("Expected BY after GROUP"); } @@ -224,7 +223,7 @@ private function parseMainSelect() : SelectQuery case 'EXCEPT': case 'INTERSECT': return $query; - break; + default: throw new ParserException("Unexpected {$token->value}"); } diff --git a/src/Parser/SetParser.php b/src/Parser/SetParser.php index 4543922d..cfd4d180 100644 --- a/src/Parser/SetParser.php +++ b/src/Parser/SetParser.php @@ -58,7 +58,7 @@ public function parse(bool $skip_set = false) throw new ParserException("Expected , between expressions in SET clause"); } $expression_parser = new ExpressionParser($this->tokens, $this->pointer - 1); - $start = $this->pointer; + list($this->pointer, $expression) = $expression_parser->buildWithPointer(); if (!$expression instanceof BinaryOperatorExpression || $expression->operator !== '=') { diff --git a/src/Parser/ShowParser.php b/src/Parser/ShowParser.php index 34f55813..15d7eb95 100644 --- a/src/Parser/ShowParser.php +++ b/src/Parser/ShowParser.php @@ -1,6 +1,8 @@ sql = $sql; } - public function parse() : ShowTablesQuery + /** + * @return ShowTablesQuery|ShowIndexQuery + * @throws ParserException + */ + public function parse() { if ($this->tokens[$this->pointer]->value !== 'SHOW') { throw new ParserException("Parser error: expected SHOW"); @@ -41,10 +47,20 @@ public function parse() : ShowTablesQuery $this->pointer++; - if ($this->tokens[$this->pointer]->value !== 'TABLES') { - throw new ParserException("Parser error: expected SHOW TABLES"); + switch ($this->tokens[$this->pointer]->value) { + case 'TABLES': + return $this->parseShowTables(); + case 'INDEX': + case 'INDEXES': + case 'KEYS': + return $this->parseShowIndex(); + default: + throw new ParserException("Parser error: expected SHOW TABLES"); } + } + private function parseShowTables(): ShowTablesQuery + { $this->pointer++; if ($this->tokens[$this->pointer]->value !== 'LIKE') { @@ -61,4 +77,31 @@ public function parse() : ShowTablesQuery return new ShowTablesQuery($token->value, $this->sql); } + + private function parseShowIndex(): ShowIndexQuery + { + $this->pointer++; + + if ($this->tokens[$this->pointer]->value !== 'FROM') { + throw new ParserException("Parser error: expected SHOW INDEX FROM"); + } + $this->pointer++; + $token = $this->tokens[$this->pointer]; + if ($token->type !== TokenType::IDENTIFIER) { + throw new ParserException("Expected table name after FROM"); + } + + $query = new ShowIndexQuery($token->value, $this->sql); + $this->pointer++; + + if ($this->pointer < count($this->tokens)) { + if ($this->tokens[$this->pointer]->value !== 'WHERE') { + throw new ParserException("Parser error: expected SHOW INDEX FROM [TABLE_NAME] WHERE"); + } + $expression_parser = new ExpressionParser($this->tokens, $this->pointer); + list($this->pointer, $expression) = $expression_parser->buildWithPointer(); + $query->whereClause = $expression; + } + return $query; + } } diff --git a/src/Parser/TruncateParser.php b/src/Parser/TruncateParser.php index 0a3a2144..31dfca5a 100644 --- a/src/Parser/TruncateParser.php +++ b/src/Parser/TruncateParser.php @@ -23,6 +23,7 @@ final class TruncateParser /** * @param array $tokens + * @param string $sql */ public function __construct(array $tokens, string $sql) { @@ -30,18 +31,31 @@ public function __construct(array $tokens, string $sql) $this->sql = $sql; } + /** + * @return TruncateQuery + * @throws ParserException + */ public function parse() : TruncateQuery { - if ($this->tokens[$this->pointer]->value !== 'TRUNCATE') { - throw new ParserException("Parser error: expected TRUNCATE"); + $token = $this->tokens[$this->pointer] ?? null; + if ($token === null) { + throw new ParserException('Parser error: invalid tokens'); + } + if ($token->value !== 'TRUNCATE') { + throw new ParserException('Parser error: expected TRUNCATE'); } $this->pointer++; - $token = $this->tokens[$this->pointer]; + $token = $this->tokens[$this->pointer] ?? null; + + if ($token!==null && $token->value === 'TABLE' && $token->type===TokenType::RESERVED) { + $this->pointer++; + $token = $this->tokens[$this->pointer] ?? null; + } if ($token === null || $token->type !== TokenType::IDENTIFIER) { - throw new ParserException("Expected table name after TRUNCATE"); + throw new ParserException('Expected table name after TRUNCATE'); } return new TruncateQuery($token->value, $this->sql); diff --git a/src/Php7/FakePdo.php b/src/Php7/FakePdo.php index bbd72b09..e579d667 100644 --- a/src/Php7/FakePdo.php +++ b/src/Php7/FakePdo.php @@ -9,45 +9,31 @@ class FakePdo extends PDO implements FakePdoInterface { use FakePdoTrait; - /** - * @param string $statement - * @param array $options - * @return FakePdoStatement - */ - public function prepare($statement, array $options = []) + /** + * @param string $statement + * @param array $options + * @return FakePdoStatement + */ + public function prepare($statement, $options = []) { - return new FakePdoStatement($this, $statement, $this->real); + $stmt = new FakePdoStatement($this, $statement, $this->real); + if ($this->defaultFetchMode) { + $stmt->setFetchMode($this->defaultFetchMode); + } + return $stmt; } - /** - * @param string $statement - * @return int|false - */ - public function exec($statement) - { - $statement = trim($statement); - if (strpos($statement, 'SET ')===0){ - return false; - } - - $sth = $this->prepare($statement); - if ($sth->execute()){ - return $sth->rowCount(); - } - return false; - } - - /** - * @param string $statement - * @param int $mode - * @param null $arg3 - * @param array $ctorargs - * @return FakePdoStatement - */ - public function query($statement, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = []) - { - $sth = $this->prepare($statement); - $sth->execute(); - return $sth; - } + /** + * @param string $statement + * @param int $mode + * @param null $arg3 + * @param array $ctorargs + * @return FakePdoStatement + */ + public function query($statement, $mode = PDO::ATTR_DEFAULT_FETCH_MODE, $arg3 = null, array $ctorargs = []) + { + $sth = $this->prepare($statement); + $sth->execute(); + return $sth; + } } diff --git a/src/Php8/FakePdo.php b/src/Php8/FakePdo.php index a7636886..c56aba93 100644 --- a/src/Php8/FakePdo.php +++ b/src/Php8/FakePdo.php @@ -5,48 +5,38 @@ use Vimeo\MysqlEngine\FakePdoInterface; use Vimeo\MysqlEngine\FakePdoTrait; -class FakePdo extends \PDO implements FakePdoInterface +class FakePdo extends PDO implements FakePdoInterface { use FakePdoTrait; - /** - * @param string $statement - * @param array $options - * @return FakePdoStatement - */ + /** + * @param string $statement + * @param array $options + * @return FakePdoStatement + */ + #[\ReturnTypeWillChange] public function prepare($statement, array $options = []) { - return new FakePdoStatement($this, $statement, $this->real); - } + $statement = new FakePdoStatement($this, $statement, $this->real); - /** - * @param string $statement - * @return int|false - */ - public function exec($statement) - { - $statement = trim($statement); - if (str_starts_with($statement, 'SET ')){ - return false; - } + if ($this->defaultFetchMode) { + $statement->setFetchMode($this->defaultFetchMode); + } - $sth = $this->prepare($statement); - if ($sth->execute()){ - return $sth->rowCount(); - } - return false; - } + return $statement; + } - /** - * @param string $statement - * @param int|null $mode - * @param mixed ...$fetchModeArgs - * @return FakePdoStatement - */ - public function query(string $statement, ?int $mode = PDO::ATTR_DEFAULT_FETCH_MODE, mixed ...$fetchModeArgs) - { - $sth = $this->prepare($statement); - $sth->execute(); - return $sth; - } + /** + * @param string $statement + * @param int|null $mode + * @param mixed ...$fetchModeArgs + * @return FakePdoStatement + */ + #[\ReturnTypeWillChange] + public function query(string $statement, ?int $mode = PDO::ATTR_DEFAULT_FETCH_MODE, mixed ...$fetchModeArgs) + { + $sth = $this->prepare($statement); + $sth->execute(); + return $sth; + } } diff --git a/src/Php8/FakePdoStatement.php b/src/Php8/FakePdoStatement.php index 252378f4..8156ba08 100644 --- a/src/Php8/FakePdoStatement.php +++ b/src/Php8/FakePdoStatement.php @@ -10,6 +10,7 @@ class FakePdoStatement extends \PDOStatement * @param ?array $params * @return bool */ + #[\ReturnTypeWillChange] public function execute(?array $params = null) { return $this->universalExecute($params); @@ -41,6 +42,7 @@ public function setFetchMode(int $mode, ...$args) : bool * @param array|null $ctorArgs * @return false|T */ + #[\ReturnTypeWillChange] public function fetchObject(?string $class = \stdClass::class, ?array $ctorArgs = null) { return $this->universalFetchObject($class, $ctorArgs); diff --git a/src/Processor/CreateProcessor.php b/src/Processor/CreateProcessor.php index 068ce5a3..49daffe0 100644 --- a/src/Processor/CreateProcessor.php +++ b/src/Processor/CreateProcessor.php @@ -8,10 +8,57 @@ final class CreateProcessor { - public static function process( - \Vimeo\MysqlEngine\FakePdoInterface $conn, - Query\CreateQuery $stmt - ) : void { + private const CHARSET_MAP = [ + 'armscii8' => 'armscii8_general_ci', + 'ascii' => 'ascii_general_ci', + 'big5' => 'big5_chinese_ci', + 'binary' => 'binary', + 'cp1250' => 'cp1250_general_ci', + 'cp1251' => 'cp1251_general_ci', + 'cp1256' => 'cp1256_general_ci', + 'cp1257' => 'cp1257_general_ci', + 'cp850' => 'cp850_general_ci', + 'cp852' => 'cp852_general_ci', + 'cp866' => 'cp866_general_ci', + 'cp932' => 'cp932_japanese_ci', + 'dec8' => 'dec8_swedish_ci', + 'eucjpms' => 'eucjpms_japanese_ci', + 'euckr' => 'euckr_korean_ci', + 'gb18030' => 'gb18030_chinese_ci', + 'gb2312' => 'gb2312_chinese_ci', + 'gbk' => 'gbk_chinese_ci', + 'geostd8' => 'geostd8_general_ci', + 'greek' => 'greek_general_ci', + 'hebrew' => 'hebrew_general_ci', + 'hp8' => 'hp8_english_ci', + 'keybcs2' => 'keybcs2_general_ci', + 'koi8r' => 'koi8r_general_ci', + 'koi8u' => 'koi8u_general_ci', + 'latin1' => 'latin1_swedish_ci', + 'latin2' => 'latin2_general_ci', + 'latin5' => 'latin5_turkish_ci', + 'latin7' => 'latin7_general_ci', + 'macce' => 'macce_general_ci', + 'macroman' => 'macroman_general_ci', + 'sjis' => 'sjis_japanese_ci', + 'swe7' => 'swe7_swedish_ci', + 'tis620' => 'tis620_thai_ci', + 'ucs2' => 'ucs2_general_ci', + 'ujis' => 'ujis_japanese_ci', + 'utf16' => 'utf16_general_ci', + 'utf16le' => 'utf16le_general_ci', + 'utf32' => 'utf32_general_ci', + 'utf8' => 'utf8_general_ci', + 'utf8mb4' => 'utf8mb4_general_ci', + 'utf8mb3' => 'utf8mb3_general_ci' + ]; + + public static function makeTableDefinition( + Query\CreateQuery $stmt, + string $database_name, + ?string $default_character_set = null, + ?string $default_collation = null + ) : TableDefinition { $definition_columns = []; $primary_key_columns = []; @@ -21,13 +68,13 @@ public static function process( foreach ($stmt->fields as $field) { $definition_columns[$field->name] = $column = self::getDefinitionColumn($field->type); - $column->isNullable = (bool) $field->type->null; + $column->setNullable((bool) $field->type->null); if ($field->auto_increment && $column instanceof Column\IntegerColumn) { $column->autoIncrement(); } - if ($field->default && $column instanceof Column\Defaultable) { + if ($field->default !== null && $column instanceof Column\Defaultable) { $column->setDefault( $field->default === 'NULL' ? null : $field->default ); @@ -52,10 +99,6 @@ function ($col) { ); } - - $default_character_set = null; - $default_collation = null; - $auto_increment_offsets = []; foreach ($stmt->props as $key => $value) { @@ -72,13 +115,20 @@ function ($col) { } } + if (!$default_collation + && $default_character_set + && isset(self::CHARSET_MAP[$default_character_set]) + ) { + $default_collation = self::CHARSET_MAP[$default_character_set]; + } + if (!$default_collation || !$default_character_set) { throw new \UnexpectedValueException('No default collation or character set given'); } - $definition = new TableDefinition( + return new TableDefinition( $stmt->name, - $conn->getDatabaseName(), + $database_name, $definition_columns, $default_character_set, $default_collation, @@ -86,12 +136,6 @@ function ($col) { $indexes, $auto_increment_offsets ); - - $conn->getServer()->addTableDefinition( - $conn->getDatabaseName(), - $stmt->name, - $definition - ); } private static function getDefinitionColumn(Query\MysqlColumnType $stmt) : Column @@ -100,9 +144,14 @@ private static function getDefinitionColumn(Query\MysqlColumnType $stmt) : Colum case DataType::TINYINT: case DataType::SMALLINT: case DataType::INT: + case DataType::INTEGER: case DataType::BIT: case DataType::MEDIUMINT: case DataType::BIGINT: + if ($stmt->null === null) { + $stmt->null = true; + } + return self::getIntegerDefinitionColumn($stmt); case DataType::FLOAT: @@ -115,7 +164,17 @@ private static function getDefinitionColumn(Query\MysqlColumnType $stmt) : Colum return new Column\Decimal($stmt->length, $stmt->decimals); case DataType::BINARY: + if ($stmt->length === null) { + throw new \UnexpectedValueException('length should not be null'); + } + + return new Column\Binary($stmt->length, 'binary', 'binary'); + case DataType::CHAR: + if ($stmt->length === null) { + throw new \UnexpectedValueException('length should not be null'); + } + return new Column\Char($stmt->length); case DataType::ENUM: @@ -145,6 +204,10 @@ private static function getDefinitionColumn(Query\MysqlColumnType $stmt) : Colum case DataType::MEDIUMTEXT: case DataType::LONGTEXT: case DataType::VARCHAR: + if ($stmt->null === null) { + $stmt->null = true; + } + return self::getTextDefinitionColumn($stmt); case DataType::DATE: @@ -160,9 +223,7 @@ private static function getDefinitionColumn(Query\MysqlColumnType $stmt) : Colum return new Column\Year(); case DataType::TIMESTAMP: - $timestamp = new Column\Timestamp(); - $timestamp->isNullable = false; - return $timestamp; + return new Column\Timestamp(); case DataType::VARBINARY: return new Column\Varbinary((int) $stmt->length); @@ -195,6 +256,7 @@ private static function getIntegerDefinitionColumn(Query\MysqlColumnType $stmt) return new Column\SmallInt($unsigned, $display_width); case DataType::INT: + case DataType::INTEGER: return new Column\IntColumn($unsigned, $display_width); case DataType::BIT: @@ -216,8 +278,8 @@ private static function getIntegerDefinitionColumn(Query\MysqlColumnType $stmt) */ private static function getTextDefinitionColumn(Query\MysqlColumnType $stmt) { - $collation = null; - $character_set = null; + $collation = $stmt->collation; + $character_set = $stmt->character_set; switch (strtoupper($stmt->type)) { case DataType::TEXT: diff --git a/src/Processor/Expression/BinaryOperatorEvaluator.php b/src/Processor/Expression/BinaryOperatorEvaluator.php index cc7ced2c..bced418c 100644 --- a/src/Processor/Expression/BinaryOperatorEvaluator.php +++ b/src/Processor/Expression/BinaryOperatorEvaluator.php @@ -152,7 +152,7 @@ public static function evaluate( return !$expr->negatedInt; } - return $l_value == $r_value ? 1 : 0 ^ $expr->negatedInt; + return ($l_value == $r_value ? 1 : 0 ) ^ $expr->negatedInt; case '<>': case '!=': @@ -165,35 +165,35 @@ public static function evaluate( return $expr->negatedInt; } - return $l_value != $r_value ? 1 : 0 ^ $expr->negatedInt; + return ($l_value != $r_value ? 1 : 0) ^ $expr->negatedInt; case '>': if ($as_string) { - return (string) $l_value > (string) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((string) $l_value > (string) $r_value ? 1 : 0) ^ $expr->negatedInt; } - return (float) $l_value > (float) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((float) $l_value > (float) $r_value ? 1 : 0 ) ^ $expr->negatedInt; // no break case '>=': if ($as_string) { - return (string) $l_value >= (string) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((string) $l_value >= (string) $r_value ? 1 : 0) ^ $expr->negatedInt; } - return (float) $l_value >= (float) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((float) $l_value >= (float) $r_value ? 1 : 0) ^ $expr->negatedInt; case '<': if ($as_string) { - return (string) $l_value < (string) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((string) $l_value < (string) $r_value ? 1 : 0) ^ $expr->negatedInt; } - return (float) $l_value < (float) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((float) $l_value < (float) $r_value ? 1 : 0) ^ $expr->negatedInt; case '<=': if ($as_string) { - return (string) $l_value <= (string) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((string) $l_value <= (string) $r_value ? 1 : 0) ^ $expr->negatedInt; } - return (float) $l_value <= (float) $r_value ? 1 : 0 ^ $expr->negatedInt; + return ((float) $l_value <= (float) $r_value ? 1 : 0) ^ $expr->negatedInt; } // PHPCS thinks there's a fallthrough here, but there provably is not @@ -226,8 +226,18 @@ public static function evaluate( case 'MOD': return \fmod((double) $left_number, (double) $right_number); case '/': + // Ensure division by 0 cannot occur and 0 divided by anything is also 0 + if ($right_number === 0 || $left_number === 0) { + return 0; + } + return $left_number / $right_number; case 'DIV': + // Ensure division by 0 cannot occur and 0 divided by anything is also 0 + if ($right_number === 0 || $left_number === 0) { + return 0; + } + return (int) ($left_number / $right_number); case '-': return $left_number - $right_number; @@ -246,7 +256,6 @@ public static function evaluate( throw new ProcessorException("Operator recognized but not implemented"); case 'LIKE': - $l_value = Evaluator::evaluate($conn, $scope, $left, $row, $result); $r_value = Evaluator::evaluate($conn, $scope, $right, $row, $result); $left_string = (string) Evaluator::evaluate($conn, $scope, $left, $row, $result); @@ -356,8 +365,6 @@ public static function getColumnSchema( if ($right instanceof IntervalOperatorExpression && ($expr->operator === '+' || $expr->operator === '-') ) { - $functionName = $expr->operator === '+' ? 'DATE_ADD' : 'DATE_SUB'; - return new Column\DateTime(); } @@ -486,7 +493,6 @@ private static function evaluateRowComparison( throw new ProcessorException("Mismatched column count in row comparison expression"); } $last_index = \array_key_last($left_elems); - $match = true; foreach ($left_elems as $index => $le) { $re = $right_elems[$index]; if ($le == $re && $index !== $last_index) { diff --git a/src/Processor/Expression/Evaluator.php b/src/Processor/Expression/Evaluator.php index e96bfc7f..edf2a715 100644 --- a/src/Processor/Expression/Evaluator.php +++ b/src/Processor/Expression/Evaluator.php @@ -150,7 +150,7 @@ public static function getColumnSchema( return $expr->column = new Column\IntColumn(false, 10); case TokenType::STRING_CONSTANT: - return $expr->column = new Column\Varchar(10); + return $expr->column = new Column\Varchar(255); case TokenType::NULL_CONSTANT: return $expr->column = new Column\NullColumn(); @@ -220,7 +220,7 @@ public static function getColumnSchema( return new Column\NullColumn(); } - return new Column\Varchar(10); + return new Column\Varchar(255); } // When MySQL can't figure out a variable column's type @@ -272,7 +272,7 @@ private static function getColumnTypeFromValue( return $expr->column = new Column\NullColumn(); } - return new Column\Varchar(10); + return new Column\Varchar(255); } /** @@ -290,13 +290,13 @@ public static function combineColumnTypes(array $types) : Column if ($type_0_null) { $type = clone $types[1]; - $type->isNullable = true; + $type->setNullable(true); return $type; } if ($type_1_null) { $type = clone $types[0]; - $type->isNullable = true; + $type->setNullable(true); return $type; } } @@ -306,12 +306,11 @@ public static function combineColumnTypes(array $types) : Column $has_floating_point = false; $has_integer = false; $has_string = false; - $has_date = false; $non_null_types = []; foreach ($types as $type) { - if ($type->isNullable) { + if ($type->isNullable()) { $is_nullable = true; } @@ -326,7 +325,7 @@ public static function combineColumnTypes(array $types) : Column if (count($non_null_types) === 1) { $type = clone $non_null_types[0]; - $type->isNullable = true; + $type->setNullable(true); return $type; } @@ -357,7 +356,7 @@ public static function combineColumnTypes(array $types) : Column } if ($is_nullable) { - $column->isNullable = true; + $column->setNullable(true); } return $column; diff --git a/src/Processor/Expression/FunctionEvaluator.php b/src/Processor/Expression/FunctionEvaluator.php index 9c349aff..b82d27a5 100644 --- a/src/Processor/Expression/FunctionEvaluator.php +++ b/src/Processor/Expression/FunctionEvaluator.php @@ -2,14 +2,16 @@ namespace Vimeo\MysqlEngine\Processor\Expression; use Vimeo\MysqlEngine\FakePdoInterface; +use Vimeo\MysqlEngine\Processor\ProcessorException; use Vimeo\MysqlEngine\Processor\QueryResult; use Vimeo\MysqlEngine\Processor\Scope; -use Vimeo\MysqlEngine\Processor\ProcessorException; use Vimeo\MysqlEngine\Query\Expression\ColumnExpression; +use Vimeo\MysqlEngine\Query\Expression\ConstantExpression; use Vimeo\MysqlEngine\Query\Expression\Expression; use Vimeo\MysqlEngine\Query\Expression\FunctionExpression; use Vimeo\MysqlEngine\Query\Expression\IntervalOperatorExpression; use Vimeo\MysqlEngine\Schema\Column; +use Vimeo\MysqlEngine\TokenType; final class FunctionEvaluator { @@ -64,12 +66,16 @@ public static function evaluate( return self::sqlConcatWS($conn, $scope, $expr, $row, $result); case 'CONCAT': return self::sqlConcat($conn, $scope, $expr, $row, $result); + case 'GROUP_CONCAT': + return self::sqlGroupConcat($conn, $scope, $expr, $row, $result); case 'FIELD': return self::sqlColumn($conn, $scope, $expr, $row, $result); case 'BINARY': return self::sqlBinary($conn, $scope, $expr, $row, $result); case 'FROM_UNIXTIME': return self::sqlFromUnixtime($conn, $scope, $expr, $row, $result); + case 'UNIX_TIMESTAMP': + return self::sqlUnixTimestamp($conn, $scope, $expr, $row, $result); case 'GREATEST': return self::sqlGreatest($conn, $scope, $expr, $row, $result); case 'VALUES': @@ -88,12 +94,32 @@ public static function evaluate( return self::sqlDateAdd($conn, $scope, $expr, $row, $result); case 'ROUND': return self::sqlRound($conn, $scope, $expr, $row, $result); + case 'CEIL': + case 'CEILING': + return self::sqlCeiling($conn, $scope, $expr, $row, $result); + case 'FLOOR': + return self::sqlFloor($conn, $scope, $expr, $row, $result); + case 'CONVERT_TZ': + return self::sqlConvertTz($conn, $scope, $expr, $row, $result); + case 'TIMESTAMPDIFF': + return self::sqlTimestampdiff($conn, $scope, $expr, $row, $result); case 'DATEDIFF': return self::sqlDateDiff($conn, $scope, $expr, $row, $result); case 'DAY': return self::sqlDay($conn, $scope, $expr, $row, $result); case 'LAST_DAY': return self::sqlLastDay($conn, $scope, $expr, $row, $result); + case 'CURDATE': + case 'CURRENT_DATE': + return self::sqlCurDate($expr); + case 'WEEKDAY': + return self::sqlWeekDay($conn, $scope, $expr, $row, $result); + case 'INET_ATON': + return self::sqlInetAton($conn, $scope, $expr, $row, $result); + case 'INET_NTOA': + return self::sqlInetNtoa($conn, $scope, $expr, $row, $result); + case 'LEAST': + return self::sqlLeast($conn, $scope, $expr, $row, $result); } throw new ProcessorException("Function " . $expr->functionName . " not implemented yet"); @@ -116,13 +142,44 @@ public static function getColumnSchema( case 'MAX': case 'MIN': $column = clone Evaluator::getColumnSchema($expr->args[0], $scope, $columns); - $column->isNullable = true; + + if ($column instanceof Column\IntegerColumn) { + $column = new Column\BigInt(false, 10); + } + + $column->setNullable(true); return $column; case 'MOD': return new Column\IntColumn(false, 10); + case 'AVG': return new Column\FloatColumn(10, 2); + + case 'CEIL': + case 'CEILING': + case 'FLOOR': + // from MySQL docs: https://dev.mysql.com/doc/refman/5.6/en/mathematical-functions.html#function_ceil + // For exact-value numeric arguments, the return value has an exact-value numeric type. For string or + // floating-point arguments, the return value has a floating-point type. But... + // + // mysql> CREATE TEMPORARY TABLE `temp` SELECT FLOOR(1.2); + // Query OK, 1 row affected (0.00 sec) + // Records: 1 Duplicates: 0 Warnings: 0 + // + // mysql> describe temp; + // +------------+--------+------+-----+---------+-------+ + // | Field | Type | Null | Key | Default | Extra | + // +------------+--------+------+-----+---------+-------+ + // | FLOOR(1.2) | bigint | NO | | 0 | NULL | + // +------------+--------+------+-----+---------+-------+ + // 1 row in set (0.00 sec) + if ($expr->args[0]->getType() == TokenType::STRING_CONSTANT) { + return new Column\DoubleColumn(10, 2); + } + + return new Column\BigInt(false, 10); + case 'IF': $if = Evaluator::getColumnSchema($expr->args[1], $scope, $columns); $else = Evaluator::getColumnSchema($expr->args[2], $scope, $columns); @@ -143,7 +200,7 @@ public static function getColumnSchema( } $if = clone $if; - $if->isNullable = false; + $if->setNullable(false); if ($if->getPhpType() === 'string') { return $if; @@ -198,14 +255,18 @@ public static function getColumnSchema( case 'NOW': return new Column\DateTime(); + case 'CURDATE': + case 'CURRENT_DATE': + return new Column\Date(); + case 'DATE': case 'LAST_DAY': $arg = Evaluator::getColumnSchema($expr->args[0], $scope, $columns); $date = new Column\Date(); - if ($arg->isNullable) { - $date->isNullable = true; + if ($arg->isNullable()) { + $date->setNullable(true); } return $date; @@ -221,10 +282,27 @@ public static function getColumnSchema( return Evaluator::getColumnSchema($expr->args[0], $scope, $columns); case 'ROUND': + $precision = 0; + + if (isset($expr->args[1])) { + /** @var ConstantExpression $arg */ + $arg = $expr->args[1]; + + $precision = (int)$arg->value; + } + + if ($precision === 0) { + return new Column\IntColumn(false, 10); + } + return Evaluator::getColumnSchema($expr->args[0], $scope, $columns); + case 'INET_ATON': + return new Column\IntColumn(true, 15); + case 'DATEDIFF': case 'DAY': + case 'WEEKDAY': return new Column\IntColumn(false, 10); } @@ -275,9 +353,13 @@ private static function sqlCount( } /** - * @param array $columns + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param QueryResult $result * - * @return ?numeric + * @return float|int|mixed|string|null + * @throws ProcessorException */ private static function sqlSum( FakePdoInterface $conn, @@ -290,6 +372,11 @@ private static function sqlSum( $sum = 0; if (!$result->rows) { + $isQueryWithoutFromClause = empty($result->columns); + if ($expr instanceof FunctionExpression && $isQueryWithoutFromClause) { + return self::evaluate($conn, $scope, $expr, [], $result); + } + return null; } @@ -363,14 +450,20 @@ private static function sqlMin( $value = Evaluator::evaluate($conn, $scope, $expr, $row, $result); - if (!\is_scalar($value)) { + if (!\is_scalar($value) && !\is_null($value)) { throw new \TypeError('Bad min value'); } $values[] = $value; } - return self::castAggregate(\min($values), $expr, $result); + $min_value = \min($values); + + if ($min_value === null) { + return null; + } + + return self::castAggregate($min_value, $expr, $result); } /** @@ -398,14 +491,20 @@ private static function sqlMax( $value = Evaluator::evaluate($conn, $scope, $expr, $row, $result); - if (!\is_scalar($value)) { + if (!\is_scalar($value) && !\is_null($value)) { throw new \TypeError('Bad max value'); } $values[] = $value; } - return self::castAggregate(\max($values), $expr, $result); + $max_value = \max($values); + + if ($max_value === null) { + return null; + } + + return self::castAggregate($max_value, $expr, $result); } /** @@ -558,7 +657,7 @@ private static function sqlSubstringIndex( $delim = (string) Evaluator::evaluate($conn, $scope, $delimiter, $row, $result); $pos = $args[2]; - if ($pos !== null) { + if ($pos !== null && $delim !== '') { $count = (int) Evaluator::evaluate($conn, $scope, $pos, $row, $result); $parts = \explode($delim, $string); @@ -809,6 +908,32 @@ private static function sqlFromUnixtime( return \date('Y-m-d H:i:s', (int) $column); } + /** + * @param array $row + */ + private static function sqlUnixTimestamp( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) : ?int { + $args = $expr->args; + + switch (\count($args)) { + case 0: + return time(); + case 1: + $column = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + if (!\is_string($column)) { + return null; + } + return \strtotime($column) ?: null; + default: + throw new ProcessorException("MySQL UNIX_TIMESTAMP() SQLFake only implemented for 0 or 1 argument"); + } + } + /** * @param array $row * @@ -828,7 +953,28 @@ private static function sqlConcat( } $final_concat = ""; - foreach ($args as $k => $arg) { + foreach ($args as $arg) { + $val = (string) Evaluator::evaluate($conn, $scope, $arg, $row, $result); + $final_concat .= $val; + } + + return $final_concat; + } + + /** + * @param array $row + */ + private static function sqlGroupConcat( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ): string { + $args = $expr->args; + + $final_concat = ""; + foreach ($args as $arg) { $val = (string) Evaluator::evaluate($conn, $scope, $arg, $row, $result); $final_concat .= $val; } @@ -1002,6 +1148,49 @@ private static function sqlLastDay( return (new \DateTimeImmutable($subject))->format('Y-m-t'); } + /** + * @param array $row + */ + private static function sqlCurDate(FunctionExpression $expr): string + { + $args = $expr->args; + + if (\count($args) !== 0) { + throw new ProcessorException("MySQL CURDATE() function takes no arguments."); + } + + return (new \DateTimeImmutable())->format('Y-m-d'); + } + + /** + * @param array $row + */ + private static function sqlWeekDay( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) : ?int { + $args = $expr->args; + + if (\count($args) !== 1) { + throw new ProcessorException("MySQL WEEKDAY() function must be called with one argument"); + } + + $subject = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!is_string($subject)) { + throw new \TypeError('Failed assertion'); + } + + if (!$subject || \strpos($subject, '0000-00-00') === 0) { + return null; + } + + return (int)(new \DateTimeImmutable($subject))->format('N'); + } + /** * @param array $row * @@ -1181,6 +1370,8 @@ private static function sqlDay( /** * @param array $row + * + * @return float|int */ private static function sqlRound( FakePdoInterface $conn, @@ -1188,17 +1379,150 @@ private static function sqlRound( FunctionExpression $expr, array $row, QueryResult $result - ) : float { + ) { $args = $expr->args; - if (\count($args) !== 2) { - throw new ProcessorException("MySQL ROUND() function must be called with one arguments"); + if (\count($args) !== 1 && \count($args) !== 2) { + throw new ProcessorException("MySQL ROUND() function must be called with one or two arguments"); + } + + $number = (float)Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!isset($args[1])) { + return \round($number); + } + + $precision = (int)Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + + return \round($number, $precision); + } + + /** + * @param array $row + * @return float|null + */ + private static function sqlInetAton( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) : ?float { + $args = $expr->args; + + if (\count($args) !== 1) { + throw new ProcessorException("MySQL INET_ATON() function must be called with one argument"); + } + + $subject = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!is_string($subject)) { + // INET_ATON() returns NULL if it does not understand its argument. + return null; + } + + $value = ip2long($subject); + + if (!$value) { + return null; + } + + // https://www.php.net/manual/en/function.ip2long.php - this comes as a signed int + //use %u to convert this to an unsigned long, then cast it as a float + return floatval(sprintf('%u', $value)); + } + + /** + * @param array $row + * @return string + */ + private static function sqlInetNtoa( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) : ?string { + $args = $expr->args; + + if (\count($args) !== 1) { + throw new ProcessorException("MySQL INET_NTOA() function must be called with one argument"); + } + + $subject = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!is_numeric($subject)) { + // INET_NTOA() returns NULL if it does not understand its argument + return null; + } + + return long2ip((int)$subject); + } + + /** + * @param array $row + * @return float|0 + */ + private static function sqlCeiling( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) { + $args = $expr->args; + + if (\count($args) !== 1) { + throw new ProcessorException("MySQL CEILING function must be called with one argument (got " . count($args) . ")"); + } + + $subject = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!is_numeric($subject)) { + // CEILING() returns 0 if it does not understand its argument. + return 0; } - $first = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); - $second = Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + $value = ceil(floatval($subject)); + + if (!$value) { + return 0; + } - return \round($first, $second); + return $value; + } + + /** + * @param array $row + * @return float|0 + */ + private static function sqlFloor( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) { + $args = $expr->args; + + if (\count($args) !== 1) { + throw new ProcessorException("MySQL FLOOR function must be called with one argument"); + } + + $subject = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + + if (!is_numeric($subject)) { + // FLOOR() returns 0 if it does not understand its argument. + return 0; + } + + $value = floor(floatval($subject)); + + if (!$value) { + return 0; + } + + return $value; } private static function getPhpIntervalFromExpression( @@ -1236,4 +1560,181 @@ private static function getPhpIntervalFromExpression( throw new ProcessorException('MySQL INTERVAL unit ' . $expr->unit . ' not supported yet'); } } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return string|null + * @throws ProcessorException + */ + private static function sqlConvertTz( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result) + { + $args = $expr->args; + + if (count($args) !== 3) { + throw new \InvalidArgumentException("CONVERT_TZ() requires exactly 3 arguments"); + } + + if ($args[0] instanceof ColumnExpression && empty($row)) { + return null; + } + + /** @var string|null $dtValue */ + $dtValue = Evaluator::evaluate($conn, $scope, $args[0], $row, $result); + /** @var string|null $fromTzValue */ + $fromTzValue = Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + /** @var string|null $toTzValue */ + $toTzValue = Evaluator::evaluate($conn, $scope, $args[2], $row, $result); + + if ($dtValue === null || $fromTzValue === null || $toTzValue === null) { + return null; + } + + try { + $dt = new \DateTime($dtValue, new \DateTimeZone($fromTzValue)); + $dt->setTimezone(new \DateTimeZone($toTzValue)); + return $dt->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + return null; + } + } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return int + * @throws ProcessorException + */ + private static function sqlTimestampdiff( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) { + $args = $expr->args; + + if (\count($args) !== 3) { + throw new ProcessorException("MySQL TIMESTAMPDIFF() function must be called with three arguments"); + } + + if (!$args[0] instanceof ColumnExpression) { + throw new ProcessorException("MySQL TIMESTAMPDIFF() function should be called with a unit for interval"); + } + + /** @var string|null $unit */ + $unit = $args[0]->columnExpression; + /** @var string|int|float|null $start */ + $start = Evaluator::evaluate($conn, $scope, $args[1], $row, $result); + /** @var string|int|float|null $end */ + $end = Evaluator::evaluate($conn, $scope, $args[2], $row, $result); + + try { + $dtStart = new \DateTime((string) $start); + $dtEnd = new \DateTime((string) $end); + } catch (\Exception $e) { + throw new ProcessorException("Invalid datetime value passed to TIMESTAMPDIFF()"); + } + + $interval = $dtStart->diff($dtEnd); + + // Calculate difference in seconds for fine-grained units + $seconds = $dtEnd->getTimestamp() - $dtStart->getTimestamp(); + + switch (strtoupper((string)$unit)) { + case 'MICROSECOND': + return $seconds * 1000000; + case 'SECOND': + return $seconds; + case 'MINUTE': + return (int) floor($seconds / 60); + case 'HOUR': + return (int) floor($seconds / 3600); + case 'DAY': + return (int) $interval->days * ($seconds < 0 ? -1 : 1); + case 'WEEK': + return (int) floor($interval->days / 7) * ($seconds < 0 ? -1 : 1); + case 'MONTH': + return ($interval->y * 12 + $interval->m) * ($seconds < 0 ? -1 : 1); + case 'QUARTER': + $months = $interval->y * 12 + $interval->m; + return (int) floor($months / 3) * ($seconds < 0 ? -1 : 1); + case 'YEAR': + return $interval->y * ($seconds < 0 ? -1 : 1); + default: + throw new ProcessorException("Unsupported unit '$unit' in TIMESTAMPDIFF()"); + } + } + + /** + * @param FakePdoInterface $conn + * @param Scope $scope + * @param FunctionExpression $expr + * @param array $row + * @param QueryResult $result + * + * @return mixed|null + * @throws ProcessorException + */ + private static function sqlLeast( + FakePdoInterface $conn, + Scope $scope, + FunctionExpression $expr, + array $row, + QueryResult $result + ) + { + $args = $expr->args; + + if (\count($args) < 2) { + throw new ProcessorException("Incorrect parameter count in the call to native function 'LEAST'"); + } + + $is_any_float = false; + $is_any_string = false; + $precision = 0; + $evaluated_args = []; + + foreach ($args as $arg) { + /** @var string|int|float|null $evaluated_arg */ + $evaluated_arg = Evaluator::evaluate($conn, $scope, $arg, $row, $result); + if (is_null($evaluated_arg)) { + return null; + } + + if (is_float($evaluated_arg)) { + $is_any_float = true; + $precision = max($precision, strlen(substr(strrchr((string) $evaluated_arg, "."), 1))); + } + + $is_any_string = $is_any_string || is_string($evaluated_arg); + $evaluated_args[] = $evaluated_arg; + } + + if ($is_any_string) { + $evaluated_str_args = array_map(function($arg) { + return (string) $arg; + }, $evaluated_args); + return min($evaluated_str_args); + } + + if ($is_any_float) { + return number_format((float) min($evaluated_args), $precision); + } + + return min($evaluated_args); + } } diff --git a/src/Processor/Expression/UnaryEvaluator.php b/src/Processor/Expression/UnaryEvaluator.php index 987eb8af..d564434d 100644 --- a/src/Processor/Expression/UnaryEvaluator.php +++ b/src/Processor/Expression/UnaryEvaluator.php @@ -39,7 +39,5 @@ public static function evaluate( default: throw new ProcessorException("Unimplemented unary operand {$expr->name}"); } - - return $val; } } diff --git a/src/Processor/Expression/VariableEvaluator.php b/src/Processor/Expression/VariableEvaluator.php index c029e606..cccf6e0e 100644 --- a/src/Processor/Expression/VariableEvaluator.php +++ b/src/Processor/Expression/VariableEvaluator.php @@ -1,6 +1,8 @@ variableName, '@') === 0) { + return self::getSystemVariable(substr($expr->variableName, 1)); + } + if (\array_key_exists($expr->variableName, $scope->variables)) { return $scope->variables[$expr->variableName]; } return null; } + + /** + * @param string $variableName + * + * @return string + * @throws ProcessorException + */ + private static function getSystemVariable(string $variableName): string + { + switch ($variableName) { + case 'session.time_zone': + return date_default_timezone_get(); + default: + throw new ProcessorException("System variable $variableName is not supported yet!"); + } + } } diff --git a/src/Processor/FromProcessor.php b/src/Processor/FromProcessor.php index ced5518e..a14c9d80 100644 --- a/src/Processor/FromProcessor.php +++ b/src/Processor/FromProcessor.php @@ -11,8 +11,6 @@ public static function process( Scope $scope, FromClause $stmt ) : QueryResult { - $is_first_table = true; - $left_column_list = []; $result = null; diff --git a/src/Processor/InsertProcessor.php b/src/Processor/InsertProcessor.php index 9f9903a0..ede5fc60 100644 --- a/src/Processor/InsertProcessor.php +++ b/src/Processor/InsertProcessor.php @@ -25,9 +25,7 @@ public static function process( $rows_affected = 0; - $row = []; - $last_insert_id = null; $conn->setLastInsertId("0"); @@ -78,17 +76,17 @@ public static function process( $column = $table_definition->columns[$column_name]; if ($column instanceof IntegerColumn && $column->isAutoIncrement()) { - $conn->getServer()->addAutoIncrementMinValue( + $last_incremented_value = $conn->getServer()->addAutoIncrementMinValue( $database, $table_name, $column_name, $value ); - } - } - if (\count($table_definition->primaryKeyColumns) === 1 && $conn->lastInsertId() === "0") { - $conn->setLastInsertId((string) $row[$table_definition->primaryKeyColumns[0]]); + if ($conn->lastInsertId() === "0") { + $conn->setLastInsertId((string)$last_incremented_value); + } + } } $table[] = $row; @@ -98,7 +96,7 @@ public static function process( $conn->getServer()->saveTable($database, $table_name, $table); if ($stmt->setClause) { - list($set_rows_affected) = self::applySet( + list($rows_affected) = self::applySet( $conn, $scope, $database, diff --git a/src/Processor/InvalidValueException.php b/src/Processor/InvalidValueException.php new file mode 100644 index 00000000..04e16cd0 --- /dev/null +++ b/src/Processor/InvalidValueException.php @@ -0,0 +1,6 @@ +rows as $row) { foreach ($right_result->rows as $r) { - $left_row = $row; $candidate_row = \array_merge($row, $r); if (!$filter || ExpressionEvaluator::evaluate( @@ -63,7 +62,7 @@ public static function process( $parts = explode('.%.', $name); $null_placeholder[$right_table_name . '.%.' . end($parts)] = null; $column = clone $column; - $column->isNullable = true; + $column->setNullable(true); $right_result->columns[$name] = $column; } @@ -95,41 +94,7 @@ public static function process( break; case JoinType::RIGHT: - $null_placeholder = []; - - foreach ($right_result->columns as $name => $_) { - $parts = explode('.%.', $name); - $null_placeholder[$right_table_name . '.%.' . end($parts)] = null; - } - - $joined_columns = array_merge($left_result->columns, $right_result->columns); - - foreach ($right_result->rows as $raw) { - $any_match = false; - - foreach ($left_result->rows as $row) { - $left_row = $row; - $candidate_row = \array_merge($left_row, $raw); - - if (!$filter - || ExpressionEvaluator::evaluate( - $conn, - $scope, - $filter, - $candidate_row, - new QueryResult([], $joined_columns) - ) - ) { - $rows[] = $candidate_row; - $any_match = true; - } - } - - if (!$any_match) { - $rows[] = $raw; - } - } - break; + throw new \Exception('Right joins are currently unsupported'); case JoinType::CROSS: $joined_columns = array_merge($left_result->columns, $right_result->columns); @@ -185,10 +150,10 @@ protected static function buildNaturalJoinFilter(array $left_dataset, array $rig throw new ParserException("Attempted NATURAL join with no data present"); } - foreach ($left as $column => $val) { + foreach ($left as $column => $_val) { $name_parts = \explode('.%.', $column); $name = end($name_parts); - foreach ($right as $col => $v) { + foreach ($right as $col => $_v) { $col_parts = \explode('.%.', $col); $colname = end($col_parts); if ($colname === $name) { diff --git a/src/Processor/Processor.php b/src/Processor/Processor.php index 2bb659b7..885f2279 100644 --- a/src/Processor/Processor.php +++ b/src/Processor/Processor.php @@ -93,7 +93,7 @@ function ($a, $b) use ($sort_fun) { ); $rows = []; - foreach ($rows_temp as $index => $item) { + foreach ($rows_temp as $item) { $rows[$item[0]] = $item[1]; } @@ -231,7 +231,6 @@ protected static function applySet( } } } else { - $changes_found = true; $row = []; foreach ($set_clauses as $clause) { @@ -250,7 +249,7 @@ protected static function applySet( $column = $table_definition->columns[$column_name]; if ($column instanceof IntegerColumn && $column->isAutoIncrement()) { - $conn->getServer()->addAutoIncrementMinValue( + $last_insert_id = $conn->getServer()->addAutoIncrementMinValue( $database, $table_name, $column_name, @@ -259,10 +258,6 @@ protected static function applySet( } } - if (\count($table_definition->primaryKeyColumns) === 1) { - $last_insert_id = $row[$table_definition->primaryKeyColumns[0]]; - } - $result = DataIntegrity::checkUniqueConstraints($original_table, $row, $table_definition, null); if ($result !== null) { diff --git a/src/Processor/SelectProcessor.php b/src/Processor/SelectProcessor.php index a5735fa5..fa9f004c 100644 --- a/src/Processor/SelectProcessor.php +++ b/src/Processor/SelectProcessor.php @@ -204,7 +204,7 @@ protected static function applyHaving( $out_groups = []; - foreach ($result->grouped_rows as $group_id => $rows) { + foreach ($result->grouped_rows as $rows) { $group_result = new QueryResult($rows, $result->columns); $first_row = reset($rows); @@ -279,7 +279,7 @@ function ($expr) { $have_reevaluated_columns = false; - foreach ($grouped_rows as $group_id => $rows) { + foreach ($grouped_rows as $rows) { $group_result = $result->grouped_rows !== null ? new QueryResult($rows, $result->columns) : $result; @@ -301,7 +301,7 @@ function ($expr) { $parts = \explode(".%.", (string) $col); if ($expr->tableName() !== null) { - list($col_table_name, $col_name) = $parts; + [$col_table_name, $col_name] = $parts; if ($col_table_name == $expr->tableName()) { if (!\array_key_exists($col, $formatted_row)) { $formatted_row[$col_name] = $val; @@ -320,11 +320,15 @@ function ($expr) { continue; } + /** + * Evaluator case \Vimeo\MysqlEngine\Query\Expression\SubqueryExpression::class: + * should ensure the value of $val is never an array, and only the value of the + * column requested, but we'll leave this code just to make sure of that. + */ $val = Expression\Evaluator::evaluate($conn, $scope, $expr, $row, $group_result); $name = $expr->name; - if ($expr instanceof SubqueryExpression) { - assert(\is_array($val), 'subquery results must be KeyedContainer'); + if ($expr instanceof SubqueryExpression && \is_array($val)) { if (\count($val) > 1) { throw new ProcessorException("Subquery returned more than one row"); } @@ -392,20 +396,12 @@ function ($expr) { } $out[$i][$name] = $val; - - if ($expr->hasAggregate()) { - $found_aggregate = true; - } } } $i = 0; - foreach ($grouped_rows as $group_id => $rows) { - $group_result = $result->grouped_rows !== null - ? new QueryResult($rows, $result->columns) - : $result; - + foreach ($grouped_rows as $rows) { foreach ($rows as $row) { $found_aggregate = false; @@ -485,7 +481,7 @@ private static function getSelectSchema( $parts = \explode(".", $column_id); if ($expr_table_name = $expr->tableName()) { - list($column_table_name, $column_name) = $parts; + [$column_table_name] = $parts; if ($column_table_name === $expr_table_name) { $columns[$column_id] = $from_column; @@ -512,7 +508,7 @@ private static function getSelectSchema( $use_cache ); - $columns[$expr->name]->isNullable = true; + $columns[$expr->name]->setNullable(true); } } } @@ -622,7 +618,7 @@ function ($col) { if (isset($subquery_result->columns[$column_name]) && (\get_class($subquery_result->columns[$column_name]) !== \get_class($column) - || $subquery_result->columns[$column_name]->isNullable !== $column->isNullable) + || $subquery_result->columns[$column_name]->isNullable() !== $column->isNullable()) ) { $columns[$column_name] = Expression\Evaluator::combineColumnTypes([ $subquery_result->columns[$column_name], diff --git a/src/Processor/ShowIndexProcessor.php b/src/Processor/ShowIndexProcessor.php new file mode 100644 index 00000000..df9aaaaa --- /dev/null +++ b/src/Processor/ShowIndexProcessor.php @@ -0,0 +1,74 @@ +table); + $table_definition = $conn->getServer()->getTableDefinition( + $database, + $table + ); + if (!$table_definition) { + return new QueryResult([], []); + } + $columns = [ + 'Table' => new Column\Varchar(255), + 'Non_unique' => new Column\TinyInt(true, 1), + 'Key_name' => new Column\Varchar(255), + 'Seq_in_index' => new Column\IntColumn(true, 4), + 'Column_name' => new Column\Varchar(255), + 'Collation' => new Column\Char(1), + 'Cardinality' => new Column\IntColumn(true, 4), + 'Sub_part' => new Column\IntColumn(true, 4), + 'Packed' => new Column\TinyInt(true, 1), + 'Null' => new Column\Varchar(3), + 'Index_type' => new Column\Varchar(5), + 'Comment' => new Column\Varchar(255), + 'Index_comment' => new Column\Varchar(255) + ]; + $rows = []; + foreach ($table_definition->indexes as $name => $index) { + foreach ($index->columns as $i => $column) { + $rows[] = [ + 'Table' => $table_definition->name, + 'Non_unique' => $index->type === 'INDEX' ? 1 : 0, + 'Key_name' => $name, + 'Seq_in_index' => 1 + (int) $i, + 'Column_name' => $column, + // because Index does not have "direction" (in the $cols of CreateIndex) + 'Collation' => null, + /* + * https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html + * because ANALYZE TABLE is not implemented + */ + 'Cardinality' => null, + // because Index does not have "length" (in the $cols of CreateIndex) + 'Sub_part' => null, + // because PACK_KEYS is not implemented + 'Packed' => null, + 'Null' => $table_definition->columns[$column]->isNullable() ? 'YES' : '', + // because Index does not have $mode (in the CreateIndex) + 'Index_type' => null, + // because DISABLE KEYS is not implemented + 'Comment' => '', + // because INDEX COMMENT is skipped in CREATE TABLE + 'Index_comment' => '' + ]; + } + } + $result = self::applyWhere($conn, $scope, $stmt->whereClause, new QueryResult($rows, $columns)); + return new QueryResult(array_merge($result->rows), $result->columns); + } +} diff --git a/src/Processor/UpdateProcessor.php b/src/Processor/UpdateProcessor.php index 1a0b4e92..88b10953 100644 --- a/src/Processor/UpdateProcessor.php +++ b/src/Processor/UpdateProcessor.php @@ -10,8 +10,6 @@ public static function process( ) : int { list($table_name, $database) = self::processUpdateClause($conn, $stmt); - $table_definition = $conn->getServer()->getTableDefinition($database, $table_name); - $existing_rows = $conn->getServer()->getTable($database, $table_name) ?: []; //Metrics::trackQuery(QueryType::UPDATE, $conn->getServer()->name, $table_name, $this->sql); diff --git a/src/Query/Expression/BinaryOperatorExpression.php b/src/Query/Expression/BinaryOperatorExpression.php index 13c45b32..a7cb1365 100644 --- a/src/Query/Expression/BinaryOperatorExpression.php +++ b/src/Query/Expression/BinaryOperatorExpression.php @@ -69,8 +69,8 @@ public function __construct( */ public function negate() { - $this->negated = true; - $this->negatedInt = 1; + $this->negated = !$this->negated; + $this->negatedInt = $this->negated ? 1 : 0; } /** diff --git a/src/Query/ShowIndexQuery.php b/src/Query/ShowIndexQuery.php new file mode 100644 index 00000000..1a3923bf --- /dev/null +++ b/src/Query/ShowIndexQuery.php @@ -0,0 +1,28 @@ +table = $table; + $this->sql = $sql; + } +} diff --git a/src/Schema/Column.php b/src/Schema/Column.php index 52ceb113..4247d08b 100644 --- a/src/Schema/Column.php +++ b/src/Schema/Column.php @@ -6,10 +6,31 @@ abstract class Column /** * @var bool */ - public $isNullable = true; + private $isNullable = true; + + public function isNullable() : bool + { + return $this->isNullable; + } + + /** + * @return static + */ + public function setNullable(bool $is_nullable) + { + $this->isNullable = $is_nullable; + return $this; + } + + public function getNullablePhp() : string + { + return '->setNullable(' . ($this->isNullable() ? 'true' : 'false') . ')'; + } /** * @return 'int'|'string'|'float'|'null' */ abstract public function getPhpType() : string; + + abstract public function getPhpCode() : string; } diff --git a/src/Schema/Column/Binary.php b/src/Schema/Column/Binary.php new file mode 100644 index 00000000..aea72996 --- /dev/null +++ b/src/Schema/Column/Binary.php @@ -0,0 +1,9 @@ +getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + return '(new \\' . static::class . '())' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/Char.php b/src/Schema/Column/Char.php index ea1963b1..c7797a01 100644 --- a/src/Schema/Column/Char.php +++ b/src/Schema/Column/Char.php @@ -6,9 +6,4 @@ class Char extends CharacterColumn implements StringColumn, Defaultable { use MySqlDefaultTrait; - - public function __construct(int $max_string_length, ?string $character_set = null, ?string $collation = null) - { - parent::__construct($max_string_length, $character_set, $collation); - } } diff --git a/src/Schema/Column/CharacterColumn.php b/src/Schema/Column/CharacterColumn.php index 96d206a4..93d286cd 100644 --- a/src/Schema/Column/CharacterColumn.php +++ b/src/Schema/Column/CharacterColumn.php @@ -14,12 +14,12 @@ abstract class CharacterColumn extends \Vimeo\MysqlEngine\Schema\Column protected $max_truncated_length; // used for in-memory columns /** - * @var string + * @var ?string */ protected $character_set; /** - * @var string + * @var ?string */ protected $collation; @@ -40,12 +40,12 @@ public function getMaxTruncatedStringLength() : ?int return $this->max_truncated_length; } - public function getCharacterSet() : string + public function getCharacterSet() : ?string { return $this->character_set; } - public function getCollation() : string + public function getCollation() : ?string { return $this->collation; } @@ -54,4 +54,27 @@ public function getPhpType() : string { return 'string'; } + + public function getPhpCode() : string + { + $default = ''; + + if ($this instanceof Defaultable && $this->hasDefault()) { + if ($this->getDefault() === null) { + $default = '->setDefault(null)'; + } else { + $default = '->setDefault(\'' . $this->getDefault() . '\')'; + } + } + + $args = [ + $this->max_string_length, + $this->character_set === null ? 'null' : "'{$this->character_set}'", + $this->collation === null ? 'null' : "'{$this->collation}'", + ]; + + return '(new \\' . static::class . '(' . implode(', ', $args) . '))' + . $default + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/Date.php b/src/Schema/Column/Date.php index 5737cd11..b71aee76 100644 --- a/src/Schema/Column/Date.php +++ b/src/Schema/Column/Date.php @@ -4,6 +4,7 @@ class Date extends \Vimeo\MysqlEngine\Schema\Column implements ChronologicalColumn, Defaultable { use MySqlDefaultTrait; + use EmptyConstructorTrait; public function getPhpType() : string { diff --git a/src/Schema/Column/DateTime.php b/src/Schema/Column/DateTime.php index b8845742..5b24342e 100644 --- a/src/Schema/Column/DateTime.php +++ b/src/Schema/Column/DateTime.php @@ -4,6 +4,7 @@ class DateTime extends \Vimeo\MysqlEngine\Schema\Column implements ChronologicalColumn, Defaultable { use MySqlDefaultTrait; + use EmptyConstructorTrait; public function getPhpType() : string { diff --git a/src/Schema/Column/DecimalPointColumn.php b/src/Schema/Column/DecimalPointColumn.php index 94bb0c21..1439ca7d 100644 --- a/src/Schema/Column/DecimalPointColumn.php +++ b/src/Schema/Column/DecimalPointColumn.php @@ -46,4 +46,24 @@ public function getPhpType() : string { return 'float'; } + + public function getPhpCode() : string + { + $default = ''; + + if ($this->hasDefault()) { + $default = '->setDefault(' + . ($this->getDefault() === null + ? 'null' + : '\'' . $this->getDefault() . '\'') + . ')'; + } + + return '(new \\' . static::class . '(' + . $this->precision + . ', ' . $this->scale + . '))' + . $default + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/Defaultable.php b/src/Schema/Column/Defaultable.php index 687e4b09..833e8ca1 100644 --- a/src/Schema/Column/Defaultable.php +++ b/src/Schema/Column/Defaultable.php @@ -3,7 +3,10 @@ interface Defaultable { - public function setDefault($mysql_default) : void; + /** + * @return static + */ + public function setDefault($mysql_default); public function hasDefault() : bool; diff --git a/src/Schema/Column/EmptyConstructorTrait.php b/src/Schema/Column/EmptyConstructorTrait.php new file mode 100644 index 00000000..41b30833 --- /dev/null +++ b/src/Schema/Column/EmptyConstructorTrait.php @@ -0,0 +1,22 @@ +hasDefault()) { + if ($this->getDefault() === null) { + $default = '->setDefault(null)'; + } else { + $default = '->setDefault(\'' . $this->getDefault() . '\')'; + } + } + + return '(new \\' . static::class . '())' + . $default + . $this->getNullablePhp(); + } +} diff --git a/src/Schema/Column/Enum.php b/src/Schema/Column/Enum.php index f9692d26..895aaf01 100644 --- a/src/Schema/Column/Enum.php +++ b/src/Schema/Column/Enum.php @@ -4,31 +4,5 @@ class Enum extends \Vimeo\MysqlEngine\Schema\Column implements Defaultable { use MySqlDefaultTrait; - - /** - * @var string[] - */ - protected $options = []; - - /** - * @param string[] $options - */ - public function __construct(array $options) - { - $this->options = $options; - } - - /** - * @return string[] - * @psalm-return array - */ - public function getOptions() : array - { - return $this->options; - } - - public function getPhpType() : string - { - return 'string'; - } + use HasOptionsTrait; } diff --git a/src/Schema/Column/HasOptionsTrait.php b/src/Schema/Column/HasOptionsTrait.php new file mode 100644 index 00000000..6a026c78 --- /dev/null +++ b/src/Schema/Column/HasOptionsTrait.php @@ -0,0 +1,44 @@ +options = $options; + } + + /** + * @return string[] + * @psalm-return array + */ + public function getOptions() : array + { + return $this->options; + } + + /** + * @return 'int'|'string'|'float'|'null' + */ + public function getPhpType() : string + { + return 'string'; + } + + public function getPhpCode() : string + { + $default = $this->getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + return '(new \\' . static::class . '([\'' . \implode('\', \'', $this->options) . '\']))' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } +} diff --git a/src/Schema/Column/IntegerColumn.php b/src/Schema/Column/IntegerColumn.php index 81745200..5c840204 100644 --- a/src/Schema/Column/IntegerColumn.php +++ b/src/Schema/Column/IntegerColumn.php @@ -3,9 +3,17 @@ interface IntegerColumn { - public function setDefault($mysql_default) : void; + /** @return static */ + public function setDefault($mysql_default); - public function autoIncrement() : void; + /** + * @return static + */ + public function autoIncrement(); public function isAutoIncrement() : bool; + + public function isUnsigned() : bool; + + public function getDisplayWidth() : int; } diff --git a/src/Schema/Column/IntegerColumnTrait.php b/src/Schema/Column/IntegerColumnTrait.php index fd189efb..655b2384 100644 --- a/src/Schema/Column/IntegerColumnTrait.php +++ b/src/Schema/Column/IntegerColumnTrait.php @@ -29,9 +29,13 @@ public function getDisplayWidth() : int return $this->integer_display_width; } - public function autoIncrement() : void + /** + * @return static + */ + public function autoIncrement() { $this->auto_increment = true; + return $this; } public function isAutoIncrement() : bool @@ -51,4 +55,25 @@ public function getPhpType() : string { return 'int'; } + + public function getPhpCode() : string + { + $default = ''; + + if ($this instanceof Defaultable && $this->hasDefault()) { + $default = '->setDefault(' + . ($this->getDefault() === null + ? 'null' + : '\'' . $this->getDefault() . '\'') + . ')'; + } + + return '(new \\' . static::class . '(' + . ($this->unsigned ? 'true' : 'false') + . ', ' . $this->integer_display_width + . '))' + . $default + . $this->getNullablePhp() + . ($this->isAutoIncrement() ? '->autoIncrement()' : ''); + } } diff --git a/src/Schema/Column/LongBlob.php b/src/Schema/Column/LongBlob.php index 1af211a6..3eb6d0cd 100644 --- a/src/Schema/Column/LongBlob.php +++ b/src/Schema/Column/LongBlob.php @@ -9,4 +9,13 @@ public function __construct() { parent::__construct(4294967295, 'binary', '_bin'); } + + public function getPhpCode() : string + { + $default = $this->getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + return '(new \\' . static::class . '())' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/LongText.php b/src/Schema/Column/LongText.php index 24fdee05..d3b97206 100644 --- a/src/Schema/Column/LongText.php +++ b/src/Schema/Column/LongText.php @@ -6,6 +6,7 @@ class LongText extends CharacterColumn implements StringColumn { use TextTrait; + use MySqlDefaultTrait; /** * @var string diff --git a/src/Schema/Column/MediumBlob.php b/src/Schema/Column/MediumBlob.php index ee5c077f..1e17f1b2 100644 --- a/src/Schema/Column/MediumBlob.php +++ b/src/Schema/Column/MediumBlob.php @@ -9,4 +9,13 @@ public function __construct() { parent::__construct(16777215, 'binary', '_bin'); } + + public function getPhpCode() : string + { + $default = $this->getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + return '(new \\' . static::class . '())' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/MediumText.php b/src/Schema/Column/MediumText.php index 2ae61c95..60fbf632 100644 --- a/src/Schema/Column/MediumText.php +++ b/src/Schema/Column/MediumText.php @@ -6,6 +6,7 @@ class MediumText extends CharacterColumn { use TextTrait; + use MySqlDefaultTrait; /** * @var string diff --git a/src/Schema/Column/MySqlDefaultTrait.php b/src/Schema/Column/MySqlDefaultTrait.php index 6902f65f..75071c01 100644 --- a/src/Schema/Column/MySqlDefaultTrait.php +++ b/src/Schema/Column/MySqlDefaultTrait.php @@ -13,10 +13,14 @@ trait MySqlDefaultTrait */ protected $has_mysql_default = false; - public function setDefault($mysql_default) : void + /** + * @return static + */ + public function setDefault($mysql_default) { $this->mysql_default = $mysql_default; $this->has_mysql_default = true; + return $this; } public function hasDefault() : bool diff --git a/src/Schema/Column/NullColumn.php b/src/Schema/Column/NullColumn.php index 4ce8bdbd..cb787982 100644 --- a/src/Schema/Column/NullColumn.php +++ b/src/Schema/Column/NullColumn.php @@ -6,6 +6,8 @@ */ class NullColumn extends \Vimeo\MysqlEngine\Schema\Column { + use EmptyConstructorTrait; + public function getPhpType() : string { return 'null'; diff --git a/src/Schema/Column/NumberColumn.php b/src/Schema/Column/NumberColumn.php index 5ddf5288..c4912477 100644 --- a/src/Schema/Column/NumberColumn.php +++ b/src/Schema/Column/NumberColumn.php @@ -13,5 +13,6 @@ public function getMaxValue(); */ public function getMinValue(); - public function setDefault($mysql_default) : void; + /** @return static */ + public function setDefault($mysql_default); } diff --git a/src/Schema/Column/Set.php b/src/Schema/Column/Set.php index 311bc397..ec5c64ce 100644 --- a/src/Schema/Column/Set.php +++ b/src/Schema/Column/Set.php @@ -4,31 +4,5 @@ class Set extends \Vimeo\MysqlEngine\Schema\Column implements Defaultable { use MySqlDefaultTrait; - - /** - * @var string[] - */ - protected $options = []; - - /** - * @param string[] $options - */ - public function __construct(array $options) - { - $this->options = $options; - } - - /** - * @return string[] - * @psalm-return array - */ - public function getOptions() : array - { - return $this->options; - } - - public function getPhpType() : string - { - return 'string'; - } + use HasOptionsTrait; } diff --git a/src/Schema/Column/Text.php b/src/Schema/Column/Text.php index f5f8e634..0e101085 100644 --- a/src/Schema/Column/Text.php +++ b/src/Schema/Column/Text.php @@ -6,6 +6,7 @@ class Text extends CharacterColumn implements StringColumn { use TextTrait; + use MySqlDefaultTrait; public function __construct(?string $character_set = null, ?string $collation = null) { diff --git a/src/Schema/Column/TextTrait.php b/src/Schema/Column/TextTrait.php index ff4173db..99ba9a27 100644 --- a/src/Schema/Column/TextTrait.php +++ b/src/Schema/Column/TextTrait.php @@ -5,4 +5,17 @@ trait TextTrait { + public function getPhpCode() : string + { + $default = $this->getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + $args = [ + $this->character_set === null ? 'null' : "'{$this->character_set}'", + $this->collation === null ? 'null' : "'{$this->collation}'", + ]; + + return '(new \\' . static::class . '(' . implode(', ', $args) . '))' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/Time.php b/src/Schema/Column/Time.php index d6760018..a6d39d26 100644 --- a/src/Schema/Column/Time.php +++ b/src/Schema/Column/Time.php @@ -4,6 +4,7 @@ class Time extends \Vimeo\MysqlEngine\Schema\Column implements ChronologicalColumn, Defaultable { use MySqlDefaultTrait; + use EmptyConstructorTrait; public function getPhpType() : string { diff --git a/src/Schema/Column/Timestamp.php b/src/Schema/Column/Timestamp.php index 788f174c..07e93018 100644 --- a/src/Schema/Column/Timestamp.php +++ b/src/Schema/Column/Timestamp.php @@ -4,6 +4,7 @@ class Timestamp extends \Vimeo\MysqlEngine\Schema\Column implements ChronologicalColumn, Defaultable { use MySqlDefaultTrait; + use EmptyConstructorTrait; public function getPhpType() : string { diff --git a/src/Schema/Column/TinyBlob.php b/src/Schema/Column/TinyBlob.php index 554b5e33..6794f27e 100644 --- a/src/Schema/Column/TinyBlob.php +++ b/src/Schema/Column/TinyBlob.php @@ -9,4 +9,13 @@ public function __construct() { parent::__construct(255, 'binary', '_bin'); } + + public function getPhpCode() : string + { + $default = $this->getDefault() !== null ? '\'' . $this->getDefault() . '\'' : 'null'; + + return '(new \\' . static::class . '())' + . ($this->hasDefault() ? '->setDefault(' . $default . ')' : '') + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/TinyText.php b/src/Schema/Column/TinyText.php index e850d4f1..25abc220 100644 --- a/src/Schema/Column/TinyText.php +++ b/src/Schema/Column/TinyText.php @@ -6,6 +6,7 @@ class TinyText extends CharacterColumn implements StringColumn { use TextTrait; + use MySqlDefaultTrait; public function __construct(?string $character_set = null, ?string $collation = null) { diff --git a/src/Schema/Column/Varbinary.php b/src/Schema/Column/Varbinary.php index 21113fc3..e889a856 100644 --- a/src/Schema/Column/Varbinary.php +++ b/src/Schema/Column/Varbinary.php @@ -9,4 +9,12 @@ public function __construct(int $max_string_length) { parent::__construct($max_string_length, 'binary', '_bin'); } + + public function getPhpCode() : string + { + return '(new \\' . static::class . '(' + . $this->max_string_length + . '))' + . $this->getNullablePhp(); + } } diff --git a/src/Schema/Column/Varchar.php b/src/Schema/Column/Varchar.php index 9c421ed8..1e22aece 100644 --- a/src/Schema/Column/Varchar.php +++ b/src/Schema/Column/Varchar.php @@ -7,9 +7,4 @@ class Varchar extends CharacterColumn implements StringColumn, Defaultable { use StringColumnTrait; use MySqlDefaultTrait; - - public function __construct(int $max_string_length, ?string $character_set = null, ?string $collation = null) - { - parent::__construct($max_string_length, $character_set, $collation); - } } diff --git a/src/Schema/Column/Year.php b/src/Schema/Column/Year.php index 6adbeda3..de263fd8 100644 --- a/src/Schema/Column/Year.php +++ b/src/Schema/Column/Year.php @@ -4,6 +4,7 @@ class Year extends \Vimeo\MysqlEngine\Schema\Column implements ChronologicalColumn, Defaultable { use MySqlDefaultTrait; + use EmptyConstructorTrait; public function getPhpType() : string { diff --git a/src/Schema/Index.php b/src/Schema/Index.php index 5565e4d7..98139578 100644 --- a/src/Schema/Index.php +++ b/src/Schema/Index.php @@ -9,12 +9,13 @@ class Index public $type; /** - * @var array + * @var array */ public $columns; /** * @param 'INDEX'|'UNIQUE'|'PRIMARY'|'FULLTEXT'|'SPATIAL' $type + * @param array $columns */ public function __construct( string $type, diff --git a/src/Schema/TableDefinition.php b/src/Schema/TableDefinition.php index 25c157ff..4f1725b0 100644 --- a/src/Schema/TableDefinition.php +++ b/src/Schema/TableDefinition.php @@ -29,7 +29,7 @@ class TableDefinition public $columns; /** - * @var array + * @var array */ public $primaryKeyColumns; @@ -44,7 +44,9 @@ class TableDefinition public $autoIncrementOffsets = []; /** + * @param array $columns * @param array $indexes + * @param array $primaryKeyColumns */ public function __construct( string $name, @@ -65,4 +67,34 @@ public function __construct( $this->indexes = $indexes; $this->autoIncrementOffsets = $autoIncrementOffsets; } + + public function getPhpCode() : string + { + $columns = []; + + foreach ($this->columns as $name => $column) { + $columns[] = '\'' . $name . '\' => ' . $column->getPhpCode(); + } + + $indexes = []; + + foreach ($this->indexes as $name => $index) { + $indexes[] = '\'' . $name . '\' => new \\' + . \get_class($index) . '(\'' . $index->type + . '\', [\'' . \implode('\', \'', $index->columns) . '\'])'; + } + + return 'new \\' . self::class . '(' + . '\'' . $this->name . '\'' + . ', \'' . $this->databaseName . '\'' + . ', [' . \implode(', ', $columns) . ']' + . ', \'' . $this->defaultCharacterSet . '\'' + . ', \'' . $this->defaultCollation . '\'' + . ', [' + . ($this->primaryKeyColumns ? '\'' . \implode('\', \'', $this->primaryKeyColumns) . '\'' : '') + . ']' + . ', [' . \implode(', ', $indexes) . ']' + . ', ' . \var_export($this->autoIncrementOffsets, true) + . ')'; + } } diff --git a/src/Server.php b/src/Server.php index 45a0c58d..0c276e1c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -98,7 +98,7 @@ public static function snapshot(string $name) : void public static function restoreSnapshot(string $name) : void { - if (!\array_key_exists($name, static::$snapshot_names)) { + if (!static::hasSnapshot($name)) { throw new Processor\ProcessorException("Snapshot {$name} not found, unable to restore"); } @@ -111,7 +111,7 @@ public static function restoreSnapshot(string $name) : void public static function deleteSnapshot(string $name) : bool { - if (!\array_key_exists($name, static::$snapshot_names)) { + if (!static::hasSnapshot($name)) { return false; } @@ -124,6 +124,11 @@ public static function deleteSnapshot(string $name) : bool return true; } + public static function hasSnapshot(string $name) : bool + { + return \array_key_exists($name, static::$snapshot_names); + } + protected function doSnapshot(string $name) : void { $this->snapshots[$name] = array_map( @@ -236,7 +241,7 @@ public function addAutoIncrementMinValue( string $table_name, string $column_name, int $value - ) : void { + ) : int { $table_definition = $this->getTableDefinition($database_name, $table_name); $table = $this->databases[$database_name][$table_name] ?? null; @@ -248,7 +253,7 @@ public function addAutoIncrementMinValue( $table = $this->databases[$database_name][$table_name] = new TableData(); } - $table->autoIncrementCursors[$column_name] = max( + return $table->autoIncrementCursors[$column_name] = max( $table->autoIncrementCursors[$column_name] ?? 0, $value ); diff --git a/tests/CreateTableParseTest.php b/tests/CreateTableParseTest.php new file mode 100644 index 00000000..52407068 --- /dev/null +++ b/tests/CreateTableParseTest.php @@ -0,0 +1,50 @@ +parse($query); + + $this->assertNotEmpty($create_queries); + + $table_defs = []; + + foreach ($create_queries as $create_query) { + $table = CreateProcessor::makeTableDefinition( + $create_query, + 'foo' + ); + $table_defs[$table->name] = $table; + + $new_table_php_code = $table->getPhpCode(); + + $new_table = eval('return ' . $new_table_php_code . ';'); + + $this->assertSame(\var_export($table, true), \var_export($new_table, true), + "The table definition for {$table->name} did not match the generated php version."); + } + + // specific parsing checks + $this->assertInstanceOf(TableDefinition::class, $table_defs['tweets']); + $this->assertEquals('utf8mb4', $table_defs['tweets']->columns['title']->getCharacterSet()); + $this->assertEquals('utf8mb4_unicode_ci', $table_defs['tweets']->columns['title']->getCollation()); + $this->assertEquals('utf8mb4', $table_defs['tweets']->columns['text']->getCharacterSet()); + $this->assertEquals('utf8mb4_unicode_ci', $table_defs['tweets']->columns['text']->getCollation()); + + $this->assertInstanceOf(TableDefinition::class, $table_defs['texts']); + $this->assertEquals('utf8mb4', $table_defs['texts']->columns['title_char_col']->getCharacterSet()); + $this->assertEquals('utf8mb4_unicode_ci', $table_defs['texts']->columns['title_char_col']->getCollation()); + $this->assertNull($table_defs['texts']->columns['title_col']->getCharacterSet()); + $this->assertEquals('utf8mb4_unicode_ci', $table_defs['texts']->columns['title_col']->getCollation()); + $this->assertNull($table_defs['texts']->columns['title']->getCharacterSet()); + $this->assertNull($table_defs['texts']->columns['title']->getCollation()); + } +} diff --git a/tests/DataIntegrityTest.php b/tests/DataIntegrityTest.php new file mode 100644 index 00000000..27143d4c --- /dev/null +++ b/tests/DataIntegrityTest.php @@ -0,0 +1,32 @@ +getPdo(), $dateTimeColumn, ''); + + $this->assertEquals('0000-00-00 00:00:00', $result); + } + + private static function getPdo(): FakePdoInterface + { + if (PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo('mysql:foo;dbname=test;'); + } + + return new FakePdo('mysql:foo;dbname=test;'); + } +} diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php index fbc08f55..1d5352dc 100644 --- a/tests/EndToEndTest.php +++ b/tests/EndToEndTest.php @@ -1,6 +1,8 @@ 1, + 0, + 1 + ) + ) + FROM + video_game_characters + WHERE + id > 100; + "; + + $pdo = self::getConnectionToFullDB(); + $query= $pdo->query($sql); + + $this->assertNull($query->fetchColumn()); + } + public function testSelectEmptyResults() { $pdo = self::getConnectionToFullDB(); @@ -32,6 +57,50 @@ public function testInvalidQuery() $this->assertSame([], $query->fetchAll(\PDO::FETCH_ASSOC)); } + public function testSelectWithDefaultFetchAssoc() + { + $pdo = self::getConnectionToFullDB(); + $pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC); + + $query = $pdo->prepare("SELECT id FROM `video_game_characters` WHERE `id` > :id ORDER BY `id` ASC"); + $query->bindValue(':id', 14); + $query->execute(); + + $this->assertSame( + [ + ['id' => '15'], + ['id' => '16'] + ], + $query->fetchAll() + ); + } + + public function testSelectFetchDefault() + { + if (!defined('PDO::FETCH_DEFAULT')) { + $this->markTestSkipped('PHP version does not support PDO::FETCH_DEFAULT'); + } + + $pdo = self::getConnectionToFullDB(); + + $query = $pdo->prepare("SELECT id FROM `video_game_characters` WHERE `id` > :id ORDER BY `id` ASC"); + $query->bindValue(':id', 14); + $query->execute(); + + $this->assertSame( + ['id' => '15', 0 => '15'], + $query->fetch(\PDO::FETCH_DEFAULT) + ); + + $this->assertSame( + [ + ['id' => '15', 0 => '15'], + ['id' => '16', 0 => '16'] + ], + $query->fetchAll(\PDO::FETCH_DEFAULT) + ); + } + public function testSelectFetchAssoc() { $pdo = self::getConnectionToFullDB(); @@ -146,6 +215,7 @@ public function testAliasWithType() $pdo = self::getConnectionToFullDB(false); $query = $pdo->prepare("SELECT SUM(`a`) FROM (SELECT `id` as `a` FROM `video_game_characters`) `foo`"); + $query->bindValue(':id', 14); $query->execute(); @@ -222,6 +292,27 @@ public function testLeftJoinWithCount() ); } + public function testLeftJoinSkipIndex() + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare( + "SELECT `id` + FROM `video_game_characters` FORCE INDEX (`PRIMARY`) + LEFT JOIN `character_tags` FORCE INDEX (`PRIMARY`) + ON `character_tags`.`character_id` = `video_game_characters`.`id` + LIMIT 1" + ); + $query->execute(); + + $this->assertSame( + [ + ['id' => 1] + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + public function testMaxValueAliasedToColumnName() { $pdo = self::getConnectionToFullDB(false); @@ -438,7 +529,8 @@ public function testDateArithhmetic() DATE_ADD(\'2020-02-29 12:31:00\', INTERVAL 1 YEAR) as `g`, DATE_ADD(\'2020-02-29 12:31:00\', INTERVAL 4 YEAR) as `h`, DATE_SUB(\'2020-03-30\', INTERVAL 1 MONTH) As `i`, - DATE_SUB(\'2020-03-01\', INTERVAL 1 MONTH) As `j`' + DATE_SUB(\'2020-03-01\', INTERVAL 1 MONTH) As `j`, + WEEKDAY(\'2021-04-29\') AS `k`' ); $query->execute(); @@ -455,6 +547,132 @@ public function testDateArithhmetic() 'h' => '2024-02-29 12:31:00', 'i' => '2020-02-29', 'j' => '2020-02-01', + 'k' => 4, + ]], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + /** + * Test various timestamp differences using the TIMESTAMPDIFF function. + * + * This method verifies the calculation of differences in seconds, minutes, + * hours, days, months, and years. + */ + public function testTimestampDiff(): void + { + // Get a PDO instance for MySQL. + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + // Prepare a single query with multiple TIMESTAMPDIFF calls. + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\') as `second_diff`, + TIMESTAMPDIFF(MINUTE, \'2020-01-01 00:00:00\', \'2020-01-01 01:30:00\') as `minute_diff`, + TIMESTAMPDIFF(HOUR, \'2020-01-02 00:00:00\', \'2020-01-01 00:00:00\') as `hour_diff`, + TIMESTAMPDIFF(DAY, \'2020-01-01\', \'2020-01-10\') as `day_diff`, + TIMESTAMPDIFF(MONTH, \'2019-01-01\', \'2020-04-01\') as `month_diff`, + TIMESTAMPDIFF(YEAR, \'2010-05-15\', \'2020-05-15\') as `year_diff`' + ); + + $query->execute(); + + $results = $query->fetchAll(\PDO::FETCH_ASSOC); + $castedResults = array_map(function($row) { + return array_map('intval', $row); + }, $results); + + $this->assertSame( + [[ + 'second_diff' => 100, + 'minute_diff' => 90, + 'hour_diff' => -24, + 'day_diff' => 9, + 'month_diff' => 15, + 'year_diff' => 10, + ]], + $castedResults + ); + } + + public function testTimestampDiffThrowsExceptionWithWrongArgumentCount(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('MySQL TIMESTAMPDIFF() function must be called with three arguments'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionIfFirstArgNotColumnExpression(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('MySQL TIMESTAMPDIFF() function should be called with a unit for interval'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(\'2020-01-01 00:00:00\', \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionWithWrongDates(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid datetime value passed to TIMESTAMPDIFF()'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(SECOND, \'2020-01-01 00:0140\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testTimestampDiffThrowsExceptionWithWrongInterval(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported unit \'CENTURY\' in TIMESTAMPDIFF()'); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare( + 'SELECT + TIMESTAMPDIFF(CENTURY, \'2020-01-01 00:01:40\', \'2020-01-01 00:01:40\')', + ); + + $query->execute(); + } + + public function testCurDateFunction() + { + $pdo = self::getPdo('mysql:foo'); + + $query = $pdo->prepare('SELECT CURDATE() AS date, CURRENT_DATE() AS date1'); + + $query->execute(); + $current_date = date('Y-m-d'); + + $this->assertSame( + [[ + 'date' => $current_date, + 'date1' => $current_date, ]], $query->fetchAll(\PDO::FETCH_ASSOC) ); @@ -519,6 +737,117 @@ public function testDecimalArithhmetic() ); } + public function testInetAtoN() + { + $pdo = self::getPdo('mysql:foo'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare("SELECT INET_ATON('255.255.255.255') AS a, INET_ATON('192.168.1.1') AS b, INET_ATON('127.0.0.1') AS c, INET_ATON('not an ip') AS d, INET_ATON(NULL) as e"); + $query->execute(); + $this->assertSame( + [ + [ + 'a' => 4294967295, + 'b' => 3232235777, + 'c' => 2130706433, + 'd' => NULL, + 'e' => NULL, + ], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + + public function testInetNtoA() + { + $pdo = self::getPdo('mysql:foo'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare("SELECT INET_NTOA(4294967295) AS a, INET_NTOA(3232235777) AS b, INET_NTOA(2130706433) AS c, INET_NTOA(NULL) as d, INET_NTOA('not a number') as e"); + $query->execute(); + + $this->assertSame( + [ + [ + 'a' => '255.255.255.255', + 'b' => '192.168.1.1', + 'c' => '127.0.0.1', + 'd' => NULL, + 'e' => NULL, + ], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + + public function testRound() + { + $pdo = self::getPdo('mysql:foo'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare('SELECT ROUND(3.141592) AS a, ROUND(3.141592, 2) AS b'); + + $query->execute(); + + $this->assertSame( + [ + [ + 'a' => 3, + 'b' => 3.14, + ], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + public function testCeil() + { + $pdo = self::getPdo('mysql:foo'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare('SELECT CEIL(3.1) AS a, CEILING(4.7) AS b, CEIL("abc") AS c, CEIL(5) AS d, CEIL("5.5") AS e'); + + $query->execute(); + + $this->assertSame( + [ + [ + 'a' => 4, + 'b' => 5, + 'c' => 0.0, + 'd' => 5, + 'e' => 6.0, + ], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + + public function testFloor() + { + $pdo = self::getPdo('mysql:foo'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $query = $pdo->prepare('SELECT FLOOR(3.1) AS a, FLOOR(4.7) AS b, FLOOR("abc") AS c, FLOOR(5) AS d, FLOOR("6.5") AS e'); + + $query->execute(); + + $this->assertSame( + [ + [ + 'a' => 3, + 'b' => 4, + 'c' => 0.0, + 'd' => 5, + 'e' => 6.0, + ], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + public function testIsInFullSubquery() { $pdo = self::getConnectionToFullDB(false); @@ -670,6 +999,36 @@ public function testLastInsertIdAfterSkippingAutoincrement() $this->assertSame("22", $pdo->lastInsertId()); } + public function testInsertWithNullAndEmpty() + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare( + "INSERT INTO `video_game_characters` + (`id`, `name`, `type`, `profession`, `console`, `is_alive`, `powerups`, `skills`, `created_on`) + VALUES + (20, 'wario','villain','plumber','nes','1','3','{\"magic\":0, \"speed\":0, \"strength\":0, \"weapons\":0}', NOW())" + ); + + $query->execute(); + + $query = $pdo->prepare( + 'SELECT `bio_en`, `bio_fr` + FROM `video_game_characters` + ORDER BY `id` DESC + LIMIT 1' + ); + + $query->execute(); + + $this->assertSame( + [ + ['bio_en' => '', 'bio_fr' => null], + ], + $query->fetchAll(\PDO::FETCH_ASSOC) + ); + } + public function testOrderBySecondDimensionAliased() { $pdo = self::getConnectionToFullDB(false); @@ -802,18 +1161,287 @@ public function testDistinctColumn() ); } - private static function getPdo(string $connection_string) : \PDO + public function testInsertOutOfRangeStrict() { + $pdo = self::getConnectionToFullDB(false, true); + + $query = $pdo->prepare( + "INSERT INTO `video_game_characters` + (`name`, `type`, `profession`, `console`, `is_alive`, `powerups`, `skills`, `created_on`) + VALUES + ('wario','villain','plumber','nes','1','-4','{\"magic\":0, \"speed\":0, \"strength\":0, \"weapons\":0}', NOW())" + ); + + $this->expectException(\Vimeo\MysqlEngine\Processor\InvalidValueException::class); + + $query->execute(); + } + + public function testInsertOutOfRangeLenient() + { + $pdo = self::getConnectionToFullDB(false, false); + + $query = $pdo->prepare( + "INSERT INTO `video_game_characters` + (`name`, `type`, `profession`, `console`, `is_alive`, `powerups`, `skills`, `created_on`) + VALUES + ('wario','villain','plumber','nes','1','-4','{\"magic\":0, \"speed\":0, \"strength\":0, \"weapons\":0}', NOW())" + ); + + $query->execute(); + } + + public function testFetchCount() + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare('SELECT count(*) FROM `video_game_characters`'); + + $query->execute(); + + $this->assertGreaterThan(0, $query->fetchColumn()); + + $this->assertFalse($query->fetchColumn()); + } + + public function dataProviderFetchCountForMissingColumn(): \Generator + { + foreach ([-1, 1, 100] as $idx) { + yield 'column: '.$idx => [$idx]; + } + } + + /** + * @dataProvider dataProviderFetchCountForMissingColumn + */ + public function testFetchCountForMissingColumn(int $columnIndex) + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare('SELECT `id` FROM `video_game_characters` LIMIT 1'); + + $query->execute(); + + $this->expectException(PDOException::class); + + $query->fetchColumn($columnIndex); + } + + public function testFetchWithColumnMode() + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare('SELECT `id` FROM `video_game_characters` WHERE id=2 LIMIT 1'); + + $query->execute(); + + self::assertEquals(2, $query->fetch(\PDO::FETCH_COLUMN)); + } + + public function dataProviderTruncateForms(): \Generator + { + foreach ([ + 'short' => 'TRUNCATE', + 'long' => 'TRUNCATE TABLE', + 'lower short' => 'truncate', + 'lower long' => 'truncate table', + ] as $formName => $formSql + ) { + foreach ([ + 'relative table' => '`video_game_characters`', + 'absolute table' => '`test`.`video_game_characters`', + ] as $position => $table + ) { + yield $formName . ' + ' . $position => [$formSql, $table]; + } + }; + } + + /** + * @param string $truncateForm + * @param string $table + * + * @dataProvider dataProviderTruncateForms + */ + public function testTruncate(string $truncateForm, string $table) + { + $pdo = self::getConnectionToFullDB(false); + + // check that table some data + $this->assertGreaterThan( + 0, + $pdo->query('SELECT count(*) FROM '.$table)->fetchColumn(0) + ); + $pdo->exec($truncateForm . ' '.$table); + $this->assertEquals( + 0, + $pdo->query('SELECT count(*) FROM '.$table)->fetchColumn(0) + ); + } + + public function dataProviderDropTable(): array + { + return [ + 'relative' => ['`video_game_characters`'], + 'absolute' => ['`test`.`video_game_characters`'], + ]; + } + /** + * @param string $table + * + * @dataProvider dataProviderDropTable + */ + public function testDropTable(string $table): void + { + $pdo = self::getConnectionToFullDB(false); + + // checking that table exists + $this->assertNotFalse( + $pdo->query('SHOW TABLES LIKE "video_game_characters"')->fetchColumn(0) + ); + + // remove table + $pdo->exec('DROP TABLE '.$table); + + // checking that table is missing + $this->assertFalse( + $pdo->query('SHOW TABLES LIKE "video_game_characters"')->fetchColumn(0) + ); + } + + public function testSelectNullableFields() + { + $pdo = self::getConnectionToFullDB(false); + + $query = $pdo->prepare("SELECT nullable_field, nullable_field_default_0 FROM `video_game_characters` WHERE `id` = 1"); + $query->execute(); + + $this->assertSame( + ['nullable_field' => null, 'nullable_field_default_0' => 0], + $query->fetch(\PDO::FETCH_ASSOC) + ); + + $query = $pdo->prepare("UPDATE `video_game_characters` SET `nullable_field_default_0` = NULL, `nullable_field` = NULL WHERE `id` = 1"); + $query->execute(); + + $query = $pdo->prepare("SELECT nullable_field, nullable_field_default_0 FROM `video_game_characters` WHERE `id` = 1"); + $query->execute(); + + $this->assertSame( + ['nullable_field' => null, 'nullable_field_default_0' => null], + $query->fetch(\PDO::FETCH_ASSOC) + ); + } + + public function testUpdate() + { + $pdo = self::getConnectionToFullDB(false); + + // before update + $query = $pdo->prepare("SELECT `type` FROM `video_game_characters` WHERE `id` = 3"); + $query->execute(); + $this->assertSame([['type' => 'hero']], $query->fetchAll(\PDO::FETCH_ASSOC)); + + // prepare update + $query = $pdo->prepare("UPDATE `video_game_characters` SET `type` = 'villain' WHERE `id` = 3 LIMIT 1"); + $query->execute(); + + // after update + $query = $pdo->prepare("SELECT `type` FROM `video_game_characters` WHERE `id` = 3"); + $query->execute(); + $this->assertSame([['type' => 'villain']], $query->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function testNegateOperationWithAnd() + { + // greater than + $pdo = self::getConnectionToFullDB(false); + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE `console` = :console AND NOT (`powerups` > :powerups)"); + $query->bindValue(':console', 'nes'); + $query->bindValue(':powerups', 3); + $query->execute(); + + $this->assertSame([['count' => 8]], $query->fetchAll(\PDO::FETCH_ASSOC)); + + // equals + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE `console` = :console AND NOT (`powerups` = :powerups)"); + $query->bindValue(':console', 'nes'); + $query->bindValue(':powerups', 0); + $query->execute(); + + $this->assertSame([['count' => 2]], $query->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function testNegateOperationWithOr() + { + // greater than + $pdo = self::getConnectionToFullDB(false); + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE `console` = :console OR NOT (`powerups` > :powerups)"); + $query->bindValue(':console', 'nes'); + $query->bindValue(':powerups', 3); + $query->execute(); + + $this->assertSame([['count' => 16]], $query->fetchAll(\PDO::FETCH_ASSOC)); + + // equals + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE `console` = :console OR NOT (`powerups` = :powerups)"); + $query->bindValue(':console', 'nes'); + $query->bindValue(':powerups', 0); + $query->execute(); + + $this->assertSame([['count' => 9]], $query->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function testNullEvaluation() + { + $pdo = self::getConnectionToFullDB(false); + + // case 1, where console value is null + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE (:console IS NULL AND `console` = 'gameboy') OR NOT (:console IS NULL)"); + $query->bindValue(':console', NULL); + $query->execute(); + $this->assertSame([['count' => 1]], $query->fetchAll(\PDO::FETCH_ASSOC)); + + // case 2, where console value is not null + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE (:console IS NULL AND `console` = 'gameboy') OR NOT (:console IS NULL)"); + $query->bindValue(':console', 'all'); + $query->execute(); + $this->assertSame([['count' => 16]], $query->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function testNullWithDoubleNegativeEvaluation() + { + $pdo = self::getConnectionToFullDB(false); + + //case 1, where console value is not null + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE (:console IS NOT NULL AND `console` = :console) OR NOT (:console IS NOT NULL)"); + $query->bindValue(':console', 'gameboy'); + $query->execute(); + + $this->assertSame([['count' => 1]], $query->fetchAll(\PDO::FETCH_ASSOC)); + + //case 1, where console value is null + $query = $pdo->prepare("SELECT COUNT(*) as 'count' FROM `video_game_characters` WHERE (:console IS NOT NULL AND `console` = :console) OR NOT (:console IS NOT NULL)"); + $query->bindValue(':console', NULL); + $query->execute(); + + $this->assertSame([['count' => 16]], $query->fetchAll(\PDO::FETCH_ASSOC)); + } + + private static function getPdo(string $connection_string, bool $strict_mode = false) : \PDO + { + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + if (\PHP_MAJOR_VERSION === 8) { - return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string); + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); } - return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string); + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); } - private static function getConnectionToFullDB(bool $emulate_prepares = true) : \PDO + private static function getConnectionToFullDB(bool $emulate_prepares = true, bool $strict_mode = false) : \PDO { - $pdo = self::getPdo('mysql:foo;dbname=test;'); + $pdo = self::getPdo('mysql:foo;dbname=test;', $strict_mode); $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, $emulate_prepares); @@ -827,4 +1455,125 @@ private static function getConnectionToFullDB(bool $emulate_prepares = true) : \ return $pdo; } + + /** + * @dataProvider leastArgumentsProvider + * @param array $args + * @param string|int|float|null $expected_value + */ + public function testLeast($args, $expected_value): void + { + // Get a PDO instance for MySQL. + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $args_str = implode(', ', array_map(function ($arg) { + return is_null($arg) ? 'null' : (string) $arg; + }, $args)); + + $query = $pdo->prepare(sprintf('SELECT LEAST(%s) as result', $args_str)); + $query->execute(); + + $result = $query->fetch(\PDO::FETCH_ASSOC); + $this->assertEquals( + ['result' => $expected_value], $result, + sprintf('Actual result is does not match the expected. Actual is: %s', print_r($result, true))); + } + + public function leastArgumentsProvider(): iterable + { + yield 'Should properly work with at least one \'null\' argument' => [ + 'args' => [1,2,null,42], + 'expected_value' => null + ]; + yield 'Should properly get the least integer argument' => [ + 'args' => [-1, 1,2,42], + 'expected_value' => '-1' + ]; + yield 'Should properly work with decimal argument' => [ + 'args' => [0.00, 0.1,2,42, -0.001], + 'expected_value' => '-0.001' + ]; + yield 'Should return proper precision if any argument is a float' => [ + 'args' => [1, 2.0001 , 42, 1.001], + 'expected_value' => '1.0000' + ]; + yield 'Should properly work with at least one string argument' => [ + 'args' => [1,2, "'null'", "'nulla'"], + 'expected_value' => '1' + ]; + yield 'Should properly work all string args' => [ + 'args' => ["'A'","'B'","'C'"], + 'expected_value' => 'A' + ]; + yield 'Should lexicographically compare #1' => [ + 'args' => ["'AA'","'AB'","'AC'"], + 'expected_value' => 'AA' + ]; + yield 'Should lexicographically compare #2' => [ + 'args' => ["'AA'","'AB'","'AC'", 1], + 'expected_value' => '1' + ]; + } + + /** @dataProvider leastWithExceptionProvider */ + public function testLeastThrowsExceptionWithWrongArgumentCount(array $args): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Incorrect parameter count in the call to native function \'LEAST\''); + + $pdo = self::getPdo('mysql:host=localhost;dbname=testdb'); + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, false); + + $args_str = implode(', ', array_map(fn ($arg) => strval($arg), $args)); + $query = $pdo->prepare(sprintf('SELECT LEAST(%s)', $args_str),); + + $query->execute(); + } + + public function leastWithExceptionProvider(): iterable + { + yield ['Should fail with single argument' => [1]]; + yield ['Should fail without any arguments' => []]; + } + + public function testNestedFunctions() + { + $pdo = self::getConnectionToFullDB(); + + $query = $pdo->prepare(" + SELECT + SUM( + TIMESTAMPDIFF( + SECOND, + CONVERT_TZ('2025-12-31 22:59:59', 'Europe/Kyiv', 'Europe/Kyiv'), + CONVERT_TZ('2025-12-31 23:59:59', 'Europe/Kyiv', 'Europe/Kyiv') + ) + ) + "); + $query->execute(); + + $this->assertSame(3600, (int)$query->fetchColumn()); + } + + public function testNestedFunctionsFromDB() + { + $pdo = self::getConnectionToFullDB(); + $count = $pdo->query("SELECT COUNT(*) FROM video_game_characters")->fetchColumn(); + + $query = $pdo->prepare(" + SELECT SUM( + TIMESTAMPDIFF( + SECOND, + CONVERT_TZ(`created_on`, 'Europe/Kyiv', 'Europe/Kyiv'), + CONVERT_TZ(`created_on` + INTERVAL 1 SECOND, 'Europe/Kyiv', 'Europe/Kyiv') + ) + ) + FROM `video_game_characters` + "); + + $query->execute(); + + $this->assertSame((int)$count, (int)$query->fetchColumn()); + } } diff --git a/tests/FakePdoTest.php b/tests/FakePdoTest.php new file mode 100644 index 00000000..fc31d782 --- /dev/null +++ b/tests/FakePdoTest.php @@ -0,0 +1,68 @@ +inTransaction()); + $pdo->beginTransaction(); + self::assertTrue($pdo->inTransaction()); + $pdo->commit(); + self::assertFalse($pdo->inTransaction()); + + + $pdo->beginTransaction(); + self::assertTrue($pdo->inTransaction()); + $pdo->rollBack(); + self::assertFalse($pdo->inTransaction()); + } + + /** + * @dataProvider quotationStringProvider + */ + public function testQuote(string $subject, string $expected): void + { + $pdo = self::getPdo('mysql:foo;dbname=test;'); + + self::assertSame($expected, $pdo->quote($subject)); + } + + /** + * @return array + */ + public function quotationStringProvider(): array + { + return [ + 'empty string' => ["", '\'\''], + 'a' => ["a", '\'a\''], + 'Kyoto in Chinese character' => ["京都", '\'京都\''], + 'null character' => ["\0", '\'\0\''], + 'includes newline(LF)'=> ["\na\nb", '\'\na\nb\''], + 'includes newline(CRLF)'=> ["\r\na\r\nb", '\'\r\na\r\nb\''], + 'includes quotations'=> ["\'a\"b", '\'\\\'a\\"b\''], + 'includes ascii 032(\Z)' => [implode(['a', chr(032), 'b']), '\'a\Zb\''], + ]; + } + + private static function getPdo(string $connection_string, bool $strict_mode = false) : \PDO + { + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); + } +} diff --git a/tests/FunctionEvaluatorTest.php b/tests/FunctionEvaluatorTest.php new file mode 100644 index 00000000..cc188dba --- /dev/null +++ b/tests/FunctionEvaluatorTest.php @@ -0,0 +1,163 @@ +prepare($sql); + $query->execute(); + /** @var array> $result */ + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + + if ($is_db_number) { + $this->assertNotEmpty($result); + $this->assertNotNull($result[0]['max']); + } else { + $this->assertSame([['max' => $expected]], $result); + } + } + + public static function maxValueProvider(): array + { + return [ + 'null when no rows' => [ + 'sql' => 'SELECT MAX(null) as `max` FROM `video_game_characters`', + 'expected' => null, + 'is_db_number' => false, + ], + 'max of scalar values' => [ + 'sql' => 'SELECT MAX(10) as `max` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, + ], + 'max in DB values' => [ + 'sql' => 'SELECT MAX(id) as `max` FROM `video_game_characters`', + 'expected' => '', + 'is_db_number' => true, + ], + ]; + } + + /** + * @dataProvider minValueProvider + */ + public function testSqlMin(string $sql, ?string $expected, bool $is_db_number) : void + { + $query = self::getConnectionToFullDB()->prepare($sql); + $query->execute(); + /** @var array> $result */ + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + + if ($is_db_number) { + $this->assertNotEmpty($result); + $this->assertNotNull($result[0]['min']); + } else { + $this->assertSame([['min' => $expected]], $result); + } + } + + public static function minValueProvider(): array + { + return [ + 'null when no rows' => [ + 'sql' => 'SELECT MIN(null) as `min` FROM `video_game_characters`', + 'expected' => null, + 'is_db_number' => false, + ], + 'min of scalar values' => [ + 'sql' => 'SELECT MIN(10) as `min` FROM `video_game_characters`', + 'expected' => '10', + 'is_db_number' => false, + ], + 'min in DB values' => [ + 'sql' => 'SELECT MIN(id) as `min` FROM `video_game_characters`', + 'expected' => '', + 'is_db_number' => true, + ], + ]; + } + + private static function getPdo(string $connection_string, bool $strict_mode = false) : \PDO + { + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); + } + + /** + * @dataProvider convertTzProvider + */ + public function testConvertTz(string $sql, ?string $expected) + { + $query = self::getConnectionToFullDB()->prepare($sql); + $query->execute(); + + $this->assertSame($expected, $query->fetch(\PDO::FETCH_COLUMN)); + } + + private static function convertTzProvider(): array + { + return [ + 'normal conversion' => [ + 'sql' => "SELECT CONVERT_TZ('2025-09-23 02:30:00', 'UTC', 'Europe/Kyiv');", + 'expected' => "2025-09-23 05:30:00", + ], + 'same tz' => [ + 'sql' => "SELECT CONVERT_TZ('2025-12-31 23:59:59', 'Europe/Kyiv', 'Europe/Kyiv');", + 'expected' => "2025-12-31 23:59:59", + ], + 'crossing DST' => [ + 'sql' => "SELECT CONVERT_TZ('2025-07-01 12:00:00', 'America/New_York', 'UTC');", + 'expected' => "2025-07-01 16:00:00", + ], + 'null date' => [ + 'sql' => "SELECT CONVERT_TZ(NULL, 'UTC', 'Europe/Kyiv');", + 'expected' => null, + ], + 'invalid timezone' => [ + 'sql' => "SELECT CONVERT_TZ('2025-09-23 02:30:00', 'Invalid/Zone', 'UTC');", + 'expected' => null, + ], + 'invalid date' => [ + 'sql' => "SELECT CONVERT_TZ('not-a-date', 'UTC', 'UTC');", + 'expected' => null, + ] + ]; + } + + private static function getConnectionToFullDB(bool $emulate_prepares = true, bool $strict_mode = false) : \PDO + { + $pdo = self::getPdo('mysql:foo;dbname=test;', $strict_mode); + + $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, $emulate_prepares); + + // create table + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/create_table.sql'))->execute(); + + // insertData + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_character_insert.sql'))->execute(); + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_enemy_insert.sql'))->execute(); + $pdo->prepare(file_get_contents(__DIR__ . '/fixtures/bulk_tag_insert.sql'))->execute(); + + return $pdo; + } +} \ No newline at end of file diff --git a/tests/ParameterBindTest.php b/tests/ParameterBindTest.php new file mode 100644 index 00000000..a882ba8b --- /dev/null +++ b/tests/ParameterBindTest.php @@ -0,0 +1,63 @@ + ['SELECT ?', 1], + ':field' => ['SELECT :field', ':field'], + 'field' => ['SELECT :field', 'field'], + ]; + } + + /** + * @param string $sql + * @param string|int $key + * @dataProvider dataSqlWithParameter + */ + public function testBindValue(string $sql, $key): void + { + $pdo = self::getPdo('mysql:foo;dbname=test;'); + + $statement = $pdo->prepare($sql); + $statement->bindValue($key, 999); + $statement->execute(); + + self::assertEquals(999, $statement->fetchColumn(0)); + } + + /** + * @param string $sql + * @param string|int $key + * @dataProvider dataSqlWithParameter + */ + public function testBindParam(string $sql, $key): void + { + $pdo = self::getPdo('mysql:foo;dbname=test;'); + + $var = 1000; + $statement = $pdo->prepare($sql); + $statement->bindParam($key, $var); + + $var = 10; + $statement->execute(); + self::assertEquals(10, $statement->fetchColumn(0)); + } + + private static function getPdo(string $connection_string): \PDO + { + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', []); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', []); + } +} diff --git a/tests/SelectProcessorTest.php b/tests/SelectProcessorTest.php index 9eada5ca..bb86ac8a 100644 --- a/tests/SelectProcessorTest.php +++ b/tests/SelectProcessorTest.php @@ -16,7 +16,7 @@ public function testCast() $this->assertInstanceOf(SelectQuery::class, $select_query); - $conn = new \Vimeo\MysqlEngine\Php8\FakePdo('mysql:foo'); + $conn = self::getPdo('mysql:foo'); $this->assertSame( [['a' => 3]], @@ -37,7 +37,7 @@ public function testSubqueryCalculation() $this->assertInstanceOf(SelectQuery::class, $select_query); - $conn = new \Vimeo\MysqlEngine\Php8\FakePdo('mysql:foo'); + $conn = self::getPdo('mysql:foo'); $this->assertSame( [['a' => 5]], @@ -58,7 +58,7 @@ public function testStringDecimalIntComparison() $this->assertInstanceOf(SelectQuery::class, $select_query); - $conn = new \Vimeo\MysqlEngine\Php8\FakePdo('mysql:foo'); + $conn = self::getPdo('mysql:foo'); $this->assertSame( [['a' => 0]], @@ -70,4 +70,13 @@ public function testStringDecimalIntComparison() )->rows ); } + + private static function getPdo(string $connection_string) : \PDO + { + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string); + } } diff --git a/tests/ShowIndexParseTest.php b/tests/ShowIndexParseTest.php new file mode 100644 index 00000000..d4ad3a6f --- /dev/null +++ b/tests/ShowIndexParseTest.php @@ -0,0 +1,62 @@ +assertInstanceOf(ShowIndexQuery::class, $show_query); + $this->assertSame('foo', $show_query->table); + } + + public function testIndexesParse() + { + $query = 'SHOW INDEXES FROM `foo`'; + + $show_query = SQLParser::parse($query); + + $this->assertInstanceOf(ShowIndexQuery::class, $show_query); + $this->assertSame('foo', $show_query->table); + } + + public function testKeysParse() + { + $query = 'SHOW KEYS FROM `foo`'; + + $show_query = SQLParser::parse($query); + + $this->assertInstanceOf(ShowIndexQuery::class, $show_query); + $this->assertSame('foo', $show_query->table); + } + + public function testParseInvalid() + { + $query = 'SHOW INDEX FROM `foo'; + + $this->expectException(\Vimeo\MysqlEngine\Parser\LexerException::class); + + $select_query = \Vimeo\MysqlEngine\Parser\SQLParser::parse($query); + + $this->assertInstanceOf(SelectQuery::class, $select_query); + } + + public function testWhereParse() + { + $query = "SHOW INDEX FROM `foo` WHERE `Key_name` = 'PRIMARY'"; + + $show_query = SQLParser::parse($query); + + $this->assertInstanceOf(ShowIndexQuery::class, $show_query); + $this->assertSame('foo', $show_query->table); + } +} diff --git a/tests/TruncateParseTest.php b/tests/TruncateParseTest.php new file mode 100644 index 00000000..7d3a0075 --- /dev/null +++ b/tests/TruncateParseTest.php @@ -0,0 +1,126 @@ + [ + [], + ], + 'associative array (equals to empty)' => [ + [ + 'a' => new Token(TokenType::CLAUSE, 'SELECT', 'SELECT', 0), + 'b' => new Token(TokenType::IDENTIFIER, 'table_name', 'table_name', 0), + ], + ], + 'select' => [ + [ + new Token(TokenType::CLAUSE, 'SELECT', 'SELECT', 0), + ], + ], + 'invalid token type' => [ + [ + new Token(TokenType::CLAUSE, 'TRUNCATE', 'TRUNCATE', 0), + new Token(TokenType::RESERVED, 'TRUNCATE', 'TRUNCATE', 0), + ], + ], + ]; + } + + /** + * @param array $tokens + * + * @dataProvider dataProviderInvalidTokens + */ + public function testInvalidTokens(array $tokens): void + { + $parser = new TruncateParser($tokens, ''); + $this->expectException(ParserException::class); + $parser->parse(); + } + + /** + * @return array + */ + public function dataProviderQueryForms(): array + { + return [ + 'short' => ['TRUNCATE %s'], + 'long' => ['TRUNCATE TABLE %s'], + 'lower short' => ['truncate %s'], + 'lower long' => ['truncate table %s'], + ]; + } + + /** + * @param string $form + * + * @dataProvider dataProviderQueryForms + */ + public function testTruncate(string $form): void + { + $query = sprintf($form, '`table_name`'); + + $truncate_query = SQLParser::parse($query); + + self::assertInstanceOf(TruncateQuery::class, $truncate_query); + } + + /** + * @param string $form + * + * @dataProvider dataProviderQueryForms + */ + public function testTruncateTable(string $form): void + { + $query = sprintf($form, '`table_name`'); + + $truncate_query = SQLParser::parse($query); + + self::assertInstanceOf(TruncateQuery::class, $truncate_query); + } + + /** + * @param string $query + * + * @dataProvider dataProviderInvalidStatements + */ + public function testInvalidStatement(string $query): void + { + $this->expectException(ParserException::class); + + SQLParser::parse($query); + } + + public function dataProviderInvalidStatements(): \Generator + { + foreach ([ + // this check uses anything that is NOT a identifier + // will result to `truncate table select` + 'invalid identifier' => 'select', + + // missing identifier checks + // will result to `truncate table` + 'missing identifier' => '', + ] as $case => $identifier + ) { + foreach ($this->dataProviderQueryForms() as $formName => $sql) { + yield $case . ': ' . $formName => [sprintf(array_shift($sql), $identifier)]; + } + } + } +} diff --git a/tests/VariableEvaluatorTest.php b/tests/VariableEvaluatorTest.php new file mode 100644 index 00000000..adb33543 --- /dev/null +++ b/tests/VariableEvaluatorTest.php @@ -0,0 +1,66 @@ +getPdo() + ->prepare('SELECT @@session.time_zone'); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(date_default_timezone_get(), $result); + } + + public function testNotImplementedGlobalVariable(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("The SQL code SELECT @@collation_server; could not be evaluated"); + + $query = $this->getPdo() + ->prepare('SELECT @@collation_server;'); + $query->execute(); + $result = $query->fetch(\PDO::FETCH_COLUMN); + + $this->assertSame(date_default_timezone_get(), $result); + } + + public function testVariable(): void + { + $sql = " + SELECT (@var := @var + 2) AS `counter` + FROM (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) AS `virtual_rows` + CROSS JOIN (SELECT @var := 0) AS `vars`; + "; + + $query = $this->getPdo() + ->prepare($sql); + $query->execute(); + $result = $query->fetchAll(\PDO::FETCH_ASSOC); + $counters = array_map('intval', array_column($result, 'counter')); + $this->assertSame([2,4,6], $counters); + } + + private function getPdo(bool $strict_mode = false): \PDO + { + $connection_string = 'mysql:foo;dbname=test;'; + $options = $strict_mode ? [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET sql_mode="STRICT_ALL_TABLES"'] : []; + + if (\PHP_MAJOR_VERSION === 8) { + return new \Vimeo\MysqlEngine\Php8\FakePdo($connection_string, '', '', $options); + } + + return new \Vimeo\MysqlEngine\Php7\FakePdo($connection_string, '', '', $options); + } +} diff --git a/tests/fixtures/bulk_character_insert.sql b/tests/fixtures/bulk_character_insert.sql index 1cec856c..c5dec12c 100644 --- a/tests/fixtures/bulk_character_insert.sql +++ b/tests/fixtures/bulk_character_insert.sql @@ -1,19 +1,19 @@ INSERT INTO `video_game_characters` - (`id`, `name`, `type`, `profession`, `console`, `is_alive`, `powerups`, `skills`, `created_on`) + (`id`, `name`, `bio_en`, `type`, `profession`, `console`, `is_alive`, `powerups`, `skills`, `created_on`) VALUES - ('1','mario','hero','plumber','nes','1','3','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), - ('2','luigi','hero','plumber','nes','1','5','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), - ('3','sonic','hero','hedgehog','sega genesis','1','0','{"magic":0, "speed":1, "strength":0, "weapons":0}', NOW()), - ('4','earthworm jim','hero','earthworm','sega genesis','0','0','{"magic":0, "speed":0, "strength":1, "weapons":1}', NOW()), - ('5','bowser','villain','evil dinosaur','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), - ('6','dr. robotnic','villain','evil doctor','sega genesis','1','0','{"magic":0, "speed":0, "strength":1, "weapons":1}', NOW()), - ('7','lakitu','villain','throwing shit from clouds','nes','0','0','{"magic":0, "speed":0, "strength":0, "weapons":1}', NOW()), - ('8','donkey kong','hero','monkey','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), - ('9','pikachu','hero','pokemon','gameboy','1','0','{"magic":1, "speed":1, "strength":0, "weapons":0}', NOW()), - ('10','princess peach','hero','princess','nes','1','0','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), - ('11','chain chomp','villain','evil chain dude','nes','0','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), - ('12','little mac','hero','boxer','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), - ('13','pac man','hero','yellow circle','atari','1','0','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), - ('14','yoshi','hero','dinosaur','super nintendo','1','0','{"magic":0, "speed":1, "strength":0, "weapons":0}', NOW()), - ('15','link','hero','not sure','nes','1','0','{"magic":1, "speed":0, "strength":0, "weapons":1}', NOW()), - ('16','dude','hero','sure','sega genesis','1','0','{"magic":1, "speed":0, "strength":0, "weapons":1}', NOW()) + ('1','mario', 'It’sa him', 'hero','plumber','nes','1','3','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), + ('2','luigi', 'It’sa his brother', 'hero','plumber','nes','1','5','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), + ('3','sonic', 'the hedgehog', 'hero','hedgehog','sega genesis','1','0','{"magic":0, "speed":1, "strength":0, "weapons":0}', NOW()), + ('4','earthworm jim', 'the worm that shoots', 'hero','earthworm','sega genesis','0','0','{"magic":0, "speed":0, "strength":1, "weapons":1}', NOW()), + ('5','bowser', 'he’s bad', 'villain','evil dinosaur','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), + ('6','dr. robotnic', 'not as bad', 'villain','evil doctor','sega genesis','1','0','{"magic":0, "speed":0, "strength":1, "weapons":1}', NOW()), + ('7','lakitu', 'who?', 'villain','throwing shit from clouds','nes','0','0','{"magic":0, "speed":0, "strength":0, "weapons":1}', NOW()), + ('8','donkey kong', 'Planet of the ape', 'hero','monkey','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), + ('9','pikachu', 'pika-who?', 'hero','pokemon','gameboy','1','0','{"magic":1, "speed":1, "strength":0, "weapons":0}', NOW()), + ('10','princess peach', 'Im peach', 'hero','princess','nes','1','0','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), + ('11','chain chomp', 'misunderstood', 'villain','evil chain dude','nes','0','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), + ('12','little mac', 'Big mac’s little brother', 'hero','boxer','nes','1','0','{"magic":0, "speed":0, "strength":1, "weapons":0}', NOW()), + ('13','pac man', 'Ms Pac Man’s worse three-quarters', 'hero','yellow circle','atari','1','0','{"magic":0, "speed":0, "strength":0, "weapons":0}', NOW()), + ('14','yoshi', 'Green machine', 'hero','dinosaur','super nintendo','1','0','{"magic":0, "speed":1, "strength":0, "weapons":0}', NOW()), + ('15','link', 'Zelda? I hardly knew her!', 'hero','not sure','nes','1','0','{"magic":1, "speed":0, "strength":0, "weapons":1}', NOW()), + ('16','dude', 'Duuuuuude', 'hero','sure','sega genesis','1','0','{"magic":1, "speed":0, "strength":0, "weapons":1}', NOW()) diff --git a/tests/fixtures/create_table.sql b/tests/fixtures/create_table.sql index b7f5eafb..de2ff8a4 100644 --- a/tests/fixtures/create_table.sql +++ b/tests/fixtures/create_table.sql @@ -1,16 +1,19 @@ CREATE TABLE `video_game_characters` ( - `id` int(10) NOT NULL AUTO_INCREMENT, - `name` varchar(16) NOT NULL DEFAULT '', + `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` varchar(32) NOT NULL DEFAULT '', + `bio_en` text NOT NULL, + `bio_fr` text, `type` enum('hero', 'villain') NOT NULL DEFAULT 'hero', `profession` varchar(30) CHARACTER SET utf8mb4 DEFAULT NULL, `console` enum('atari', 'gameboy', 'nes', 'pc', 'sega genesis', 'super nintendo') DEFAULT NULL, `is_alive` tinyint(3) NOT NULL DEFAULT '1', - `powerups` tinyint(3) NOT NULL DEFAULT '0', + `powerups` tinyint(3) UNSIGNED NOT NULL DEFAULT '0', `skills` varchar(1000) NOT NULL DEFAULT '', `nullable_field` tinyint(3) DEFAULT NULL, + `nullable_field_default_0` tinyint(3) DEFAULT '0', `some_float` float DEFAULT '0.00', - `total_games` int(11) NOT NULL DEFAULT '0', - `lives` int(11) unsigned NOT NULL DEFAULT '0', + `total_games` int(11) UNSIGNED NOT NULL DEFAULT '0', + `lives` int(11) UNSIGNED NOT NULL DEFAULT '0', `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `modified_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted_on` timestamp NULL DEFAULT NULL, @@ -38,12 +41,44 @@ CREATE TABLE `character_tags` ( PRIMARY KEY (`id`), KEY `character_id` (`character_id`) ) -ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; +ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `transactions` ( `id` int(10) NOT NULL AUTO_INCREMENT, `total` DECIMAL(12, 2) NOT NULL, `tax` DECIMAL(12, 2) NOT NULL, + `other_tax` DECIMAL(12, 2) DEFAULT NULL, + PRIMARY KEY (`id`) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `orders` +( + `id` INTEGER(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INTEGER(11) UNSIGNED, + `price` INTEGER(11) UNSIGNED NOT NULL DEFAULT 0, + `created_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `tweets` ( + `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `title` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + PRIMARY KEY (`id`) +) +ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +CREATE TABLE `texts` ( + `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `title_char_col` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `title_col` varchar(256) COLLATE utf8mb4_unicode_ci, + `title` varchar(256) + `text_char_col` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `text_col` text COLLATE utf8mb4_unicode_ci, + `text` text, PRIMARY KEY (`id`) ) -ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; \ No newline at end of file +ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;