diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 000000000..25adc9520 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,2 @@ +github: dg +custom: "https://nette.org/donate" diff --git a/composer.json b/composer.json index 46ebba32d..4380a83d9 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.1-dev" } } } diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 1272a1259..5b0737b6e 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -38,6 +38,7 @@ public function getConfigSchema(): Nette\Schema\Schema 'options' => Expect::array(), 'debugger' => Expect::bool(true), 'explain' => Expect::bool(true), + 'connectionTimeout' => Expect::int()->min(0), 'reflection' => Expect::string(), // BC 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions 'autowired' => Expect::bool(), @@ -75,6 +76,10 @@ private function setupDatabase(\stdClass $config, string $name): void } } + if ($config->connectionTimeout !== null) { + $config->options[\PDO::ATTR_TIMEOUT] = $config->connectionTimeout; + } + $connection = $builder->addDefinition($this->prefix("$name.connection")) ->setFactory(Nette\Database\Connection::class, [$config->dsn, $config->user, $config->password, $config->options]) ->setAutowired($config->autowired); diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 5bd58bab8..6d6392966 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -34,6 +34,9 @@ class ConnectionPanel implements Tracy\IBarPanel /** @var bool */ public $disabled = false; + /** @var float */ + public $performanceScale = 0.25; + /** @var float logged time */ private $totalTime = 0; @@ -46,11 +49,11 @@ class ConnectionPanel implements Tracy\IBarPanel public function __construct(Connection $connection) { - $connection->onQuery[] = [$this, 'logQuery']; + $connection->onQuery[] = \Closure::fromCallable([$this, 'logQuery']); } - public function logQuery(Connection $connection, $result): void + private function logQuery(Connection $connection, $result): void { if ($this->disabled) { return; @@ -138,6 +141,7 @@ public function getPanel(): ?string $name = $this->name; $count = $this->count; $totalTime = $this->totalTime; + $performanceScale = $this->performanceScale; require __DIR__ . '/templates/ConnectionPanel.panel.phtml'; }); } diff --git a/src/Bridges/DatabaseTracy/templates/ConnectionPanel.panel.phtml b/src/Bridges/DatabaseTracy/templates/ConnectionPanel.panel.phtml index ef83eddf9..a31f7b9cb 100644 --- a/src/Bridges/DatabaseTracy/templates/ConnectionPanel.panel.phtml +++ b/src/Bridges/DatabaseTracy/templates/ConnectionPanel.panel.phtml @@ -29,7 +29,7 @@ use Tracy\Helpers; [$connection, $sql, $params, $source, $time, $rows, $error, $command, $explain] = $query; ?> - + ERROR diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 18ffe26ac..7b589983e 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -152,6 +152,23 @@ public function rollBack(): void } + /** + * @return mixed + */ + public function transaction(callable $callback) + { + $this->beginTransaction(); + try { + $res = $callback(); + } catch (\Throwable $e) { + $this->rollBack(); + throw $e; + } + $this->commit(); + return $res; + } + + /** * Generates and executes SQL query. */ diff --git a/src/Database/Context.php b/src/Database/Context.php index 280bd16f5..9cf68c1b4 100644 --- a/src/Database/Context.php +++ b/src/Database/Context.php @@ -60,6 +60,15 @@ public function rollBack(): void } + /** + * @return mixed + */ + public function transaction(callable $callback) + { + return $this->connection->transaction($callback); + } + + public function getInsertId(string $sequence = null): string { return $this->connection->getInsertId($sequence); diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 0517f8dcc..1fbdbd422 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -19,18 +19,26 @@ class SqlPreprocessor { use Nette\SmartObject; - /** @var array */ - private const MODE_LIST = ['and', 'or', 'set', 'values', 'order']; + private const + MODE_AND = 'and', // (key [operator] value) AND ... + MODE_OR = 'or', // (key [operator] value) OR ... + MODE_SET = 'set', // key=value, key=value, ... + MODE_VALUES = 'values', // (key, key, ...) VALUES (value, value, ...) + MODE_ORDER = 'order', // key, key DESC, ... + MODE_LIST = 'list', // value, value, ... | (tuple), (tuple), ... + MODE_AUTO = 'auto'; // arrayMode for arrays + + private const MODES = [self::MODE_AND, self::MODE_OR, self::MODE_SET, self::MODE_VALUES, self::MODE_ORDER, self::MODE_LIST]; private const ARRAY_MODES = [ - 'INSERT' => 'values', - 'REPLACE' => 'values', - 'KEY UPDATE' => 'set', - 'SET' => 'set', - 'WHERE' => 'and', - 'HAVING' => 'and', - 'ORDER BY' => 'order', - 'GROUP BY' => 'order', + 'INSERT' => self::MODE_VALUES, + 'REPLACE' => self::MODE_VALUES, + 'KEY UPDATE' => self::MODE_SET, + 'SET' => self::MODE_SET, + 'WHERE' => self::MODE_AND, + 'HAVING' => self::MODE_AND, + 'ORDER BY' => self::MODE_ORDER, + 'GROUP BY' => self::MODE_ORDER, ]; private const PARAMETRIC_COMMANDS = [ @@ -60,7 +68,7 @@ class SqlPreprocessor /** @var bool */ private $useParams; - /** @var string|null values|set|and|order */ + /** @var string|null values|set|and|order|items */ private $arrayMode; @@ -88,16 +96,15 @@ public function process(array $params, bool $useParams = false): array $param = $params[$this->counter++]; if (($this->counter === 2 && count($params) === 2) || !is_scalar($param)) { - $res[] = $this->formatValue($param, 'auto'); - $this->arrayMode = null; + $res[] = $this->formatValue($param, self::MODE_AUTO); } elseif (is_string($param) && $this->counter > $prev + 1) { $prev = $this->counter; $this->arrayMode = null; $res[] = Nette\Utils\Strings::replace( $param, - '~\'[^\']*+\'|"[^"]*+"|\?[a-z]*|^\s*+(?:\(?\s*SELECT|INSERT|UPDATE|DELETE|REPLACE|EXPLAIN)\b|\b(?:SET|WHERE|HAVING|ORDER BY|GROUP BY|KEY UPDATE)(?=\s*$|\s*\?)|/\*.*?\*/|--[^\n]*~Dsi', - [$this, 'callback'] + '~\'[^\']*+\'|"[^"]*+"|\?[a-z]*|^\s*+(?:\(?\s*SELECT|INSERT|UPDATE|DELETE|REPLACE|EXPLAIN)\b|\b(?:SET|WHERE|HAVING|ORDER BY|GROUP BY|KEY UPDATE)(?=\s*$|\s*\?)|\bIN\s+\(\?\)|/\*.*?\*/|--[^\n]*~Dsi', + \Closure::fromCallable([$this, 'callback']) ); } else { throw new Nette\InvalidArgumentException('There are more parameters than placeholders.'); @@ -108,19 +115,24 @@ public function process(array $params, bool $useParams = false): array } - /** @internal */ - public function callback(array $m): string + private function callback(array $m): string { $m = $m[0]; if ($m[0] === '?') { // placeholder if ($this->counter >= count($this->params)) { throw new Nette\InvalidArgumentException('There are more placeholders than passed parameters.'); } - return $this->formatValue($this->params[$this->counter++], substr($m, 1) ?: 'auto'); + return $this->formatValue($this->params[$this->counter++], substr($m, 1) ?: self::MODE_AUTO); } elseif ($m[0] === "'" || $m[0] === '"' || $m[0] === '/' || $m[0] === '-') { // string or comment return $m; + } elseif (substr($m, -3) === '(?)') { // IN (?) + if ($this->counter >= count($this->params)) { + throw new Nette\InvalidArgumentException('There are more placeholders than passed parameters.'); + } + return 'IN (' . $this->formatValue($this->params[$this->counter++], self::MODE_LIST) . ')'; + } else { // command $cmd = ltrim(strtoupper($m), "\t\n\r ("); $this->arrayMode = self::ARRAY_MODES[$cmd] ?? null; @@ -132,7 +144,7 @@ public function callback(array $m): string private function formatValue($value, string $mode = null): string { - if (!$mode || $mode === 'auto') { + if (!$mode || $mode === self::MODE_AUTO) { if (is_scalar($value) || is_resource($value)) { if ($this->useParams) { $this->remaining[] = $value; @@ -187,16 +199,16 @@ private function formatValue($value, string $mode = null): string $value = iterator_to_array($value); } - if (is_array($value)) { + if ($mode && is_array($value)) { $vx = $kx = []; - if ($mode === 'auto') { - $mode = $this->arrayMode; + if ($mode === self::MODE_AUTO) { + $mode = $this->arrayMode ?? self::MODE_LIST; } - if ($mode === 'values') { // (key, key, ...) VALUES (value, value, ...) + if ($mode === self::MODE_VALUES) { // (key, key, ...) VALUES (value, value, ...) if (array_key_exists(0, $value)) { // multi-insert if (!is_array($value[0]) && !$value[0] instanceof Row) { - throw new Nette\InvalidArgumentException('Automaticaly detected multi-insert, but values aren\'t array. If you need try to change mode like "?[' . implode('|', self::MODE_LIST) . ']". Mode "' . $mode . '" was used.'); + throw new Nette\InvalidArgumentException('Automaticaly detected multi-insert, but values aren\'t array. If you need try to change mode like "?[' . implode('|', self::MODES) . ']". Mode "' . $mode . '" was used.'); } foreach ($value[0] as $k => $v) { $kx[] = $this->delimite($k); @@ -219,10 +231,10 @@ private function formatValue($value, string $mode = null): string } return '(' . implode(', ', $kx) . ') VALUES (' . implode(', ', $vx) . ')'; - } elseif (!$mode || $mode === 'set') { + } elseif ($mode === self::MODE_SET) { foreach ($value as $k => $v) { - if (is_int($k)) { // value, value, ... OR (1, 2), (3, 4) - $vx[] = is_array($v) ? '(' . $this->formatValue($v) . ')' : $this->formatValue($v); + if (is_int($k)) { // value, value, ... + $vx[] = $this->formatValue($v); } elseif (substr($k, -1) === '=') { // key+=value, key-=value, ... $k2 = $this->delimite(substr($k, 0, -2)); $vx[] = $k2 . '=' . $k2 . ' ' . substr($k, -2, 1) . ' ' . $this->formatValue($v); @@ -232,7 +244,13 @@ private function formatValue($value, string $mode = null): string } return implode(', ', $vx); - } elseif ($mode === 'and' || $mode === 'or') { // (key [operator] value) AND ... + } elseif ($mode === self::MODE_LIST) { // value, value, ... | (tuple), (tuple), ... + foreach ($value as $k => $v) { + $vx[] = is_array($v) ? '(' . $this->formatValue($v, self::MODE_LIST) . ')' : $this->formatValue($v); + } + return implode(', ', $vx); + + } elseif ($mode === self::MODE_AND || $mode === self::MODE_OR) { // (key [operator] value) AND ... foreach ($value as $k => $v) { if (is_int($k)) { $vx[] = $this->formatValue($v); @@ -242,7 +260,7 @@ private function formatValue($value, string $mode = null): string $k = $this->delimite($k); if (is_array($v)) { if ($v) { - $vx[] = $k . ' ' . ($operator ? $operator . ' ' : '') . 'IN (' . $this->formatValue(array_values($v)) . ')'; + $vx[] = $k . ' ' . ($operator ? $operator . ' ' : '') . 'IN (' . $this->formatValue(array_values($v), self::MODE_LIST) . ')'; } elseif ($operator === 'NOT') { } else { $vx[] = '1=0'; @@ -257,7 +275,7 @@ private function formatValue($value, string $mode = null): string } return $value ? '(' . implode(') ' . strtoupper($mode) . ' (', $vx) . ')' : '1=1'; - } elseif ($mode === 'order') { // key, key DESC, ... + } elseif ($mode === self::MODE_ORDER) { // key, key DESC, ... foreach ($value as $k => $v) { $vx[] = $this->delimite($k) . ($v > 0 ? '' : ' DESC'); } @@ -267,11 +285,11 @@ private function formatValue($value, string $mode = null): string throw new Nette\InvalidArgumentException("Unknown placeholder ?$mode."); } - } elseif (in_array($mode, self::MODE_LIST, true)) { + } elseif (in_array($mode, self::MODES, true)) { $type = gettype($value); throw new Nette\InvalidArgumentException("Placeholder ?$mode expects array or Traversable object, $type given."); - } elseif ($mode && $mode !== 'auto') { + } elseif ($mode && $mode !== self::MODE_AUTO) { throw new Nette\InvalidArgumentException("Unknown placeholder ?$mode."); } else { diff --git a/src/Database/Structure.php b/src/Database/Structure.php index b2bc08ca4..5ec06790b 100644 --- a/src/Database/Structure.php +++ b/src/Database/Structure.php @@ -177,14 +177,11 @@ protected function needStructure(): void return; } - $this->structure = $this->cache->load('structure', [$this, 'loadStructure']); + $this->structure = $this->cache->load('structure', \Closure::fromCallable([$this, 'loadStructure'])); } - /** - * @internal - */ - public function loadStructure(): array + protected function loadStructure(): array { $driver = $this->connection->getSupplementalDriver(); diff --git a/tests/Database/Context.transaction.phpt b/tests/Database/Context.transaction.phpt index c5d293845..b5f9f4a3e 100644 --- a/tests/Database/Context.transaction.phpt +++ b/tests/Database/Context.transaction.phpt @@ -23,6 +23,18 @@ test(function () use ($context) { }); +test(function () use ($context) { + Assert::exception(function () use ($context) { + $context->transaction(function () use ($context) { + $context->query('DELETE FROM book'); + throw new Exception('my exception'); + }); + }, Exception::class, 'my exception'); + + Assert::same(3, $context->fetchField('SELECT id FROM book WHERE id = ', 3)); +}); + + test(function () use ($context) { $context->beginTransaction(); $context->query('DELETE FROM book'); diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index d6cf7314d..75392dae4 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -80,6 +80,11 @@ test(function () use ($preprocessor) { // IN Assert::same(reformat('SELECT id FROM author WHERE ([a] IN (NULL, ?, ?, ?)) AND (1=0) AND ([c] NOT IN (NULL, ?, ?, ?))'), $sql); Assert::same([1, 2, 3, 1, 2, 3], $params); + + + [$sql, $params] = $preprocessor->process(['SELECT * FROM table WHERE ? AND id IN (?) AND ?', ['a' => 111], [3, 4], ['b' => 222]]); + Assert::same(reformat('SELECT * FROM table WHERE ([a] = ?) AND id IN (?, ?) AND ([b] = ?)'), $sql); + Assert::same([111, 3, 4, 222], $params); }); @@ -332,7 +337,7 @@ test(function () use ($preprocessor) { // insert [$sql, $params] = $preprocessor->process(['/* comment */ INSERT INTO author', ['name' => 'Catelyn Stark'], ]); - Assert::same(reformat("/* comment */ INSERT INTO author [name]='Catelyn Stark'"), $sql); // autodetection not used + Assert::same(reformat("/* comment */ INSERT INTO author 'Catelyn Stark'"), $sql); // autodetection not used Assert::same([], $params); }); @@ -347,10 +352,10 @@ test(function () use ($preprocessor) { // ?values }); -test(function () use ($preprocessor) { // automatic detection faild +test(function () use ($preprocessor) { // automatic detection failed Assert::exception(function () use ($preprocessor) { - $preprocessor->process(['INSERT INTO author (name) SELECT name FROM user WHERE id IN (?)', [11, 12]]); - }, Nette\InvalidArgumentException::class, 'Automaticaly detected multi-insert, but values aren\'t array. If you need try to change mode like "?[and|or|set|values|order]". Mode "values" was used.'); + dump($preprocessor->process(['INSERT INTO author (name) SELECT name FROM user WHERE id ?', [11, 12]])); // invalid sql + }, Nette\InvalidArgumentException::class, 'Automaticaly detected multi-insert, but values aren\'t array. If you need try to change mode like "?[and|or|set|values|order|list]". Mode "values" was used.'); }); @@ -440,10 +445,10 @@ test(function () use ($preprocessor) { // update Assert::same([12, 'John Doe'], $params); - [$sql, $params] = $preprocessor->process(['UPDATE author SET a=1,', + [$sql, $params] = $preprocessor->process(['UPDATE author SET a=1,', // autodetection not used ['id' => 12, 'name' => 'John Doe'], ]); - Assert::same(reformat('UPDATE author SET a=1, [id]=?, [name]=?'), $sql); + Assert::same(reformat('UPDATE author SET a=1, ?, ?'), $sql); Assert::same([12, 'John Doe'], $params); });