From b692bec76728801c5f5dc30bf698cd1e8aecc867 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 6 Jun 2026 20:21:10 +0200 Subject: [PATCH 01/51] fixed PHPStan errors --- src/Database/Drivers/SqliteDriver.php | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 40abcb71b..cc3c6043c 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -176,34 +176,33 @@ public function getIndexes(string $table): array $rows = $this->connection->query('PRAGMA index_list(?name)', $table); while ($row = $rows->fetch()) { $id = (string) $row['name']; + $columns = []; + $res = $this->connection->query('PRAGMA index_info(?name)', $id); + while ($info = $res->fetch()) { + $columns[] = (string) $info['name']; + } + $indexes[$id] = [ 'name' => $id, 'unique' => (bool) $row['unique'], 'primary' => false, - 'columns' => [], + 'columns' => $columns, ]; } - foreach ($indexes as $index => $values) { - $res = $this->connection->query('PRAGMA index_info(?name)', $index); - while ($row = $res->fetch()) { - $indexes[$index]['columns'][] = (string) $row['name']; - } - } - - $columns = $this->getColumns($table); - foreach ($indexes as $index => $values) { - $column = $values['columns'][0]; - foreach ($columns as $info) { + $tableColumns = $this->getColumns($table); + foreach ($indexes as $id => $index) { + $column = $index['columns'][0] ?? null; + foreach ($tableColumns as $info) { if ($column === $info['name']) { - $indexes[$index]['primary'] = (bool) $info['primary']; + $indexes[$id]['primary'] = (bool) $info['primary']; break; } } } if (!$indexes) { // @see http://www.sqlite.org/lang_createtable.html#rowid - foreach ($columns as $column) { + foreach ($tableColumns as $column) { if ($column['vendor']['pk']) { $indexes[] = [ 'name' => 'ROWID', From 36a2e9ef26d28225034a2d1f433e6b6c7c16691c Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 28 May 2026 01:50:48 +0200 Subject: [PATCH 02/51] phpstan.neon: narrow ignore --- phpstan.neon | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 46cea130e..b8f9f42ff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,6 +16,7 @@ parameters: # $refPath is populated via @param-out by getRefTable() — PHPStan doesn't track this - identifier: variable.undefined + count: 1 path: src/Database/Table/Selection.php # Readonly lazy-loading via __get magic @@ -67,21 +68,25 @@ parameters: # Intentional new static() in exception hierarchy - identifier: new.static + count: 1 path: src/Database/DriverException.php # Closure variables consumed by require'd phtml template - identifier: closure.unusedUse + count: 2 path: src/Bridges/DatabaseTracy/ConnectionPanel.php # Defensive instanceof check in elseif branch for readability - identifier: instanceof.alwaysTrue + count: 1 path: src/Bridges/DatabaseTracy/ConnectionPanel.php # Lazy-loading side effect via __get magic - identifier: expr.resultUnused + count: 1 path: src/Database/Reflection.php # DI extension: $this->config is array|object from Nette Schema @@ -92,27 +97,33 @@ parameters: # PDOException::$queryString is set by PDO engine, not formally declared - identifier: property.notFound + count: 1 path: src/Bridges/DatabaseTracy/ConnectionPanel.php # Dynamic callable construction: "is_$type"($value) where $type is always valid - identifier: callable.nonCallable + count: 1 path: src/Database/SqlPreprocessor.php # Defensive runtime checks unreachable per @param type - identifier: instanceof.alwaysTrue + count: 1 path: src/Database/SqlPreprocessor.php - identifier: booleanAnd.alwaysFalse + count: 1 path: src/Database/SqlPreprocessor.php # getPrimary() returns string for single-column PK (composite PK not supported here) - identifier: argument.type + count: 1 path: src/Database/Table/ActiveRow.php - identifier: array.invalidKey + count: 1 path: src/Database/Table/ActiveRow.php # Return type mismatches from generic covariance and internal caching @@ -132,14 +143,17 @@ parameters: # Array offset access on Row/ActiveRow objects and nullable arrays - identifier: offsetAccess.notFound + count: 7 path: src/Database/Helpers.php # Defensive checks that are always true/false per PHPStan type narrowing - identifier: empty.offset + count: 1 path: src/Database/Helpers.php - identifier: isset.offset + count: 2 path: src/Database/Helpers.php # Latte-generated n:attr idiom: ($tmp = expr) === null ? '' : ... - From d8e8adeed643d2cb7e875e8f1b83544f392719d3 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 31 May 2026 12:25:21 +0200 Subject: [PATCH 03/51] cs --- src/Bridges/DatabaseDI/DatabaseExtension.php | 7 ++----- src/Database/Drivers/OciDriver.php | 2 +- src/Database/Drivers/SqliteDriver.php | 2 +- src/Database/ResultSet.php | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 5b8560427..ccec18c49 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -99,7 +99,7 @@ private function setupDatabase(\stdClass $config, string $name): void if (!empty($config->reflection)) { $conventionsServiceName = 'reflection'; $config->conventions = $config->reflection; - if (is_string($config->conventions) && strtolower($config->conventions) === 'conventional') { + if (strtolower($config->conventions) === 'conventional') { $config->conventions = 'Static'; } } else { @@ -109,16 +109,13 @@ private function setupDatabase(\stdClass $config, string $name): void if (!$config->conventions) { $conventions = null; - } elseif (is_string($config->conventions)) { + } else { $conventions = $builder->addDefinition($this->prefix("$name.$conventionsServiceName")) ->setFactory(preg_match('#^[a-z]+$#Di', $config->conventions) ? 'Nette\Database\Conventions\\' . ucfirst($config->conventions) . 'Conventions' : $config->conventions) ->setArguments(strtolower($config->conventions) === 'discovered' ? [$structure] : []) ->setAutowired($config->autowired); - - } else { - $conventions = Nette\DI\Helpers::filterArguments([$config->conventions])[0]; } $builder->addDefinition($this->prefix("$name.explorer")) diff --git a/src/Database/Drivers/OciDriver.php b/src/Database/Drivers/OciDriver.php index 7587dd572..2e2a8cf80 100644 --- a/src/Database/Drivers/OciDriver.php +++ b/src/Database/Drivers/OciDriver.php @@ -39,7 +39,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti if (in_array($code, [1, 2299, 38911], strict: true)) { return Nette\Database\UniqueConstraintViolationException::from($e); - } elseif (in_array($code, [1400], strict: true)) { + } elseif ($code === 1400) { return Nette\Database\NotNullConstraintViolationException::from($e); } elseif (in_array($code, [2266, 2291, 2292], strict: true)) { diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index cc3c6043c..8fed19b77 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -150,7 +150,7 @@ public function getColumns(string $table): array $rows = $this->connection->query('PRAGMA table_info(?name)', $table); while ($row = $rows->fetch()) { $column = $row['name']; - $pattern = "/(\"$column\"|`$column`|\\[$column\\]|$column)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; + $pattern = "/(\"$column\"|`$column`|\\[$column]|$column)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; $typeInfo = Nette\Database\Helpers::parseColumnType($row['type']); $columns[] = [ 'name' => $column, diff --git a/src/Database/ResultSet.php b/src/Database/ResultSet.php index 6051bbeb0..f53c46d99 100644 --- a/src/Database/ResultSet.php +++ b/src/Database/ResultSet.php @@ -39,7 +39,7 @@ public function __construct( /** @var ?\Closure(array, self): array */ private readonly ?\Closure $normalizer = null, ) { - $time = microtime(true); + $time = microtime(as_float: true); $types = ['boolean' => PDO::PARAM_BOOL, 'integer' => PDO::PARAM_INT, 'resource' => PDO::PARAM_LOB, 'NULL' => PDO::PARAM_NULL]; try { From 3c5e4c75441747ce6fd3645abb8f8f4108536c50 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 12 May 2026 02:52:39 +0200 Subject: [PATCH 04/51] improved phpDoc types --- src/Database/Connection.php | 4 ++-- src/Database/IStructure.php | 2 +- src/Database/Table/GroupedSelection.php | 1 + src/Database/Table/Selection.php | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 537702be7..61dd7fb33 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -20,10 +20,10 @@ */ class Connection { - /** @var array Occurs after connection is established */ + /** @var array Occurs after connection is established */ public array $onConnect = []; - /** @var array Occurs after query is executed */ + /** @var array Occurs after query is executed */ public array $onQuery = []; private Driver $driver; private SqlPreprocessor $preprocessor; diff --git a/src/Database/IStructure.php b/src/Database/IStructure.php index 48c5f7982..fd4781cbf 100644 --- a/src/Database/IStructure.php +++ b/src/Database/IStructure.php @@ -34,7 +34,7 @@ function getTables(): array; /** * Returns all columns in a table. - * @return list}> + * @return list}> */ function getColumns(string $table): array; diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index b7ac2a170..b6a35a16b 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -254,6 +254,7 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data + * @return ($data is list|Selection ? int : T|array|int) */ public function insert(iterable $data): ActiveRow|array|int { diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 76603ccd9..aab1ae4e1 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -54,7 +54,7 @@ class Selection implements \Iterator, IRowContainer, \ArrayAccess, \Countable protected ?string $generalCacheKey = null; protected ?string $specificCacheKey = null; - /** @var array> of [conditions => [group value => row]]; used by GroupedSelection */ + /** @var array> of [conditions => [group value => row]]; used by GroupedSelection */ protected array $aggregation = []; /** @var array|false|null column => selected */ From c87e70d8b57b14ff6e893579d70c47d1d33c2ca4 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 12:28:47 +0200 Subject: [PATCH 05/51] SqlPreprocessor: empty ?values inserts a row of database defaults on every driver INSERT INTO t () VALUES () is a MySQL-only extension, so insert([]) failed with a syntax error on PostgreSQL, SQLite and MS SQL. Those engines use the standard DEFAULT VALUES clause instead, which MySQL in turn does not know, so there is no common syntax and the driver has to decide. --- src/Database/Driver.php | 1 + src/Database/Drivers/MsSqlDriver.php | 2 +- src/Database/Drivers/PgSqlDriver.php | 2 +- src/Database/Drivers/SqliteDriver.php | 2 +- src/Database/Drivers/SqlsrvDriver.php | 2 +- src/Database/SqlPreprocessor.php | 6 ++++++ tests/Database/SqlPreprocessor.phpt | 11 +++++++++++ 7 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Database/Driver.php b/src/Database/Driver.php index fdad8837b..3a4564aca 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -18,6 +18,7 @@ interface Driver SupportSelectUngroupedColumns = 'ungrouped_cols', SupportMultiInsertAsSelect = 'insert_as_select', SupportMultiColumnAsOrCondition = 'multi_column_as_or', + SupportDefaultValues = 'default_values', SupportSchema = 'schema'; /** @deprecated use Driver::Support* */ diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index b3f6e04c0..414b9f525 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -27,7 +27,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return false; + return $feature === self::SupportDefaultValues; } diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index 4024359f9..5dcc808ae 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -27,7 +27,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return $feature === self::SupportSequence || $feature === self::SupportSchema; + return $feature === self::SupportSequence || $feature === self::SupportSchema || $feature === self::SupportDefaultValues; } diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 8fed19b77..11fd75169 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -29,7 +29,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return $feature === self::SupportMultiInsertAsSelect || $feature === self::SupportMultiColumnAsOrCondition; + return $feature === self::SupportMultiInsertAsSelect || $feature === self::SupportMultiColumnAsOrCondition || $feature === self::SupportDefaultValues; } diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index 143f182b6..67483334c 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -27,7 +27,7 @@ public function initialize(Nette\Database\Connection $connection, array $options public function isSupported(string $feature): bool { - return false; + return $feature === self::SupportDefaultValues; } diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 8a3e72348..24849c234 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -231,6 +231,12 @@ private function formatList(array $values): string */ private function formatInsert(array $items): string { + if (!$items) { + return $this->driver->isSupported(Driver::SupportDefaultValues) + ? 'DEFAULT VALUES' + : '() VALUES ()'; + } + $cols = $vals = []; foreach ($items as $k => $v) { $cols[] = $this->delimit($k); diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index ad386f4c1..c5d901df7 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -469,6 +469,17 @@ test('?values placeholder in INSERT', function () use ($preprocessor) { }); +test('?values placeholder with an empty array inserts a row of defaults', function () use ($preprocessor) { + [$sql, $params] = $preprocessor->process(['INSERT INTO update ?values', []]); + + Assert::same(reformat([ + 'mysql' => 'INSERT INTO update () VALUES ()', // MySQL does not know DEFAULT VALUES + 'INSERT INTO update DEFAULT VALUES', + ]), $sql); + Assert::same([], $params); +}); + + test('Detects incorrect multi-insert usage', function () use ($preprocessor) { Assert::exception( fn() => $preprocessor->process(['INSERT INTO author (name) SELECT name FROM user WHERE id ?', [11, 12]]), From 959066dd95d29b104efc62579377b0e50788fc66 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 4 Jun 2026 13:09:18 +0200 Subject: [PATCH 06/51] Selection::insert() returns number of inserted rows during performing multi-insert (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-line inserting wasn't returning the number of affected rows but an ActiveRow instead (with the first inserted record). The documented behaviour is: a single associative array inserts one row and returns the ActiveRow, a list of rows or a Selection performs a bulk insert and returns the count. A list is recognized by its keys being integers - they need not be sequential, since a filtered array leaves gaps in them, and an integer is never a column name. Such rows are reindexed before the query. Rows coming from a Traversable are drained by position, so none is lost when a generator yields them under colliding keys (`yield from`). An empty array stays a single insert of database defaults, so an unfilled form ($form->getValues()) still inserts a row. Both checks live in Helpers::materializeRows() and Helpers::isRowList(), so that insert() and GroupedSelection share one notion of what a list of rows is. forum thread: https://forum.nette.org/cs/36954-nette-database-v3-2-9-phpdoc-selection-insert-nepokryva-dokumentovany-bulk-insert Co-authored-by: Matěj Kmínek --- src/Database/Helpers.php | 40 +++++++++++++++++- src/Database/Table/GroupedSelection.php | 6 +-- src/Database/Table/Selection.php | 30 +++++++------ .../Explorer/Selection.insert().multi.phpt | 42 ++++++++++++++++++- .../Selection.insert().primaryKeys.phpt | 17 ++++++++ 5 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index cef1af1f8..18e4dfcd3 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -10,7 +10,7 @@ use Nette; use Nette\Bridges\DatabaseTracy\ConnectionPanel; use Tracy; -use function array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_bool, is_float, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap; +use function array_combine, array_filter, array_keys, array_unique, count, fclose, fgets, fopen, fstat, get_resource_type, htmlspecialchars, implode, is_array, is_bool, is_float, is_resource, is_string, preg_last_error, preg_match, preg_replace, preg_replace_callback, reset, rtrim, set_time_limit, str_ends_with, str_starts_with, stream_get_meta_data, strlen, strncasecmp, substr, trim, wordwrap; /** @@ -405,6 +405,44 @@ public static function findDuplicates(\PDOStatement $statement): string } + /** + * Materializes rows for insertion. A Traversable is drained by position, so that rows yielded + * under colliding keys (`yield from`) survive, while a single associative row stays inspectable. + * @param iterable $data + * @return array + * @internal + */ + public static function materializeRows(iterable $data): array + { + if (is_array($data)) { + return $data; + } + + $keys = $values = []; + foreach ($data as $key => $value) { + $keys[] = $key; + $values[] = $value; + } + + return $keys === array_filter($keys, 'is_int') + ? $values + : array_combine($keys, $values); + } + + + /** + * Checks whether the data is a list of rows rather than a single row. Integer keys are never + * column names, so any all-integer keys mean a list, even with gaps left by e.g. array_filter(). + * @param array $data + * @internal + */ + public static function isRowList(array $data): bool + { + $keys = array_keys($data); + return $keys !== [] && $keys === array_filter($keys, 'is_int'); + } + + /** * Parses a SQL column type string into its components. * @return array{type: ?string, length: ?int, scale: ?int, parameters: ?string} diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index b6a35a16b..d40434541 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -262,13 +262,13 @@ public function insert(iterable $data): ActiveRow|array|int return parent::insert($data); } - $data = $data instanceof \Traversable ? iterator_to_array($data) : $data; - if (array_is_list($data)) { + $data = Nette\Database\Helpers::materializeRows($data); + if (Nette\Database\Helpers::isRowList($data)) { foreach (array_keys($data) as $key) { $data[$key][$this->column] = $this->active; } } else { - $data[$this->column] = $this->active; + $data[$this->column] = $this->active; // a single row (an empty one too) } return parent::insert($data); diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index aab1ae4e1..10f039c0a 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -810,30 +810,36 @@ public function getDataRefreshed(): bool /** * Inserts one or more rows into the table. - * Returns the inserted ActiveRow for single-row inserts, or the number of affected rows otherwise. - * @param iterable|Selection $data + * A single associative array inserts one row and returns the inserted ActiveRow; + * a list of rows or a Selection performs a bulk insert and returns the number of affected rows. + * @param iterable|iterable>|Selection $data * @return ($data is array ? T|array : int) */ public function insert(iterable $data): ActiveRow|array|int { + if ($data instanceof self) { // INSERT ... SELECT identifies no row, it only reports the count + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); + $this->loadRefCache(); + unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + return $return->getRowCount() + ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + } + //should be called before query for not to spoil PDO::lastInsertId $primarySequenceName = $this->getPrimarySequence(); $primaryAutoincrementKey = $this->explorer->getStructure()->getPrimaryAutoincrementKey($this->name); - if ($data instanceof self) { - $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); - - } else { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } - - $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); + // an empty array is a single row of database defaults, not a bulk insert + $data = Nette\Database\Helpers::materializeRows($data); + $bulk = Nette\Database\Helpers::isRowList($data); + if ($bulk) { + $data = array_values($data); // keys may have gaps, ?values needs a list } + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); $this->loadRefCache(); - if ($data instanceof self || $this->primary === null) { + if ($bulk || $this->primary === null) { unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); diff --git a/tests/Database/Explorer/Selection.insert().multi.phpt b/tests/Database/Explorer/Selection.insert().multi.phpt index b560b268e..acf47c48d 100644 --- a/tests/Database/Explorer/Selection.insert().multi.phpt +++ b/tests/Database/Explorer/Selection.insert().multi.phpt @@ -17,7 +17,7 @@ Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverN test('', function () use ($explorer) { Assert::same(3, $explorer->table('author')->count()); - $explorer->table('author')->insert([ + $result = $explorer->table('author')->insert([ [ 'name' => 'Catelyn Stark', 'web' => 'http://example.com', @@ -29,15 +29,53 @@ test('', function () use ($explorer) { 'born' => new DateTime('2021-11-11'), ], ]); // INSERT INTO `author` (`name`, `web`, `born`) VALUES ('Catelyn Stark', 'http://example.com', '2011-11-11 00:00:00'), ('Sansa Stark', 'http://example.com', '2021-11-11 00:00:00') + Assert::same(2, $result); Assert::same(5, $explorer->table('author')->count()); $explorer->table('book_tag')->where('book_id', 1)->delete(); // DELETE FROM `book_tag` WHERE (`book_id` = ?) Assert::same(4, $explorer->table('book_tag')->count()); - $explorer->table('book')->get(1)->related('book_tag')->insert([ // SELECT * FROM `book` WHERE (`id` = ?) + $result = $explorer->table('book')->get(1)->related('book_tag')->insert([ // SELECT * FROM `book` WHERE (`id` = ?) ['tag_id' => 21], ['tag_id' => 22], ['tag_id' => 23], ]); // INSERT INTO `book_tag` (`tag_id`, `book_id`) VALUES (21, 1), (22, 1), (23, 1) + Assert::same(3, $result); Assert::same(7, $explorer->table('book_tag')->count()); }); + + +test('rows with non-sequential keys are treated as a multi-insert', function () use ($explorer) { + $rows = (function () { + yield 1 => ['name' => 'Arya Stark', 'web' => 'http://example.com']; + yield 3 => ['name' => 'Jon Snow', 'web' => 'http://example.com']; + })(); + + Assert::same(2, $explorer->table('author')->insert($rows)); + + // an array behaves the same, e.g. left over from array_filter() + Assert::same(2, $explorer->table('author')->insert([ + 0 => ['name' => 'Hodor', 'web' => 'http://example.com'], + 2 => ['name' => 'Osha', 'web' => 'http://example.com'], + ])); + + // and so does a GroupedSelection, which adds the grouping column to each row + $before = $explorer->table('book_tag')->where('book_id', 3)->count(); + Assert::same(2, $explorer->table('book')->get(3)->related('book_tag')->insert([ + 0 => ['tag_id' => 23], + 2 => ['tag_id' => 24], + ])); + Assert::same($before + 2, $explorer->table('book_tag')->where('book_id', 3)->count()); +}); + + +test('a generator yielding rows under colliding keys loses none of them', function () use ($explorer) { + $before = $explorer->table('author')->count(); + $rows = (function () { + yield from [['name' => 'Ygritte', 'web' => 'http://example.com']]; // both batches yield the key 0 + yield from [['name' => 'Gilly', 'web' => 'http://example.com']]; + })(); + + Assert::same(2, $explorer->table('author')->insert($rows)); + Assert::same($before + 2, $explorer->table('author')->count()); +}); diff --git a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt index 85fe3ebe7..ef2a2519a 100644 --- a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt +++ b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt @@ -32,6 +32,23 @@ test('insert into table with simple primary index (autoincrement)', function () Assert::same('Some note here 2', $simplePkAutoincrementResult2->note); }); +test('insert with an empty array leaves all columns to their defaults', function () use ($explorer) { + $row = $explorer->table('simple_pk_autoincrement')->insert([]); + + Assert::type(Nette\Database\Table\ActiveRow::class, $row); + Assert::same(3, $row->identifier1); + Assert::null($row->note); +}); + +test('insert with an empty Traversable is a row of defaults, not an empty bulk', function () use ($explorer) { + // $form->getValues() returns an ArrayHash; an unfilled form must still insert a row + $row = $explorer->table('simple_pk_autoincrement')->insert(Nette\Utils\ArrayHash::from([])); + + Assert::type(Nette\Database\Table\ActiveRow::class, $row); + Assert::same(4, $row->identifier1); + Assert::null($row->note); +}); + test('insert into table with simple primary index (no autoincrement)', function () use ($explorer) { $simplePkNoAutoincrementResult = $explorer->table('simple_pk_no_autoincrement')->insert([ 'identifier1' => 100, From 0c9eda5d193670aec27ad6f908dd352034039e18 Mon Sep 17 00:00:00 2001 From: Michal Haltuf Date: Wed, 22 Apr 2026 19:08:15 +0200 Subject: [PATCH 07/51] Selection::insert() phpDoc fixed (#326) Reported in https://forum.nette.org/cs/36954 --- src/Database/Table/Selection.php | 2 +- tests/types/TypesTest.phpt | 1 + tests/types/database-types.php | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 10f039c0a..76b187a6d 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -813,7 +813,7 @@ public function getDataRefreshed(): bool * A single associative array inserts one row and returns the inserted ActiveRow; * a list of rows or a Selection performs a bulk insert and returns the number of affected rows. * @param iterable|iterable>|Selection $data - * @return ($data is array ? T|array : int) + * @return ($data is list|Selection ? int : T|array|int) */ public function insert(iterable $data): ActiveRow|array|int { diff --git a/tests/types/TypesTest.phpt b/tests/types/TypesTest.phpt index 1bd53e017..6bbbf6406 100644 --- a/tests/types/TypesTest.phpt +++ b/tests/types/TypesTest.phpt @@ -5,3 +5,4 @@ require __DIR__ . '/../bootstrap.php'; use Nette\PHPStan\Tester\TypeAssert; TypeAssert::assertTypes(__DIR__ . '/database-types.php'); +TypeAssert::assertNoErrors(__DIR__ . '/database-types.php'); diff --git a/tests/types/database-types.php b/tests/types/database-types.php index d553a9282..a8028733a 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -116,3 +116,36 @@ function testResultSetFetchPairs(ResultSet $resultSet): void { assertType('array', $resultSet->fetchPairs()); } + + +/** @param Selection $selection */ +function testSelectionInsertSingleRow(Selection $selection): void +{ + // Single associative array -> inserted ActiveRow (or affected count / data for keyless tables) + $result = $selection->insert(['name' => 'Alice']); + assertType('array|int|Nette\Database\Table\ActiveRow', $result); +} + + +/** @param Selection $selection */ +function testSelectionInsertBulk(Selection $selection): void +{ + // List of rows -> bulk insert -> number of affected rows + $result = $selection->insert([ + ['name' => 'Alice'], + ['name' => 'Bob'], + ]); + assertType('int', $result); +} + + +/** + * @param Selection $selection + * @param Selection $source + */ +function testSelectionInsertFromSelection(Selection $selection, Selection $source): void +{ + // Insert from another Selection -> bulk insert -> number of affected rows + $result = $selection->insert($source); + assertType('int', $result); +} From e2791094d911bdd1a5236bd61f79886cb3d79b4b Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 12 May 2026 02:52:39 +0200 Subject: [PATCH 08/51] Helpers::parseColumnType() returns 'size' instead of 'length' --- src/Database/Drivers/MySqlDriver.php | 2 +- src/Database/Drivers/SqliteDriver.php | 2 +- src/Database/Helpers.php | 4 ++-- tests/Database/Helpers.parseColumnType.phpt | 12 ++++++------ tests/types/database-types.php | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Database/Drivers/MySqlDriver.php b/src/Database/Drivers/MySqlDriver.php index 8ed8cc6fb..5be0dac7e 100644 --- a/src/Database/Drivers/MySqlDriver.php +++ b/src/Database/Drivers/MySqlDriver.php @@ -168,7 +168,7 @@ public function getColumns(string $table): array 'name' => $row['field'], 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? ''), - 'size' => $typeInfo['length'], + 'size' => $typeInfo['size'], 'nullable' => $row['null'] === 'YES', 'default' => $row['default'], 'autoincrement' => $row['extra'] === 'auto_increment', diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 11fd75169..6290492ac 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -156,7 +156,7 @@ public function getColumns(string $table): array 'name' => $column, 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? 'BLOB'), - 'size' => $typeInfo['length'], + 'size' => $typeInfo['size'], 'nullable' => $row['notnull'] == 0, 'default' => $row['dflt_value'], 'autoincrement' => $createSql && preg_match($pattern, $createSql['sql']), diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 18e4dfcd3..0544756b9 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -445,14 +445,14 @@ public static function isRowList(array $data): bool /** * Parses a SQL column type string into its components. - * @return array{type: ?string, length: ?int, scale: ?int, parameters: ?string} + * @return array{type: ?string, size: ?int, scale: ?int, parameters: ?string} */ public static function parseColumnType(string $type): array { preg_match('/^([^(]+)(?:\((?:(\d+)(?:,(\d+))?|([^)]+))\))?/', $type, $m, PREG_UNMATCHED_AS_NULL); return [ 'type' => $m[1] ?? null, - 'length' => isset($m[2]) ? (int) $m[2] : null, + 'size' => isset($m[2]) ? (int) $m[2] : null, 'scale' => isset($m[3]) ? (int) $m[3] : null, 'parameters' => $m[4] ?? null, ]; diff --git a/tests/Database/Helpers.parseColumnType.phpt b/tests/Database/Helpers.parseColumnType.phpt index 935e074ba..e3272ffe3 100644 --- a/tests/Database/Helpers.parseColumnType.phpt +++ b/tests/Database/Helpers.parseColumnType.phpt @@ -13,20 +13,20 @@ require __DIR__ . '/../bootstrap.php'; // Test basic type $result = Helpers::parseColumnType('UNSIGNED INT'); -Assert::same(['type' => 'UNSIGNED INT', 'length' => null, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => 'UNSIGNED INT', 'size' => null, 'scale' => null, 'parameters' => null], $result); -// Test type with length +// Test type with size $result = Helpers::parseColumnType('VARCHAR(255)'); -Assert::same(['type' => 'VARCHAR', 'length' => 255, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => 'VARCHAR', 'size' => 255, 'scale' => null, 'parameters' => null], $result); // Test type with precision and scale $result = Helpers::parseColumnType('DECIMAL(10,2)'); -Assert::same(['type' => 'DECIMAL', 'length' => 10, 'scale' => 2, 'parameters' => null], $result); +Assert::same(['type' => 'DECIMAL', 'size' => 10, 'scale' => 2, 'parameters' => null], $result); // Test type with additional parameters $result = Helpers::parseColumnType("ENUM('value1','value2')"); -Assert::same(['type' => 'ENUM', 'length' => null, 'scale' => null, 'parameters' => "'value1','value2'"], $result); +Assert::same(['type' => 'ENUM', 'size' => null, 'scale' => null, 'parameters' => "'value1','value2'"], $result); // Test omitted type $result = Helpers::parseColumnType(''); -Assert::same(['type' => null, 'length' => null, 'scale' => null, 'parameters' => null], $result); +Assert::same(['type' => null, 'size' => null, 'scale' => null, 'parameters' => null], $result); diff --git a/tests/types/database-types.php b/tests/types/database-types.php index a8028733a..c19fb12b1 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -72,7 +72,7 @@ function testSelectionFluentMethods(Selection $selection): void function testParseColumnType(): void { $result = Helpers::parseColumnType('varchar(255)'); - assertType('array{type: string|null, length: int|null, scale: int|null, parameters: string|null}', $result); + assertType('array{type: string|null, size: int|null, scale: int|null, parameters: string|null}', $result); } From 76db6b705eaf1641e0ae58c3a95900baf999d775 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 25 Apr 2026 13:35:34 +0200 Subject: [PATCH 09/51] ConnectionPanel: use n:attributes for cleaner syntax --- src/Bridges/DatabaseTracy/dist/panel.phtml | 62 ++++++++--------- src/Bridges/DatabaseTracy/dist/tab.phtml | 4 +- src/Bridges/DatabaseTracy/panel.latte | 78 +++++++++------------- src/Bridges/DatabaseTracy/tab.latte | 2 +- tests/Database.Tracy/panel.html | 1 - 5 files changed, 67 insertions(+), 80 deletions(-) diff --git a/src/Bridges/DatabaseTracy/dist/panel.phtml b/src/Bridges/DatabaseTracy/dist/panel.phtml index 2ba640d30..4d8af248f 100644 --- a/src/Bridges/DatabaseTracy/dist/panel.phtml +++ b/src/Bridges/DatabaseTracy/dist/panel.phtml @@ -19,9 +19,9 @@ echo ' -

Queries: {$count}{$totalTime ? sprintf(', time: %0.3f ms', $totalTime * 1000) : ''}, {$name}

+

Queries: {$count}{$totalTime ? sprintf(', time: %0.3f ms', $totalTime * 1000)}, {$name}

@@ -23,55 +23,43 @@ - {foreach $queries as [$connection, $sql, $params, $trace, $time, $rows, $error, $command, $explain]} - - + + {if $explain}
explain{/if} + {if $trace}
trace{/if} + - + {if $trace} + {substr_replace(Tracy\Helpers::editorLink($trace[0][file], $trace[0][line]), ' class="nette-DbConnectionPanel-source"', 2, 0)} +
Rows
- {if $error} - ERROR - {elseif $time !== null}{sprintf('%0.3f', $time * 1000)} - {/if} +
+ {if $error} + ERROR + {elseif $time !== null}{sprintf('%0.3f', $time * 1000)} + {/if} - {if $explain}
explain{/if} - {if $trace}
trace{/if} -
- {Nette\Database\Helpers::dumpSql($sql, $params, $connection)|noescape} + + {Nette\Database\Helpers::dumpSql($sql, $params, $connection)|noescape} - {if $explain} - - - {foreach $explain[0] as $col => $foo} - - {/foreach} - - {foreach $explain as $row} - - {foreach $row as $col} - - {/foreach} - - {/foreach} -
{$col}
{$col}
- {/if} + + + + + + + +
{$col}
{$col}
- {if $trace} - {substr_replace(Tracy\Helpers::editorLink($trace[0][file], $trace[0][line]), ' class="nette-DbConnectionPanel-source"', 2, 0)} - - {foreach $trace as $row} - - - - - {/foreach} -
{isset($row[file]) ? Tracy\Helpers::editorLink($row[file], $row[line]) : ''}{$row[class] ?? ''}{$row[type] ?? ''}{$row[function]}()
- {/if} -
+ + + + +
{isset($row[file]) ? Tracy\Helpers::editorLink($row[file], $row[line])}{$row[class] ?? ''}{$row[type] ?? ''}{$row[function]}()
+ {/if} + - {$rows} - - {/foreach} + {$rows} + - {if count($queries) < $count}

...and more

{/if} +

...and more

diff --git a/src/Bridges/DatabaseTracy/tab.latte b/src/Bridges/DatabaseTracy/tab.latte index d942ba4e9..c127ec17e 100644 --- a/src/Bridges/DatabaseTracy/tab.latte +++ b/src/Bridges/DatabaseTracy/tab.latte @@ -5,5 +5,5 @@ {$totalTime ? sprintf('%0.1f ms / ', $totalTime * 1000) : ''}{$count} + >{$totalTime ? sprintf('%0.1f ms / ', $totalTime * 1000)}{$count} diff --git a/tests/Database.Tracy/panel.html b/tests/Database.Tracy/panel.html index 81cac1c74..858b74d5d 100644 --- a/tests/Database.Tracy/panel.html +++ b/tests/Database.Tracy/panel.html @@ -73,5 +73,4 @@

Queries: 4, time: %a% ms, foo

- From bb61b68aa37282d4ff9c9d945dd729f2ada1cd15 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 8 Jun 2026 13:16:58 +0200 Subject: [PATCH 10/51] Selection: centralized referencing-cache invalidation into a private method The three identical unset() of refCache['referencing'] in insert()/insertMany() are replaced by a single clearReferencingCache() method. --- src/Database/Table/Selection.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 76b187a6d..7725eb9bf 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -680,6 +680,15 @@ protected function loadRefCache(): void } + /** + * Invalidates the cache of referencing selections after a manipulation that added rows. + */ + private function clearReferencingCache(): void + { + unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + } + + /** * Returns general cache key independent of query parameters or SQL limit. * Used e.g. for previously accessed columns caching. @@ -840,7 +849,7 @@ public function insert(iterable $data): ActiveRow|array|int $this->loadRefCache(); if ($bulk || $this->primary === null) { - unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } @@ -874,7 +883,7 @@ public function insert(iterable $data): ActiveRow|array|int // If primaryKey cannot be prepared, return inserted rows count } else { - unset($this->refCache['referencing'][$this->getGeneralCacheKey()][$this->getSpecificCacheKey()]); + $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } From 95437b00a2c02c8826a0d67aa172484571252676 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 8 Jun 2026 15:06:00 +0200 Subject: [PATCH 11/51] Selection::insert() invalidates the referencing cache on every unidentifiable row The composite-primary-key branch that returns null skipped clearReferencingCache(), unlike the other two null-returning branches. Since a row was inserted but not added to $this->rows, a cached referencing (grouped) selection would stay stale. All three branches now invalidate consistently. --- src/Database/Table/Selection.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 7725eb9bf..f12cd4477 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -873,6 +873,7 @@ public function insert(iterable $data): ActiveRow|array|int } elseif (is_array($this->primary)) { foreach ($this->primary as $key) { if (!isset($data[$key])) { + $this->clearReferencingCache(); return $data; } } From 81d8311ecf91ada9637c1eefaab080a9f3a8cdfc Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 17 May 2026 19:49:11 +0200 Subject: [PATCH 12/51] added AGENTS.md & DOCS --- .gitattributes | 2 + AGENTS.md | 82 +++++++++ docs/internals/connection-drivers.md | 55 ++++++ docs/internals/explorer.md | 241 +++++++++++++++++++++++++++ docs/internals/readme.md | 15 ++ docs/internals/results-and-types.md | 38 +++++ docs/internals/sql-preprocessor.md | 51 ++++++ docs/internals/transactions.md | 34 ++++ 8 files changed, 518 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/internals/connection-drivers.md create mode 100644 docs/internals/explorer.md create mode 100644 docs/internals/readme.md create mode 100644 docs/internals/results-and-types.md create mode 100644 docs/internals/sql-preprocessor.md create mode 100644 docs/internals/transactions.md diff --git a/.gitattributes b/.gitattributes index 433a2de9a..c361dca0e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,11 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +AGENTS.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore +docs/ export-ignore tests/ export-ignore *.php* diff=php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..882402e31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# To My Agents! + +It is my fervent wish that this file guide every AI coding agent working with code in this repository. + +## Documentation + +Any distilled, agent-facing documentation for this package - how it works +internally and the rationale behind key design decisions - lives in `docs/`. +Consult it before non-trivial changes; it is the source of truth from which the +public manual is distilled. + +Two independent worlds - the low-level core and the Explorer (ActiveRow) layer - +each with sharp edges (lazy execution, accessed-column narrowing, N+1 batching, +context-detected preprocessor modes). Read `docs/internals/` before touching them. + +## Project Overview + +**Nette Database** is a database abstraction layer offering two components: + +1. **Core** - a PDO wrapper with an advanced SQL preprocessor and parameter + substitution. +2. **Explorer** - an ActiveRow layer (inspired by NotORM) with convention-based + relationships and automatic N+1 prevention. + +Supports MySQL, PostgreSQL, SQLite, MS SQL Server, and Oracle. + +- **PHP Version**: 8.1 - 8.5 +- **Package**: `nette/database` + +## Essential Commands + +```bash +# Run all tests +vendor/bin/tester tests -s -C + +# Run one test directory / file +vendor/bin/tester tests/Database/Explorer -s -C +vendor/bin/tester tests/Database/Explorer/Explorer.basic.phpt -s -C + +# Static analysis (PHPStan level 5 + phpstan-nette) +composer phpstan +``` + +Most tests connect to real MySQL/PostgreSQL/MS SQL servers via +`@dataProvider databases.ini`. Bring the servers up with the repo's +`docker-compose.yml` (`docker compose up -d`, wait for `healthy`) before running +them; a `Connection refused` / `could not find driver` failure means the servers +aren't up yet, not a broken test. + +## Conventions + +- Every file starts with `declare(strict_types=1);`; everything typed; single + quotes unless the string contains an apostrophe; Nette Coding Standard. +- Use generic annotations for IDE/PHPStan support: `@return Selection`, + `@template T of ActiveRow`. Method phpDoc starts with a 3rd-person verb (Returns, + Formats, Checks); document a param/return only when it adds info beyond the type. +- Tests are Nette Tester `.phpt` files; use `@dataProvider databases.ini` to run + against every engine, `test()` / `testException()` with descriptive names, and + **no comment before `test()`**. Fixtures: `tests/Database/files/{driver}-nette_test1.sql`. + +## Working in this repo + +- **The Explorer is lazy and self-narrowing.** `accessColumn` is the single seam + every read passes through; a first query fetches `SELECT *`, later ones narrow to + the accessed columns (cached), and relations are batched to avoid N+1. The cache + key even depends on the call-site (`debug_backtrace`) - a real refactor trap. See + `docs/internals/explorer.md`. +- **The SQL preprocessor picks its array mode from surrounding SQL context** + (`?and`/`?set`/`?values`/`?order`/`?list`), so the same array expands differently + after `WHERE` vs `SET` vs `INSERT`. See `docs/internals/sql-preprocessor.md`. +- **Nested transactions use a depth counter, not savepoints** - only the outermost + `transaction()` issues a real BEGIN/COMMIT/ROLLBACK; there is no partial rollback, + and no retry mechanism (no `$attempts`, no `RetryableException`, no `onRetry`). + There is no `TypeConverter` class either (DB->PHP conversion is + `Helpers::normalizeRow`). Don't document designed-but-absent features as present. +- **Array expansion is a mass-assignment surface.** Passing raw user input as the + array to `insert`/`update`/`where` lets an attacker set arbitrary columns and + inject operators/SQL via keys - always whitelist columns first. Full guidance is + web-manual material. +- User-facing how-to (Explorer/Selection API, `?`-placeholder reference, NEON + config, transactions, Reflection API) is manual material and lives in the public + web docs, not here. diff --git a/docs/internals/connection-drivers.md b/docs/internals/connection-drivers.md new file mode 100644 index 000000000..39d87e89c --- /dev/null +++ b/docs/internals/connection-drivers.md @@ -0,0 +1,55 @@ +# Connection & drivers + +## Execution path + +`Connection` connects **lazily** — the constructor opens the PDO only when the `lazy` +option is falsy; otherwise the first `getPdo()`/`getDriver()`/`preprocess()` triggers +`connect()` (which also instantiates the driver from `PDO::ATTR_DRIVER_NAME`, builds +the `SqlPreprocessor`, and fires `onConnect`). + +`query()` = `preprocess()` (which runs the preprocessor only when there are +parameters) → `new ResultSet(...)`. **The SQL actually executes in the `ResultSet` +constructor, not in `query()`** — so timing, binding, and exception conversion all +live there (see results-and-types.md). The `fetch*` shortcuts on `Connection` just +delegate to `query(...)->fetch*()`. + +## The `Driver` dialect abstraction + +Each engine implements `Driver`: `delimite` (identifier quoting — MySQL backticks, +PgSql double-quotes, both doubling), `formatDateTime`/`formatDateInterval`/`formatLike`, +`applyLimit`, schema reflection (`getTables`/`getColumns`/`getIndexes`/`getForeignKeys`/ +`getColumnTypes`), `convertException`, and `isSupported` over the `Support*` feature +constants. + +**`applyLimit` is the most dialect-divergent piece** — MySQL uses `LIMIT` (with the +`LIMIT 18446744073709551615 OFFSET` trick for offset-only), PgSql separate `LIMIT`/ +`OFFSET`, SQL Server `OFFSET … ROWS FETCH NEXT … ROWS ONLY`, MS SQL/ODBC inject a +`TOP n` (no offset), Oracle wraps in a `ROWNUM` subquery. Result-set type detection is +per-driver `getColumnTypes`: PgSql and MsSql map the whole result set via +`Helpers::detectTypes`, MySQL/SQLite/Sqlsrv go column-by-column via +`Helpers::detectType`, and Odbc/Oci detect nothing. MySQL adds dialect rules +(`NEWDECIMAL` precision 0 → integer, `TINY` len 1 + `convertBoolean` → bool, `TIME` → +interval). + +## Exception mapping + +``` +\PDOException → DriverException + ├── ConnectionException → ConnectionLostException + ├── ConstraintViolationException + │ ├── ForeignKey / NotNull / Unique / CheckConstraintViolation + ├── DeadlockException + └── LockTimeoutException +``` + +Note `Deadlock`/`LockTimeout` extend `DriverException` **directly**, not the +constraint hierarchy. There is **no** `RetryableException` marker and `transaction()` +performs no retries — retrying on deadlock/lock-timeout/connection-lost is the +caller's job. The mapping is **per driver** in +`convertException`: MySQL keys on the numeric error code, PgSql on the SQLSTATE; an +unrecognized error falls back to a bare `DriverException::from()`. `DriverException::from` +parses `errorInfo`, or the `SQLSTATE[..] [..] ..` pattern from the message when +`errorInfo` is absent. Conversion is invoked in the `ResultSet` constructor (which also +attaches the query string and params) and in `getInsertId`; `connect()`/`quote()` use +`ConnectionException::from`/`DriverException::from` directly because the driver may not +exist yet. diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md new file mode 100644 index 000000000..ce1817470 --- /dev/null +++ b/docs/internals/explorer.md @@ -0,0 +1,241 @@ +# Explorer internals (Selection & ActiveRow) + +How the Explorer layer (`Selection`, `ActiveRow`, `GroupedSelection`, `SqlBuilder`) +really works inside — data flow, caching, the two optimizations, and above all the +**traps and invariants**. This is the reference for the current code. + +## Two layers + +- **Core** (`src/Database/`): `Connection` (PDO wrapper), `SqlPreprocessor`, + `ResultSet` (iterator + type normalization), `Driver` (per-engine), `Structure`/ + reflection. +- **Explorer** (`src/Database/Table/`): `Selection` (lazy fluent builder), + `ActiveRow` (a row), `GroupedSelection` (has-many), `SqlBuilder`. + +Explorer is NotORM-inspired, and its value is two optimizations: + +1. **N+1 prevention** — related rows are fetched in batches (`WHERE id IN (...)`), + a constant number of queries. +2. **SELECT narrowing** — after an initial `SELECT *` it learns which columns are + actually read and next time selects only those. **Works only with a cache.** + +## Selection: lazy execution + +`Selection` is a **lazy** fluent builder — `where`/`order`/`select`/… only push into +`SqlBuilder` and return `$this`. **Nothing executes until you touch data.** + +Key fields: `$rows` (`?array` of `[signature => ActiveRow]`; `null` = not executed), +`$data` (iterable form — for a plain Selection a COW copy of `$rows`, but for a +`GroupedSelection` **bound by reference** into the shared refCache — hence sharing), +`$cache` (without it narrowing is off), `$accessedColumns` (columns read *now*), +`$previousAccessedColumns` (learned last time, from cache — **per-instance**), the +two cache keys, `$refCache`/`$globalRefCache`, `$observeCache` (which instance owns +saving learned columns), and `$dataRefreshed` (a re-query happened; signal to +`ActiveRow` to pull fresh data). + +**`execute()`**: idempotent if `$rows !== null`; runs `query(getSql())` where +`getSql()` builds the SELECT column list from `previousAccessedColumns`; **on a +`DriverException` retries with `SELECT *`**, but only when the SELECT was actually +narrowed (non-empty `previousAccessedColumns`, no explicit `select()`) — a narrowed +SELECT can reference a column dropped by a schema change; builds an +`ActiveRow` per PDO row keyed by **signature** (PK values joined by `|`, or numeric +when PK-less); then marks the primary column(s) as accessed. + +`get($key)` = `clone $this` then `wherePrimary($key)->fetch()` — it **clones** so as +not to dirty the original. + +## ActiveRow: storage & access + +**All data lives in one private `$data` array** (`[column => value]`); no column is a +separate property, so every read falls into `__get` and flows through `$data` — the +only way to track which columns are actually read (SELECT narrowing). A subclass must +therefore **not** declare real typed properties (`public int $id`): an existing +property bypasses the magic; use `@property-read` annotations instead. (There is no +`EntityMapping` class and no read-side enum conversion — `BackedEnum` handling exists +only on the write side, in the preprocessor.) + +- `__get($key)`: `accessColumn($key)` → returns `$data[$key]`; if the column is + absent it tries a **relation** (`getReferencedTable`), else throws + `MemberAccessException` (with a did-you-mean hint). `__set`/`__unset` throw + (read-only). `toArray()` forces all columns via `accessColumn(null)`. +- `getPrimary()`/`getSignature()` read **only `$data[$primary]`, with no query** — so + row-cache writes can call them without triggering a fetch. + +## `accessColumn`: the single shared seam + +**Every** row-data access flows through `ActiveRow::accessColumn(?string $key)` — +`__get`, `__isset`, `toArray()` (via `accessColumn(null)`), and forward references +(`getReferencedTable` calls `$row->accessColumn($fkColumn)`). One hook covers every +path, which is why SELECT narrowing hangs off it. It: (1) delegates to +`$this->table->accessColumn($key)` and, if the Selection reports a re-query, pulls +fresh data from `$this->table[signature]->data`, (2) returns whether the column +exists. + +## Cache keys + +- `getGeneralCacheKey()` hashes `[table, conditions, debug_backtrace]` (plus a fixed + `Selection::class` constant — `self::class`, identical even for a `GroupedSelection`). It + **deliberately omits limit and select**, so it is stable across limited variants of + the same query (used by the limit re-query). **The trap:** it includes + `debug_backtrace`, so the cache key depends on the **call site** — two call sites + with identical conditions get different keys and different learned columns. This is + intentional (different sites read different columns) but surprising, and a + refactor that moves a call site (e.g. wraps `table()` in a factory) silently + "forgets" the learned columns. +- `getSpecificCacheKey()` hashes the **whole built select query** via + `SqlBuilder::getSelectQueryHash()` — conditions, order, aliases, limit/offset, + parameters, and the column list — so `SELECT id,name` vs `SELECT *` land in + different cache slots. + +## SELECT narrowing + +State: `$accessedColumns` (`array|false|null`; `false` = "all", forced +e.g. by `toArray()`), `$previousAccessedColumns` (learned last time). Off entirely +when `$cache === null` or when an explicit `select(...)` (even `select('*')`) is set. + +Flow: `accessColumn($key)` records the access; a **re-query** fires only when **all** +hold — the access wants a select column, `previous` is non-empty (something was +learned; the very first query with nothing learned just runs `SELECT *`), the key is +**not** in `previous`, and there is no explicit `select()`. `saveCacheState()` (from +`__destruct` / `emptyResultSet`) merges accessed into the cache, but only when +`observeCache === $this` — so only the owning instance saves. + +**The re-query** on an unlearned column: **without a limit**, `emptyResultSet`, set +`previous = []`, `execute()` again (now `SELECT *`); **with a limit**, it must not +re-run the original limited query (different rows), so it collects the already-loaded +rows' PKs, clones the SqlBuilder, **drops the limit**, `wherePrimary(collected PKs)`, +`execute()` (a `SELECT *` of exactly those rows), then restores the SqlBuilder — the +`generalCacheKey` is preserved throughout. In both branches, when the trigger was +`accessColumn(null)` (e.g. `toArray()` mid-iteration), the iterator position is +captured and restored after the re-query. It sets `dataRefreshed = true` so +`ActiveRow::accessColumn` pulls fresh data. This mechanism is **intrinsically inside +Selection** (it manipulates the SqlBuilder, rows, execute). + +**Shared vs per-instance — the key asymmetry.** `accessedColumns` is **shared** across +grouped clones (for the N+1 batch), bound by reference into +`&$referencing[$hash]['accessed']`. `previousAccessedColumns` is **not** shared. They +cannot be trivially unified into one shared object: the shared `accessed` slot is +selected by `hash` (`specificCacheKey`), which **depends on `previous`** — so if +`previous` lived inside the hash-selected object you'd get a cycle (object → need hash +→ need previous → need object). `previous` therefore must exist earlier and +independently, as a per-instance array. (A WeakMap does not help — the blocker is a +computation-order dependency, not object identity.) + +`Selection::__clone` clones only the SqlBuilder; `accessedColumns` is an **array** and +so is copied by value on clone (a `get()` clone is automatically independent). + +## N+1 prevention & refCache + +A Selection holds `$refCache` as a reference into the **root** selection's +`globalRefCache[$refPath]` (a GroupedSelection climbs up to build a `book.author.` +path), so relations are shared across the whole chain. Keys: `['referenced']` (forward +belongs-to batches), `['referencing']` (backward has-many batches + shared `accessed`, +`rows`, `data`), `['referencingPrototype']` (grouped prototypes). + +- **Forward (`$book->author`):** collect `author_id` from **all** parent rows, build + **one** `SELECT * FROM author WHERE id IN (...)`, index by FK — constant queries. +- **Backward (`$author->related('book')`):** `getReferencingTable` returns a **prototype** + `GroupedSelection` (cloned per access); its `execute` binds through `loadRefCache` to + the shared slot (`observeCache`, `rows`, `data`, **and** `accessedColumns` — all by + reference). The first clone queries **all** children of **all** parents at once and + buckets them by group value; later clones find the data cached. + +`loadRefCache` binds `$this->accessedColumns = &$referencing[$hash]['accessed']` (a +by-ref array), so a mutation from one clone is seen by all clones of the same relation +(they share the learned columns of the whole N+1 batch). + +## insert() + +`Selection::insert(iterable)` handles every form in one method (return type +`ActiveRow|array|int`, never `null`): a **list, a Selection, or a PK-less table** +returns the affected-row count (int). A **single associative row** collects the PK +from the data (an autoincrement part filled from `getInsertId`) and **re-fetches the +row eagerly** (`SELECT *` by PK) to return an `ActiveRow` — so a single insert always +costs a second query. Two fallbacks return early instead — a single-column PK without +autoincrement absent from the data → int, a composite PK without autoincrement with a +part missing → the original `$data` array. + +All the early-return branches above (int / array) call `clearReferencingCache()`; a +returned row is also registered into `rows`/`data` if the Selection was already +executed. + +## update() / delete() + +`ActiveRow::update($data)` runs UPDATE via `createSelectionInstance()->wherePrimary()` +then **re-fetches `SELECT *`** into `$this->data` (returns whether it changed). +`ActiveRow::delete()` runs DELETE and removes the row from `table[$signature]`. + +## The invariants that matter most + +1. **Row identity.** A returned row **must** be the same object that is in its + `table->rows` (else update + relations = stale). +2. **`debug_backtrace` in the cache key.** `generalCacheKey` depends on the call site; + a refactor that moves the call can change keys and "forget" learned columns — an + unsuspected source of "why is it suddenly `SELECT *`". +3. **Shared vs per-instance.** `accessedColumns` shared by reference, + `previousAccessedColumns` per-instance; not unifiable due to the previous→hash→slot + cycle above. +4. **`previousAccessedColumns` is transiently mutated** (`false` on retry, `[]` before + re-query, `null` after save) — which is exactly why it must **not** be shared across + grouped clones (it would corrupt another clone's hash computation). +5. **`select('*')` disables the optimization** — which is why `insert()` re-fetches + with `select('*')` (to avoid a cache-narrowed row and to avoid dirtying the cache). +6. **`offsetSet` on Selection writes only `rows`, not `data`** (asymmetry vs + `offsetUnset`) — latent fragility. +7. **refCache invalidation.** All "no identifiable row" insert branches call + `clearReferencingCache()`; a successful insert does not (it adds the row to `rows` + in place). + +## Three flows worth tracing + +**A) N+1 prevention (forward reference):** + +```php +$books = $explorer->table('book'); // lazy, nothing runs +foreach ($books as $book) { // execute(): SELECT * FROM book (query 1) + echo $book->author->name; // $book->author → getReferencedTable +} +``` + +On the **first** `$book->author`, `getReferencedTable` walks **all** `$books->rows`, +collects the `author_id`s and runs **one** `SELECT * FROM author WHERE id IN (...)` +(query 2), indexed by id. Later iterations just hit the cached selection — **2 queries +total** regardless of row count. (Backward `related()` works analogously via +`GroupedSelection`.) + +**B) insert():** + +```php +$row = $explorer->table('book')->insert([ // INSERT ... (query 1) + 'title' => 'Foo', 'author_id' => 1, +]); // then SELECT * WHERE id = ? (query 2) + // the PK comes from getInsertId() +$id = $row->id; // the row is already complete → no further query +echo $row->title; +``` + +**C) SELECT narrowing across requests (with cache):** + +```php +// request 1 (cache empty for this call site): +$book = $explorer->table('book')->get(1); // SELECT * FROM book WHERE id = 1 +echo $book->title; // accessColumn('title') records the access + // at the end: saveCacheState stores {id, title} under generalCacheKey + +// request 2 (cache already knows {id, title}): +$book = $explorer->table('book')->get(1); // SELECT id, title FROM book WHERE id = 1 (narrowed!) +echo $book->author->name; // needs author_id, missing from the narrowed {id, title} + // → re-query: SELECT * WHERE id = 1 + // getReferencedTable calls accessColumn('author_id') → marks it + // at the end: the cache grows to {id, title, author_id} +``` + +The learning is keyed by `generalCacheKey` (including `debug_backtrace`), so **the same +call site** gradually learns its own column set; a different call site learns separately. + +## Refactoring boundary + +The narrowing optimization is architecturally fused into Selection: the re-query +mechanism touches SqlBuilder/rows/execute, and the cache-persistence policy needs the +per-instance `previous` + cache + generalKey (the cycle blocker). Realistically only +the shared `accessed` state and its write operations are cleanly extractable. diff --git a/docs/internals/readme.md b/docs/internals/readme.md new file mode 100644 index 000000000..598c52f26 --- /dev/null +++ b/docs/internals/readme.md @@ -0,0 +1,15 @@ +# Database internals + +How `nette/database` works underneath, for agents editing it. Two independent +worlds — the low-level core and the Explorer (ActiveRow) layer — so split by seam: + +- **[explorer.md](explorer.md)** — the flagship: `Selection`/`ActiveRow` lazy + execution, `accessColumn`, SELECT narrowing, N+1 batching (`refCache`), lazy + insert. The subtlest, trap-richest code. +- **[sql-preprocessor.md](sql-preprocessor.md)** — parameter substitution and the + context-detected array modes. +- **[connection-drivers.md](connection-drivers.md)** — connection/execution, the + `Driver` dialect abstraction, and exception mapping. +- **[results-and-types.md](results-and-types.md)** — `ResultSet` and the DB→PHP + value normalization. +- **[transactions.md](transactions.md)** — `transaction()` and depth-counter nesting. diff --git a/docs/internals/results-and-types.md b/docs/internals/results-and-types.md new file mode 100644 index 000000000..833708c91 --- /dev/null +++ b/docs/internals/results-and-types.md @@ -0,0 +1,38 @@ +# ResultSet & type normalization + +## ResultSet executes eagerly and iterates once + +The `ResultSet` constructor **runs the query immediately** — it times, prepares, binds +(PDO param types by PHP type: bool→`PARAM_BOOL`, int→`PARAM_INT`, resource→`PARAM_LOB`, +null→`PARAM_NULL`, else `PARAM_STR`), sets `FETCH_ASSOC`, and executes; a `PDOException` +becomes a converted `DriverException`. A query string beginning with **`::`** is a +special channel: it calls the named PDO method directly (this is how transactions issue +`::beginTransaction`/`::commit`/`::rollBack`). + +The iterator is **one-way** — `rewind()` throws once the iterator has advanced past +the first row. `fetchAssoc` is the core (each `fetch` → `normalizeRow`, with a +duplicate-column check on the first row only); `fetch` wraps it in a `Row`; `fetchAll` +caches `iterator_to_array` into `$rows`. `getRowCount()` returns `rowCount()` (affected +rows for non-SELECT; `null` for `::`-channel calls with no statement). + +## DB→PHP conversion is a function, not a class + +There is **no `TypeConverter` class**. Conversion is `Helpers::normalizeRow()`, wired +into the `Connection` as the `rowNormalizer` closure (which the `newDateTime` option +swaps between `Nette\Database\DateTime` and `Nette\Utils\DateTime`). It can be replaced +via `setRowNormalizer`. + +`normalizeRow` iterates the result-set column types (`IStructure::FIELD_*`) and +converts, with a few traps worth knowing: + +- **`FIELD_INTEGER`** → `$value * 1`, but keeps the original **string** if it would + overflow to a float. +- **`FIELD_FLOAT`/`FIELD_DECIMAL`** → `(float)` — **a decimal is coerced to float** + (precision hazard). +- **`FIELD_BOOL`** → `$value && $value !== 'f' && $value !== 'F'` (handles PostgreSQL's + `'f'`). +- **`FIELD_DATETIME`/`FIELD_DATE`** → `new $dateTimeClass($value)`, but `'0000-00…'` + → `null`; `FIELD_TIME` sets the date to `0001-01-01`; `FIELD_TIME_INTERVAL` → + `\DateInterval`; `FIELD_UNIX_TIMESTAMP` → a datetime via `setTimestamp`. +- **Binary and JSON are not converted** — they come back as the raw PDO string. + `FIELD_BINARY` exists only for detection, not value conversion. diff --git a/docs/internals/sql-preprocessor.md b/docs/internals/sql-preprocessor.md new file mode 100644 index 000000000..c22318fe9 --- /dev/null +++ b/docs/internals/sql-preprocessor.md @@ -0,0 +1,51 @@ +# SqlPreprocessor + +Substitutes parameters into SQL, with **context-detected array modes** — the same +array means different SQL depending on where it appears. + +## Context detection + +`process()` scans the SQL fragments with one regex, recognizing the leading command +(`SELECT|INSERT|UPDATE|DELETE|REPLACE|EXPLAIN`) and the keywords +`SET|WHERE|HAVING|ORDER BY|GROUP BY|KEY UPDATE` **only when followed by end-of-string +or `?`**. A matched keyword sets `$arrayMode` via `CommandToMode`: + +| context | mode | array becomes | +|---|---|---| +| `INSERT`/`REPLACE` | `ModeValues` | `(cols) VALUES (…)` | +| `SET` / `KEY UPDATE` | `ModeSet` | `col = ?, …` | +| `WHERE` / `HAVING` | `ModeAnd` | `col = ? AND …` | +| `ORDER BY` / `GROUP BY` | `ModeOrder` | order list | + +An array parameter with no context and no explicit `?mode` **defaults to `ModeSet`**. +Explicit placeholders `?values`/`?set`/`?and`/`?or`/`?order`/`?list`/`?name` force a +mode; `?name` quotes an identifier by splitting on `.` and delimiting each part with +the driver. + +## Value formatting + +`formatValue` (`match(true)`) handles scalars (int/bool/float), a binary **resource** +(quoted stream contents), strings (`connection->quote`), `null`, and several object +types worth knowing: + +- **`SqlLiteral` is not escaped** — and may itself carry parameters + (`SqlLiteral($sql, $params)`), spliced in via a cloned preprocessor. +- **`ActiveRow` → its primary key** (this is how a row/subquery value is used). +- `DateTimeInterface`/`DateInterval` → the **driver's** format; `BackedEnum` → its + `value`; `Stringable` → its string. + +The same code either **inlines literals** or emits a bound `?` (pushing the value to +`remaining`), switched by `useParams` — which is turned on for the parametric +commands. `formatWhere` carries the fiddly bits: a compound key `'col op'` splits on +the first space; an empty array under a bare key or `IN` becomes `1=0` +(short-circuiting a whole `?and`), under `NOT`/`NOT IN` it becomes `1=1`; a `NULL` +value under a bare key renders `IS` (under `NOT`, `IS NOT`) — an explicitly written +`=` operator is **not** remapped, so `'col =' => null` yields a broken `= NULL`. +`formatSet` turns a `'points+='` key into `col = col + ?`. + +## Not implemented (so don't document as present) + +- **No `@` escaping.** There is no `@`-prefix logic anywhere in the preprocessor. +- **No PHP-array → PostgreSQL-array-literal serialization.** Arrays only travel through + the modes; on the read side a pg array column is merely detected as text (no + parsing). diff --git a/docs/internals/transactions.md b/docs/internals/transactions.md new file mode 100644 index 000000000..813b9782d --- /dev/null +++ b/docs/internals/transactions.md @@ -0,0 +1,34 @@ +# Transactions + +Nesting is **counter-based only** — there are **no savepoints**. + +## `transaction()` + +`transaction(callable $callback): mixed` runs the callback between `BEGIN` and +`COMMIT`/`ROLLBACK`. Nesting is tracked purely by `$transactionDepth`: + +- a real `BEGIN` is issued only when `transactionDepth === 0`; a nested call merely + increments the counter and **emits no SQL**; +- `COMMIT` likewise only at depth 0; on an exception, `ROLLBACK` only when it unwinds + back to depth 0, then the exception is rethrown. The rollback itself is **not** + guarded — if the server already rolled back (e.g. after a deadlock) and the + `ROLLBACK` fails, that failure replaces the original exception. + +**The consequence to internalize:** a nested `transaction()` gives **no partial +rollback**. Only the outermost transaction issues real `BEGIN`/`COMMIT`/`ROLLBACK`, so +an inner failure tears down the *entire* outer transaction. The idea of savepoints is +**not implemented** — there is no `SAVEPOINT`/`RELEASE` anywhere in the code. + +**No retries either (so don't document them as present):** there is no `$attempts` +parameter, no retry loop, no `RetryableException` marker, no `onRetry` event. +`DeadlockException`, `LockTimeoutException` and `ConnectionLostException` exist, but +nothing in `transaction()` catches and retries them — retrying is the caller's job. + +## Manual control is fenced off inside a callback + +`beginTransaction()`/`commit()`/`rollBack()` each **throw** if called while +`transactionDepth !== 0`, i.e. manual transaction control is forbidden inside a +`transaction()` callback (they would desynchronize the counter). They execute via the +`::beginTransaction`/`::commit`/`::rollBack` PDO channel (see results-and-types.md). +`getInsertId` returns `lastInsertId` as a string (`'0'` on false), converting a +`PDOException` through the driver. From 7599fe5cf17bbaa94132ab8c81ca010bb2102ade Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:31:23 +0200 Subject: [PATCH 13/51] SqlPreprocessor: short-circuited ?and/?or no longer leaks bound parameters of discarded conditions --- src/Database/SqlPreprocessor.php | 4 +++- tests/Database/SqlPreprocessor.phpt | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 24849c234..4051866b3 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -8,7 +8,7 @@ namespace Nette\Database; use Nette; -use function array_key_exists, array_keys, array_map, array_values, count, explode, get_debug_type, implode, in_array, is_array, is_bool, is_float, is_int, is_resource, is_scalar, is_string, iterator_to_array, ltrim, number_format, rtrim, str_contains, str_ends_with, stream_get_contents, strtoupper, substr; +use function array_key_exists, array_keys, array_map, array_slice, array_values, count, explode, get_debug_type, implode, in_array, is_array, is_bool, is_float, is_int, is_resource, is_scalar, is_string, iterator_to_array, ltrim, number_format, rtrim, str_contains, str_ends_with, stream_get_contents, strtoupper, substr; /** @@ -306,6 +306,7 @@ private function formatWhere(array $items, string $mode): string { $default = '1=1'; $res = []; + $begin = count($this->remaining); foreach ($items as $k => $v) { if (is_int($k)) { $res[] = $this->formatValue($v); @@ -322,6 +323,7 @@ private function formatWhere(array $items, string $mode): string } else { $default = $kind ? '1=0' : '1=1'; if ($kind === ($mode === self::ModeAnd)) { + $this->remaining = array_slice($this->remaining, 0, $begin); // drops params of discarded conditions return "($default)"; } } diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index c5d901df7..b51f7542a 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -223,6 +223,30 @@ test('Empty WHERE conditions joined with OR', function () use ($preprocessor) { }); +test('Short-circuited WHERE discards params of preceding conditions', function () use ($preprocessor) { + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE', [ + 'a' => 1, + 'col_empty IN' => [], + ]]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=0)'), $sql); + Assert::same([], $params); + + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE ?and LIMIT ?', [ + 'a' => 1, + 'col_empty' => [], + ], 10]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=0) LIMIT ?'), $sql); + Assert::same([10], $params); + + [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE ?or', [ + 'a' => 1, + 'col_empty NOT IN' => [], + ]]); + Assert::same(reformat('SELECT id FROM tbl WHERE (1=1)'), $sql); + Assert::same([], $params); +}); + + test('WHERE conditions with indexed items', function () use ($preprocessor) { [$sql, $params] = $preprocessor->process(['SELECT id FROM tbl WHERE', [ new SqlLiteral('foo'), From 44510757e26a67dbf708aaa14dbb5b2716ccaf12 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:34:57 +0200 Subject: [PATCH 14/51] Row: fixed __isset() testing a column literally named 'key' instead of the given one --- src/Database/Row.php | 3 ++- tests/Database/Row.phpt | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Database/Row.php b/src/Database/Row.php index 3143a19fd..b629f8e4f 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -26,7 +26,8 @@ public function __get(mixed $key): mixed public function __isset(string $key): bool { - return isset($this->key); + // called only for non-existent properties, so that `$row->missing ?? …` does not invoke throwing __get + return false; } diff --git a/tests/Database/Row.phpt b/tests/Database/Row.phpt index 32c386ea1..8a35ed094 100644 --- a/tests/Database/Row.phpt +++ b/tests/Database/Row.phpt @@ -43,6 +43,15 @@ test('numeric field', function () use ($connection) { }); +test('isset is not confused by a column named key', function () use ($connection) { + $row = $connection->fetch("SELECT 123 AS {$connection->getDriver()->delimite('key')}"); + Assert::true(isset($row->key)); + Assert::false(isset($row->missing)); + Assert::same('default', $row->missing ?? 'default'); + Assert::false(isset($row['missing'])); +}); + + test('named field', function () use ($connection) { $row = $connection->fetch('SELECT 123 AS title'); Assert::same(123, $row->title); From 7b0cb4c1e54abccc9b336ddd9b3da1340c15b641 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:35:16 +0200 Subject: [PATCH 15/51] Row: offsetExists() with numeric index no longer reports falsy values as missing --- src/Database/Row.php | 3 ++- tests/Database/Row.phpt | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Database/Row.php b/src/Database/Row.php index b629f8e4f..9b0fdd09f 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -57,7 +57,8 @@ public function offsetGet($key): mixed public function offsetExists($key): bool { if (is_int($key)) { - return (bool) current(array_slice((array) $this, $key, 1)); + $arr = array_slice((array) $this, $key, 1); + return $arr && current($arr) !== null; // null value → false, consistently with string keys } return parent::offsetExists($key); diff --git a/tests/Database/Row.phpt b/tests/Database/Row.phpt index 8a35ed094..14d5a7ae5 100644 --- a/tests/Database/Row.phpt +++ b/tests/Database/Row.phpt @@ -29,6 +29,11 @@ test('numeric field', function () use ($connection) { Assert::false(isset($row[1])); // null value Assert::false(isset($row[2])); // is not set + $falsy = $connection->fetch("SELECT 0 AS a, '' AS b"); + Assert::true(isset($falsy[0])); // falsy value is set + Assert::true(isset($falsy[1])); + Assert::false(isset($falsy[2])); + Assert::error( fn() => $row->{2}, Nette\MemberAccessException::class, From 1182d95e1bbd93df2d7333eacb202fe395d37001 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:40:24 +0200 Subject: [PATCH 16/51] SqlBuilder: cache-key hashes use serialize(), json_encode() returns false for binary (non-UTF-8) parameters and made condition dedup collide --- src/Database/Table/SqlBuilder.php | 6 +++--- tests/Database/Explorer/SqlBuilder.addWhere().phpt | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 6b33520be..30285af3e 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -13,7 +13,7 @@ use Nette\Database\Explorer; use Nette\Database\IStructure; use Nette\Database\SqlLiteral; -use function array_flip, array_keys, array_map, array_merge, array_pop, array_shift, array_unshift, array_values, count, end, explode, hash, implode, is_array, iterator_to_array, json_encode, key, preg_match, preg_match_all, preg_replace, preg_replace_callback, rtrim, str_contains, str_repeat, strlen, strtoupper, substr, substr_count, substr_replace, trim; +use function array_flip, array_keys, array_map, array_merge, array_pop, array_shift, array_unshift, array_values, count, end, explode, hash, implode, is_array, iterator_to_array, key, preg_match, preg_match_all, preg_replace, preg_replace_callback, rtrim, serialize, str_contains, str_repeat, strlen, strtoupper, substr, substr_count, substr_replace, trim; /** @@ -144,7 +144,7 @@ public function getSelectQueryHash(?array $columns = null): string $parts[] = "{$this->delimitedTable}.*"; } - return $this->getConditionHash(json_encode($parts), [ + return $this->getConditionHash(serialize($parts), [ $this->parameters['select'], $this->parameters['joinCondition'], $this->parameters['where'], @@ -949,7 +949,7 @@ private function getConditionHash(string $condition, array $parameters): string } } - return hash('xxh128', $condition . json_encode($parameters)); + return hash('xxh128', $condition . serialize($parameters)); // serialize() unlike json_encode() does not fail on binary strings } diff --git a/tests/Database/Explorer/SqlBuilder.addWhere().phpt b/tests/Database/Explorer/SqlBuilder.addWhere().phpt index 3c07a3756..7da597c94 100644 --- a/tests/Database/Explorer/SqlBuilder.addWhere().phpt +++ b/tests/Database/Explorer/SqlBuilder.addWhere().phpt @@ -26,6 +26,15 @@ test('combine duplicate where conditions, ignoring repetition', function () use }); +test('conditions differing only in binary parameters are not deduplicated', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + Assert::true($sqlBuilder->addWhere('title > ?', "\xC0\x01")); // non-UTF-8 values, json_encode() fails on them + Assert::true($sqlBuilder->addWhere('title > ?', "\xC0\x02")); + Assert::same(reformat('SELECT * FROM [book] WHERE ([title] > ?) AND ([title] > ?)'), $sqlBuilder->buildSelectQuery()); + Assert::count(2, $sqlBuilder->getParameters()); +}); + + test('handle named placeholders with mixed conditions', function () use ($explorer) { $sqlBuilder = new SqlBuilder('book', $explorer); $sqlBuilder->addWhere('?name ?', 'id', 3); From 2cca49f0326d2ab6a90bbba075209569cb13ddd1 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:42:37 +0200 Subject: [PATCH 17/51] SqlBuilder: WHERE and JOIN conditions are deduplicated independently, a WHERE identical to an existing JOIN condition is no longer dropped --- src/Database/Table/SqlBuilder.php | 12 ++++++++++-- tests/Database/Explorer/SqlBuilder.addWhere().phpt | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 30285af3e..33ba43d1d 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -60,6 +60,9 @@ class SqlBuilder /** @var array alias => chain */ protected array $aliases = []; protected string $currentAlias = ''; + + /** distinguishes WHERE vs JOIN conditions in the dedup hash; a subclass calling addCondition() directly gets the WHERE scope */ + private string $conditionScope = ''; private readonly Driver $driver; private readonly IStructure $structure; @@ -306,7 +309,12 @@ public function addJoinCondition(string $tableChain, string|array $condition, mi $this->joinCondition[$tableChain] = $this->parameters['joinCondition'][$tableChain] = []; } - return $this->addCondition($condition, $params, $this->joinCondition[$tableChain], $this->parameters['joinCondition'][$tableChain]); + $this->conditionScope = "join:$tableChain\x00"; + try { + return $this->addCondition($condition, $params, $this->joinCondition[$tableChain], $this->parameters['joinCondition'][$tableChain]); + } finally { + $this->conditionScope = ''; + } } @@ -332,7 +340,7 @@ protected function addCondition( return $this->addConditionComposition($condition, $params[0], $conditions, $conditionsParameters); } - $hash = $this->getConditionHash($condition, $params); + $hash = $this->getConditionHash($this->conditionScope . $condition, $params); if (isset($this->conditions[$hash])) { return false; } diff --git a/tests/Database/Explorer/SqlBuilder.addWhere().phpt b/tests/Database/Explorer/SqlBuilder.addWhere().phpt index 7da597c94..00d8b5928 100644 --- a/tests/Database/Explorer/SqlBuilder.addWhere().phpt +++ b/tests/Database/Explorer/SqlBuilder.addWhere().phpt @@ -35,6 +35,16 @@ test('conditions differing only in binary parameters are not deduplicated', func }); +test('identical WHERE and JOIN conditions are deduplicated independently', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('author', $explorer); + Assert::true($sqlBuilder->addJoinCondition(':book(translator)', ':book(translator).title ?', 'X')); + Assert::true($sqlBuilder->addWhere(':book(translator).title ?', 'X')); // same condition in WHERE must still be added + Assert::false($sqlBuilder->addWhere(':book(translator).title ?', 'X')); // duplicate WHERE + Assert::false($sqlBuilder->addJoinCondition(':book(translator)', ':book(translator).title ?', 'X')); // duplicate JOIN + Assert::count(2, $sqlBuilder->getParameters()); +}); + + test('handle named placeholders with mixed conditions', function () use ($explorer) { $sqlBuilder = new SqlBuilder('book', $explorer); $sqlBuilder->addWhere('?name ?', 'id', 3); From c98bff0223aa00a078b2ad0a7a98b23d4ed1c4c3 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:47:26 +0200 Subject: [PATCH 18/51] SqlBuilder: buildSelectQuery() and getParameters() no longer permanently mutate the builder with the implicit ORDER BY --- src/Database/Table/SqlBuilder.php | 18 ++++++++++++++- tests/Database/Explorer/SqlBuilder.order.phpt | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 33ba43d1d..6191439fe 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -164,6 +164,7 @@ public function getSelectQueryHash(?array $columns = null): string */ public function buildSelectQuery(?array $columns = null): string { + $order = $this->order; if (!$this->order && ($this->limit !== null || $this->offset)) { $this->order = array_map( fn($col) => "$this->tableName.$col", @@ -171,6 +172,17 @@ public function buildSelectQuery(?array $columns = null): string ); } + try { + return $this->doBuildSelectQuery($columns); + } finally { + $this->order = $order; // the implicit ORDER BY must not persist in the builder state + } + } + + + /** @param list|null $columns */ + private function doBuildSelectQuery(?array $columns): string + { $queryJoinConditions = $this->buildJoinConditions(); $queryCondition = $this->buildConditions(); $queryEnd = $this->buildQueryEnd(); @@ -215,7 +227,11 @@ public function buildSelectQuery(?array $columns = null): string public function getParameters(): array { if (!isset($this->parameters['joinConditionSorted'])) { - $this->buildSelectQuery(); + if ($this->joinCondition) { + $this->buildSelectQuery(); + } else { + $this->parameters['joinConditionSorted'] = []; + } } return array_values(array_merge( diff --git a/tests/Database/Explorer/SqlBuilder.order.phpt b/tests/Database/Explorer/SqlBuilder.order.phpt index 4a2a06498..5666a4acf 100644 --- a/tests/Database/Explorer/SqlBuilder.order.phpt +++ b/tests/Database/Explorer/SqlBuilder.order.phpt @@ -12,6 +12,8 @@ require __DIR__ . '/../../bootstrap.php'; $explorer = connectToDB(); +Nette\Database\Helpers::loadFromFile($explorer->getConnection(), __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + test('add multiple order conditions with parameters', function () use ($explorer) { $sqlBuilder = new SqlBuilder('book', $explorer); $sqlBuilder->addOrder('id'); @@ -32,3 +34,24 @@ test('set order conditions replacing previous orders', function () use ($explore Assert::same(reformat('SELECT * FROM [book] ORDER BY FIELD([title], ?, ?)'), $sqlBuilder->buildSelectQuery()); Assert::same(['a', 'b'], $sqlBuilder->getParameters()); }); + + +test('implicit ORDER BY of a limited query does not leak into builder state', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + $sqlBuilder->setLimit(5, null); + + $sql = $sqlBuilder->buildSelectQuery(); + Assert::match('%a%ORDER BY%a%', $sql); + Assert::same([], $sqlBuilder->getOrder()); // implicit order is not a builder state + Assert::same($sql, $sqlBuilder->buildSelectQuery()); // repeated build gives the same query +}); + + +test('getParameters() does not mutate the builder', function () use ($explorer) { + $sqlBuilder = new SqlBuilder('book', $explorer); + $sqlBuilder->addWhere('id > ?', 1); + $sqlBuilder->setLimit(5, null); + + Assert::same([1], $sqlBuilder->getParameters()); + Assert::same([], $sqlBuilder->getOrder()); +}); From a095c8d364814205dafda9e1d106213e8daabd86 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:53:14 +0200 Subject: [PATCH 19/51] GroupedSelection: limit() offset is no longer ignored with multiple parent rows and no longer lost from the builder after execution --- src/Database/Table/GroupedSelection.php | 15 ++++++++++----- .../Database/Explorer/Explorer.related().phpt | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index d40434541..8280fd0a3 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -164,24 +164,29 @@ protected function execute(): void $this->accessedColumns = $accessedColumns; $limit = $this->sqlBuilder->getLimit(); + $offset = $this->sqlBuilder->getOffset(); $rows = count($this->refTable->rows ?? []); if ($limit && $rows > 1) { $this->sqlBuilder->setLimit(null, null); } - parent::execute(); - $this->sqlBuilder->setLimit($limit, null); + try { + parent::execute(); + } finally { + $this->sqlBuilder->setLimit($limit, $offset); + } + $data = []; - $offset = []; + $skips = []; $this->accessColumn($this->column); foreach ((array) $this->rows as $key => $row) { $ref = &$data[$row[$this->column]]; - $skip = &$offset[$row[$this->column]]; + $skip = &$skips[$row[$this->column]]; if ( $limit === null || $rows <= 1 || (count($ref ?? []) < $limit - && $skip >= $this->sqlBuilder->getOffset()) + && $skip >= $offset) ) { $ref[$key] = $row; } else { diff --git a/tests/Database/Explorer/Explorer.related().phpt b/tests/Database/Explorer/Explorer.related().phpt index 148a28cc9..e03445a00 100644 --- a/tests/Database/Explorer/Explorer.related().phpt +++ b/tests/Database/Explorer/Explorer.related().phpt @@ -97,3 +97,22 @@ test('conditional related entry fetching', function () use ($explorer) { Assert::same('JUSH', $author->related('book', null)->where('translator_id', null)->fetch()->title); }); + + +test('related() with limit and offset applies the offset for every parent row', function () use ($explorer) { + $titles = []; + foreach ($explorer->table('author')->where('id', [11, 12]) as $author) { + foreach ($author->related('book')->order('book.id')->limit(1, 1) as $book) { + $titles[] = $book->title; + } + } + + Assert::same(['JUSH', 'Dibi'], $titles); +}); + + +test('related() with limit and offset, single parent row', function () use ($explorer) { + $author = $explorer->table('author')->get(11); + $books = $author->related('book')->order('book.id')->limit(1, 1); + Assert::same(['JUSH'], array_values($books->fetchPairs(null, 'title'))); +}); From c8629f5ce965606c19b9bc6633678b593002b109 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:57:13 +0200 Subject: [PATCH 20/51] Selection: SELECT narrowing is disabled for tables without a primary key, a re-query used to crash with LogicException --- src/Database/Table/Selection.php | 4 ++-- tests/Database/Explorer/Explorer.cache.phpt | 24 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index f12cd4477..3ab2e2b61 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -154,7 +154,7 @@ public function getSql(): string */ public function getPreviousAccessedColumns(): array { - if ($this->cache && $this->previousAccessedColumns === null) { + if ($this->cache && $this->primary !== null && $this->previousAccessedColumns === null) { $this->accessedColumns = $this->previousAccessedColumns = $this->cache->load($this->getGeneralCacheKey()); $this->previousAccessedColumns ??= []; } @@ -733,7 +733,7 @@ protected function getSpecificCacheKey(): string */ public function accessColumn(?string $key, bool $selectColumn = true): bool { - if (!$this->cache) { + if (!$this->cache || $this->primary === null) { // narrowing needs the primary key to re-query missing columns return false; } diff --git a/tests/Database/Explorer/Explorer.cache.phpt b/tests/Database/Explorer/Explorer.cache.phpt index 64b863a9b..07fc80a41 100644 --- a/tests/Database/Explorer/Explorer.cache.phpt +++ b/tests/Database/Explorer/Explorer.cache.phpt @@ -223,3 +223,27 @@ test('SQL query logging with caching', function () use ($explorer) { reformat('SELECT [id], [title], [translator_id] FROM [book] WHERE ([author_id] = ?)'), ], $sql); }); + + +test('table without primary key never narrows the select', function () use ($explorer) { + $explorer->table('note')->insert(['book_id' => 1, 'note' => 'test note']); + + $sql = []; + for ($i = 0; $i < 2; ++$i) { + $selection = $explorer->table('note'); + $sql[] = $selection->getSql(); + foreach ($selection as $row) { + $row->book_id; + if ($i > 0) { + Assert::same('test note', $row->note); // reading a column unknown to the cache must not throw + } + } + + $selection->__destruct(); + } + + Assert::same([ + reformat('SELECT * FROM [note]'), + reformat('SELECT * FROM [note]'), // never narrowed, a re-query would need the primary key + ], $sql); +}); From a9256cc6c2600018d612c1f2aef2116422f742fb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 13:58:15 +0200 Subject: [PATCH 21/51] ActiveRow: related() on a composite primary key throws NotSupportedException instead of TypeError --- src/Database/Table/ActiveRow.php | 9 +++++++-- .../Database/Explorer/Explorer.multi-primary-key.phpt | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 53d8e706d..4164fe4a5 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -8,7 +8,7 @@ namespace Nette\Database\Table; use Nette; -use function array_intersect_key, array_key_exists, array_keys, implode, is_array, iterator_to_array; +use function array_intersect_key, array_key_exists, array_keys, implode, is_array, is_string, iterator_to_array; /** @@ -135,7 +135,12 @@ public function ref(string $key, ?string $throughColumn = null): ?self */ public function related(string $key, ?string $throughColumn = null): GroupedSelection { - $groupedSelection = $this->table->getReferencingTable($key, $throughColumn, $this->__get($this->table->getPrimary())); + $primary = $this->table->getPrimary(); + if (!is_string($primary)) { + throw new Nette\NotSupportedException('related() does not support tables with a composite primary key.'); + } + + $groupedSelection = $this->table->getReferencingTable($key, $throughColumn, $this->__get($primary)); if (!$groupedSelection) { throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->related($key)."); } diff --git a/tests/Database/Explorer/Explorer.multi-primary-key.phpt b/tests/Database/Explorer/Explorer.multi-primary-key.phpt index 9d76650ae..204c760db 100644 --- a/tests/Database/Explorer/Explorer.multi-primary-key.phpt +++ b/tests/Database/Explorer/Explorer.multi-primary-key.phpt @@ -64,3 +64,13 @@ test('insert into multi-key table', function () use ($explorer) { $count = $explorer->table('book_tag')->where('book_id', 1)->count('*'); Assert::same(2, $count); }); + + +test('related() on a composite primary key row throws a comprehensible exception', function () use ($explorer) { + $bookTag = $explorer->table('book_tag')->where('book_id', 1)->fetch(); + Assert::exception( + fn() => $bookTag->related('whatever'), + Nette\NotSupportedException::class, + 'related() does not support tables with a composite primary key.', + ); +}); From 8a485fbb56912c8881ec3c0dd381a5b551fce4ce Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:03:10 +0200 Subject: [PATCH 22/51] Connection: transaction() no longer masks the original exception when the final ROLLBACK fails (e.g. after a deadlock) --- docs/internals/transactions.md | 6 +++--- src/Database/Connection.php | 7 ++++++- tests/Database/Connection.transaction.phpt | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/internals/transactions.md b/docs/internals/transactions.md index 813b9782d..89e7b5e61 100644 --- a/docs/internals/transactions.md +++ b/docs/internals/transactions.md @@ -10,9 +10,9 @@ Nesting is **counter-based only** — there are **no savepoints**. - a real `BEGIN` is issued only when `transactionDepth === 0`; a nested call merely increments the counter and **emits no SQL**; - `COMMIT` likewise only at depth 0; on an exception, `ROLLBACK` only when it unwinds - back to depth 0, then the exception is rethrown. The rollback itself is **not** - guarded — if the server already rolled back (e.g. after a deadlock) and the - `ROLLBACK` fails, that failure replaces the original exception. + back to depth 0, then the exception is rethrown. Any failure of the rollback itself + (e.g. when the server already rolled back after a deadlock, or an `onQuery` handler + throws) is swallowed so it cannot mask the original exception. **The consequence to internalize:** a nested `transaction()` gives **no partial rollback**. Only the outermost transaction issues real `BEGIN`/`COMMIT`/`ROLLBACK`, so diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 61dd7fb33..d06d40c3f 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -205,6 +205,7 @@ public function commit(): void /** * Rolls back current transaction. * @throws \LogicException when called inside a transaction + * @throws DriverException */ public function rollBack(): void { @@ -232,7 +233,11 @@ public function transaction(callable $callback): mixed } catch (\Throwable $e) { $this->transactionDepth--; if ($this->transactionDepth === 0) { - $this->rollBack(); + try { + $this->rollBack(); + } catch (\Throwable) { + // e.g. after a deadlock the server has already rolled back; the original exception matters more + } } throw $e; diff --git a/tests/Database/Connection.transaction.phpt b/tests/Database/Connection.transaction.phpt index 2e4de1501..1d0625a12 100644 --- a/tests/Database/Connection.transaction.phpt +++ b/tests/Database/Connection.transaction.phpt @@ -110,3 +110,20 @@ test('beginTransaction(), commit() & rollBack() calls are forbidden in transacti Connection::class . '::rollBack() call is forbidden inside a transaction() callback', ); }); + + +test('failed ROLLBACK does not mask the original exception', function () use ($connection) { + Assert::exception( + fn() => $connection->transaction(function (Connection $connection) { + try { + $connection->query('ROLLBACK'); // ends the server-side transaction behind the wrapper's back + } catch (Nette\Database\DriverException $e) { + // some drivers (sqlsrv) complain right away, the transaction is gone either way + } + + throw new RuntimeException('original exception'); + }), + RuntimeException::class, + 'original exception', + ); +}); From 1e60e3ae641ace29e7d0efe01e193d4cb3ee5d53 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:03:53 +0200 Subject: [PATCH 23/51] Connection: unknown PDO driver name reports InvalidStateException instead of a raw class-not-found Error --- src/Database/Connection.php | 7 ++++++- tests/Database/connection.options.sqlite.phpt | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index d06d40c3f..220eb205e 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -12,7 +12,7 @@ use Nette\Utils\Arrays; use PDO; use PDOException; -use function str_replace, ucfirst; +use function class_exists, str_replace, ucfirst; /** @@ -72,6 +72,11 @@ public function connect(): void $class = empty($this->options['driverClass']) ? 'Nette\Database\Drivers\\' . ucfirst(str_replace('sql', 'Sql', $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME))) . 'Driver' : $this->options['driverClass']; + if (!class_exists($class)) { + throw new Nette\InvalidStateException(empty($this->options['driverClass']) + ? "Driver class '$class' not found, specify it using the 'driverClass' option." + : "Driver class '$class' not found."); + } $driver = new $class; if (!$driver instanceof Driver) { throw new Nette\InvalidStateException("Driver class '$class' does not implement " . Driver::class . '.'); diff --git a/tests/Database/connection.options.sqlite.phpt b/tests/Database/connection.options.sqlite.phpt index f70bc148a..a89c6eddb 100644 --- a/tests/Database/connection.options.sqlite.phpt +++ b/tests/Database/connection.options.sqlite.phpt @@ -21,3 +21,11 @@ test('formatDateTime', function () { $driver = $connection->getDriver(); Assert::same('1978-01-23', $driver->formatDateTime(new DateTime('1978-01-23 00:00:00'))); }); + +test('unknown driverClass', function () { + Assert::exception( + fn() => new Nette\Database\Connection('sqlite::memory:', options: ['driverClass' => 'UnknownDriverClass']), + Nette\InvalidStateException::class, + "Driver class 'UnknownDriverClass' not found.", + ); +}); From 1593fc8c057d64d61db72729aa7c5f30c974fa3b Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:06:36 +0200 Subject: [PATCH 24/51] Drivers: completed exception mapping (SQL Server constraint violations, SQLite CHECK, PostgreSQL 57P0x and MySQL 4031 as connection lost) --- src/Database/Drivers/MsSqlDriver.php | 19 ++++++- src/Database/Drivers/MySqlDriver.php | 11 +++- src/Database/Drivers/PgSqlDriver.php | 3 + src/Database/Drivers/SqliteDriver.php | 5 +- src/Database/Drivers/SqlsrvDriver.php | 19 ++++++- .../Connection.exceptions.sqlite.phpt | 14 +++++ .../Connection.exceptions.sqlsrv.phpt | 57 +++++++++++++++++++ 7 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 tests/Database/Connection.exceptions.sqlsrv.phpt diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index 414b9f525..b734b7ffb 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_values, explode, preg_replace, str_replace, strtoupper, strtr; +use function array_values, explode, preg_replace, str_contains, str_replace, strtoupper, strtr; /** @@ -34,7 +34,22 @@ public function isSupported(string $feature): bool public function convertException(\PDOException $e): Nette\Database\DriverException { $code = $e->errorInfo[1] ?? null; - if ($code === 1205) { + if ($code === 2627 || $code === 2601) { + return Nette\Database\UniqueConstraintViolationException::from($e); + + } elseif ($code === 515) { + return Nette\Database\NotNullConstraintViolationException::from($e); + + } elseif ($code === 547) { + return match (true) { + str_contains($e->getMessage(), 'CHECK constraint') => Nette\Database\CheckConstraintViolationException::from($e), + str_contains($e->getMessage(), 'FOREIGN KEY constraint'), + str_contains($e->getMessage(), 'REFERENCE constraint') => Nette\Database\ForeignKeyConstraintViolationException::from($e), + // the message is localized per login language and cannot be classified further + default => Nette\Database\ConstraintViolationException::from($e), + }; + + } elseif ($code === 1205) { return Nette\Database\DeadlockException::from($e); } elseif ($code === 1222) { diff --git a/src/Database/Drivers/MySqlDriver.php b/src/Database/Drivers/MySqlDriver.php index 5be0dac7e..84d551153 100644 --- a/src/Database/Drivers/MySqlDriver.php +++ b/src/Database/Drivers/MySqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function addcslashes, array_change_key_case, array_values, in_array, str_replace, strtoupper, substr; +use function addcslashes, array_change_key_case, array_values, in_array, str_contains, str_replace, strtoupper, substr; /** @@ -66,7 +66,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif (in_array($code, [1062, 1557, 1569, 1586], strict: true)) { return Nette\Database\UniqueConstraintViolationException::from($e); - } elseif ($code === 3819) { + } elseif ($code === 3819 || $code === 4025) { // 3819 = MySQL, 4025 = MariaDB return Nette\Database\CheckConstraintViolationException::from($e); } elseif ($code === 1213) { @@ -75,7 +75,12 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif ($code === 1205) { return Nette\Database\LockTimeoutException::from($e); - } elseif ($code === 2006 || $code === 2013) { + } elseif ( + $code === 2006 + || $code === 2013 + // ER_CLIENT_INTERACTION_TIMEOUT (MySQL 8.0.24+); on MariaDB 4031 is an unrelated trigger error + || ($code === 4031 && str_contains($e->getMessage(), 'disconnected')) + ) { return Nette\Database\ConnectionLostException::from($e); } elseif ($code >= 2001 && $code <= 2028) { diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index 5dcc808ae..ce4e45610 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -58,6 +58,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti } elseif ( $code === '08003' || $code === '08006' + || $code === '57P01' // admin_shutdown, e.g. pg_terminate_backend() or server restart + || $code === '57P02' // crash_shutdown + || $code === '57P03' // cannot_connect_now || ($code === 'HY000' && str_contains($e->getMessage(), 'server closed the connection unexpectedly')) ) { return Nette\Database\ConnectionLostException::from($e); diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 6290492ac..963acdeeb 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -48,7 +48,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti return Nette\Database\UniqueConstraintViolationException::from($e); } elseif ( - str_contains($msg, 'may not be null') + str_contains($msg, 'may not be NULL') || str_contains($msg, 'NOT NULL constraint failed') ) { return Nette\Database\NotNullConstraintViolationException::from($e); @@ -59,6 +59,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti ) { return Nette\Database\ForeignKeyConstraintViolationException::from($e); + } elseif (str_contains($msg, 'CHECK constraint failed')) { + return Nette\Database\CheckConstraintViolationException::from($e); + } else { return Nette\Database\ConstraintViolationException::from($e); } diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index 67483334c..36a2fbebf 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_values, str_replace, strtr; +use function array_values, str_contains, str_replace, strtr; /** @@ -34,7 +34,22 @@ public function isSupported(string $feature): bool public function convertException(\PDOException $e): Nette\Database\DriverException { $code = $e->errorInfo[1] ?? null; - if ($code === 1205) { + if ($code === 2627 || $code === 2601) { + return Nette\Database\UniqueConstraintViolationException::from($e); + + } elseif ($code === 515) { + return Nette\Database\NotNullConstraintViolationException::from($e); + + } elseif ($code === 547) { + return match (true) { + str_contains($e->getMessage(), 'CHECK constraint') => Nette\Database\CheckConstraintViolationException::from($e), + str_contains($e->getMessage(), 'FOREIGN KEY constraint'), + str_contains($e->getMessage(), 'REFERENCE constraint') => Nette\Database\ForeignKeyConstraintViolationException::from($e), + // the message is localized per login language and cannot be classified further + default => Nette\Database\ConstraintViolationException::from($e), + }; + + } elseif ($code === 1205) { return Nette\Database\DeadlockException::from($e); } elseif ($code === 1222) { diff --git a/tests/Database/Connection.exceptions.sqlite.phpt b/tests/Database/Connection.exceptions.sqlite.phpt index ce94d1a37..ad40fa0c8 100644 --- a/tests/Database/Connection.exceptions.sqlite.phpt +++ b/tests/Database/Connection.exceptions.sqlite.phpt @@ -86,3 +86,17 @@ test('Exception thrown for foreign key constraint violation', function () use ($ Assert::same(19, $e->getDriverCode()); Assert::same($e->getCode(), $e->getSqlState()); }); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $connection->query('CREATE TABLE check_test (id int, price int CHECK (price >= 0))'); + + $e = Assert::exception( + fn() => $connection->query('INSERT INTO check_test (id, price) VALUES (1, -5)'), + Nette\Database\CheckConstraintViolationException::class, + '%a% CHECK constraint failed%a%', + '23000', + ); + + Assert::same(19, $e->getDriverCode()); +}); diff --git a/tests/Database/Connection.exceptions.sqlsrv.phpt b/tests/Database/Connection.exceptions.sqlsrv.phpt new file mode 100644 index 000000000..06a2dda02 --- /dev/null +++ b/tests/Database/Connection.exceptions.sqlsrv.phpt @@ -0,0 +1,57 @@ +getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/files/{$driverName}-nette_test1.sql"); + +$connection->query('DROP TABLE IF EXISTS exc_test'); +$connection->query('CREATE TABLE exc_test (id INT NOT NULL PRIMARY KEY, price INT CHECK (price >= 0))'); +$connection->query('INSERT INTO exc_test (id, price) VALUES (1, 1)'); + + +test('Exception thrown for unique constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (1, 1)'), + Nette\Database\UniqueConstraintViolationException::class, + ); + + Assert::same(2627, $e->getDriverCode()); +}); + + +test('Exception thrown for not null constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (NULL, 1)'), + Nette\Database\NotNullConstraintViolationException::class, + ); + + Assert::same(515, $e->getDriverCode()); +}); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO exc_test (id, price) VALUES (2, -5)'), + Nette\Database\CheckConstraintViolationException::class, + ); + + Assert::same(547, $e->getDriverCode()); +}); + + +test('Exception thrown for foreign key constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query("INSERT INTO book (author_id, translator_id, title) VALUES (999, 12, '')"), + Nette\Database\ForeignKeyConstraintViolationException::class, + ); + + Assert::same(547, $e->getDriverCode()); +}); From 042a7f8a0f9ff627151842114ddb7b7ac8478142 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:08:51 +0200 Subject: [PATCH 25/51] SqlsrvDriver: applyLimit() no longer generates invalid SQL for offset-only and zero-limit queries --- src/Database/Drivers/SqlsrvDriver.php | 11 ++++++++--- tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt | 8 ++++++-- tests/Database/Explorer/Explorer.limit.sqlsrv.phpt | 10 +++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index 36a2fbebf..064057a2b 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_values, str_contains, str_replace, strtr; +use function array_values, preg_replace, str_contains, str_replace, strtr; /** @@ -96,10 +96,15 @@ public function applyLimit(string &$sql, ?int $limit, ?int $offset): void if ($limit < 0 || $offset < 0) { throw new Nette\InvalidArgumentException('Negative offset or limit.'); + } elseif ($limit === 0) { // FETCH NEXT 0 is rejected by the server + $sql = preg_replace('#^\s*(SELECT(\s+DISTINCT|\s+ALL)?)#i', '$0 TOP 0', $sql, 1, $count); + if (!$count) { + throw new Nette\InvalidArgumentException('SQL query must begin with SELECT command.'); + } } elseif ($limit !== null || $offset) { // requires ORDER BY, see https://technet.microsoft.com/en-us/library/gg699618(v=sql.110).aspx - $sql .= ' OFFSET ' . (int) $offset . ' ROWS ' - . 'FETCH NEXT ' . (int) $limit . ' ROWS ONLY'; + $sql .= ' OFFSET ' . (int) $offset . ' ROWS' + . ($limit !== null ? " FETCH NEXT $limit ROWS ONLY" : ''); } } diff --git a/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt b/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt index 34668674f..274b18d54 100644 --- a/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt +++ b/tests/Database/Drivers/SqlsrvDriver.applyLimit.phpt @@ -13,7 +13,11 @@ Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 0, 20); -Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 0 ROWS ONLY', $query); +Assert::same('SELECT TOP 0 1 FROM t', $query); + +$query = 'SELECT 1 FROM t'; +$driver->applyLimit($query, 0, null); +Assert::same('SELECT TOP 0 1 FROM t', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 10, 0); @@ -21,7 +25,7 @@ Assert::same('SELECT 1 FROM t OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, null, 20); -Assert::same('SELECT 1 FROM t OFFSET 20 ROWS FETCH NEXT 0 ROWS ONLY', $query); +Assert::same('SELECT 1 FROM t OFFSET 20 ROWS', $query); $query = 'SELECT 1 FROM t'; $driver->applyLimit($query, 10, null); diff --git a/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt b/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt index 8d7756e24..531cec02c 100644 --- a/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt +++ b/tests/Database/Explorer/Explorer.limit.sqlsrv.phpt @@ -38,9 +38,7 @@ Assert::same( ); Assert::same( - $version2008 - ? 'SELECT TOP 0 * FROM [author] ORDER BY [author].[id]' - : 'SELECT * FROM [author] ORDER BY [author].[id] OFFSET 0 ROWS FETCH NEXT 0 ROWS ONLY', + 'SELECT TOP 0 * FROM [author] ORDER BY [author].[id]', // FETCH NEXT 0 would be rejected by the server $explorer->table('author')->page(0, 10)->getSql(), ); @@ -70,3 +68,9 @@ if ($version2008) { ); Assert::same(2, $count); } + +// execute against the live server +Assert::count(0, $explorer->table('author')->page(0, 10)->fetchAll()); // limit 0 +if (!$version2008) { + Assert::count(1, $explorer->table('author')->order('id')->limit(null, 2)->fetchAll()); // offset without limit +} From febe4d8478a2b1a12d8314e2802b28879a542490 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:10:22 +0200 Subject: [PATCH 26/51] SqliteDriver: autoincrement detection no longer misfires on substring column names and regex meta-characters --- src/Database/Drivers/SqliteDriver.php | 5 +-- .../Reflection.sqlite.autoincrement.phpt | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/Database/Reflection.sqlite.autoincrement.phpt diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 963acdeeb..e21f35fef 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function addcslashes, array_values, in_array, preg_match, str_contains, strtoupper, strtr, substr; +use function addcslashes, array_values, in_array, preg_match, preg_quote, str_contains, strtoupper, strtr, substr; /** @@ -153,7 +153,8 @@ public function getColumns(string $table): array $rows = $this->connection->query('PRAGMA table_info(?name)', $table); while ($row = $rows->fetch()) { $column = $row['name']; - $pattern = "/(\"$column\"|`$column`|\\[$column]|$column)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; + $q = preg_quote($column, '/'); + $pattern = "/(\"$q\"|`$q`|\\[$q]|\\b$q)\\s+[^,]+\\s+PRIMARY\\s+KEY\\s+AUTOINCREMENT/Ui"; $typeInfo = Nette\Database\Helpers::parseColumnType($row['type']); $columns[] = [ 'name' => $column, diff --git a/tests/Database/Reflection.sqlite.autoincrement.phpt b/tests/Database/Reflection.sqlite.autoincrement.phpt new file mode 100644 index 000000000..02504a413 --- /dev/null +++ b/tests/Database/Reflection.sqlite.autoincrement.phpt @@ -0,0 +1,31 @@ +getConnection(); +$driver = $connection->getDriver(); + + +test('a column whose name is a substring of the autoincrement column is not flagged', function () use ($connection, $driver) { + $connection->query('CREATE TABLE ai_test (id INTEGER, paid INTEGER PRIMARY KEY AUTOINCREMENT)'); + + $autoincrement = array_column($driver->getColumns('ai_test'), 'autoincrement', 'name'); + Assert::false($autoincrement['id']); + Assert::true($autoincrement['paid']); +}); + + +test('a column name with regex meta-characters does not break the detection', function () use ($connection, $driver) { + $connection->query('CREATE TABLE ai_test2 ("price(usd)" TEXT, id INTEGER PRIMARY KEY AUTOINCREMENT)'); + + $autoincrement = array_column($driver->getColumns('ai_test2'), 'autoincrement', 'name'); + Assert::false($autoincrement['price(usd)']); + Assert::true($autoincrement['id']); +}); From fddcba2dc0d4735b4d3baba2991a4580e9b5120d Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:11:24 +0200 Subject: [PATCH 27/51] Reflection: getTable() for an unknown table throws instead of returning a phantom empty table on drivers that do not fail (SQLite) --- src/Database/Reflection.php | 5 +++-- tests/Database/Reflection.phpt | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Database/Reflection.php b/src/Database/Reflection.php index 036f0e9be..8e1ca442f 100644 --- a/src/Database/Reflection.php +++ b/src/Database/Reflection.php @@ -51,8 +51,9 @@ private function tryGetTable(string $name): ?Table { try { $table = new Table($this, $name); - $table->columns; - return $table; + return $table->columns + ? $table + : null; // some drivers (SQLite) report no columns instead of failing for an unknown table } catch (DriverException) { } return null; diff --git a/tests/Database/Reflection.phpt b/tests/Database/Reflection.phpt index fe5e8a6e7..608a652fe 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -215,3 +215,11 @@ switch ($driverName) { Assert::same([$table->getColumn('book_id')], $key->localColumns); Assert::same('book', $key->foreignTable->name); Assert::same([$key->foreignTable->getColumn('id')], $key->foreignColumns); + + +// unknown table +Assert::exception( + fn() => $reflection->getTable('unknown_table'), + InvalidArgumentException::class, + "Table 'unknown_table' not found.", +); From beff82718f0585cc56f39ccf64c6c0010f5ceee6 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:12:06 +0200 Subject: [PATCH 28/51] MsSqlDriver, OdbcDriver: delimite() no longer doubles the [ character, which corrupted identifiers --- src/Database/Drivers/MsSqlDriver.php | 2 +- src/Database/Drivers/OdbcDriver.php | 2 +- tests/Database/Drivers/delimite.phpt | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 tests/Database/Drivers/delimite.phpt diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index b734b7ffb..392359d4a 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -66,7 +66,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { // @see https://msdn.microsoft.com/en-us/library/ms176027.aspx - return '[' . str_replace(['[', ']'], ['[[', ']]'], $name) . ']'; + return '[' . str_replace(']', ']]', $name) . ']'; } diff --git a/src/Database/Drivers/OdbcDriver.php b/src/Database/Drivers/OdbcDriver.php index 9b9403398..d0cc6cb3a 100644 --- a/src/Database/Drivers/OdbcDriver.php +++ b/src/Database/Drivers/OdbcDriver.php @@ -38,7 +38,7 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { - return '[' . str_replace(['[', ']'], ['[[', ']]'], $name) . ']'; + return '[' . str_replace(']', ']]', $name) . ']'; // only ] is escaped by doubling in bracketed identifiers } diff --git a/tests/Database/Drivers/delimite.phpt b/tests/Database/Drivers/delimite.phpt new file mode 100644 index 000000000..262f467c8 --- /dev/null +++ b/tests/Database/Drivers/delimite.phpt @@ -0,0 +1,17 @@ +delimite('hello')); + Assert::same('[a[b]', $driver->delimite('a[b')); + Assert::same('[a]]b]', $driver->delimite('a]b')); +} From dbc0f19952785fdb70aa3ae69f0b665ec7aa6e28 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:18:50 +0200 Subject: [PATCH 29/51] MySqlDriver, PgSqlDriver: reflection fixes (composite FK and index column order, functional index parts, view comment, per-connection column-type cache) --- phpstan.neon | 11 ------ src/Database/Drivers/MySqlDriver.php | 7 ++-- src/Database/Drivers/PgSqlDriver.php | 21 +++++++---- .../Reflection.foreignKeys.mysql.phpt | 27 ++++++++++++++ tests/Database/Reflection.indexOrder.phpt | 35 +++++++++++++++++++ 5 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 tests/Database/Reflection.foreignKeys.mysql.phpt create mode 100644 tests/Database/Reflection.indexOrder.phpt diff --git a/phpstan.neon b/phpstan.neon index b8f9f42ff..3de03d356 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -83,12 +83,6 @@ parameters: count: 1 path: src/Bridges/DatabaseTracy/ConnectionPanel.php - # Lazy-loading side effect via __get magic - - - identifier: expr.resultUnused - count: 1 - path: src/Database/Reflection.php - # DI extension: $this->config is array|object from Nette Schema - identifier: foreach.nonIterable @@ -116,11 +110,6 @@ parameters: count: 1 path: src/Database/SqlPreprocessor.php - # getPrimary() returns string for single-column PK (composite PK not supported here) - - - identifier: argument.type - count: 1 - path: src/Database/Table/ActiveRow.php - identifier: array.invalidKey count: 1 diff --git a/src/Database/Drivers/MySqlDriver.php b/src/Database/Drivers/MySqlDriver.php index 84d551153..b0d69e892 100644 --- a/src/Database/Drivers/MySqlDriver.php +++ b/src/Database/Drivers/MySqlDriver.php @@ -154,7 +154,7 @@ public function getTables(): array $tables[] = [ 'name' => (string) $row['TABLE_NAME'], 'view' => $row['TABLE_TYPE'] === 'VIEW', - 'comment' => (string) $row['TABLE_COMMENT'], + 'comment' => $row['TABLE_TYPE'] === 'VIEW' ? '' : (string) $row['TABLE_COMMENT'], // views have the literal comment 'VIEW' ]; } @@ -199,7 +199,9 @@ public function getIndexes(string $table): array 'primary' => $id === 'PRIMARY', 'columns' => [], ]; - $indexes[$id]['columns'][(int) $row['Seq_in_index'] - 1] = (string) $row['Column_name']; + $indexes[$id]['columns'][(int) $row['Seq_in_index'] - 1] = $row['Column_name'] === null + ? (string) ($row['Expression'] ?? '') // functional index part + : (string) $row['Column_name']; } foreach ($indexes as &$index) { @@ -220,6 +222,7 @@ public function getForeignKeys(string $table): array WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL AND TABLE_NAME = ? + ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION X, $table); while ($row = $rows->fetch()) { diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index ce4e45610..c2ad5a0b8 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function array_map, array_values, explode, implode, str_contains, str_replace, strtr, substr; +use function array_map, array_values, count, explode, implode, str_contains, str_replace, strtr, substr; /** @@ -18,6 +18,9 @@ class PgSqlDriver implements Nette\Database\Driver { private Nette\Database\Connection $connection; + /** @var array> query => column types */ + private array $columnTypesCache = []; + public function initialize(Nette\Database\Connection $connection, array $options): void { @@ -219,15 +222,17 @@ public function getIndexes(string $table): array c2.relname::varchar AS name, i.indisunique AS unique, i.indisprimary AS primary, - a.attname::varchar AS column + coalesce(a.attname, pg_catalog.pg_get_indexdef(i.indexrelid, k.ord::int, TRUE))::varchar AS column -- expression part when attnum = 0 FROM pg_catalog.pg_class AS c1 JOIN pg_catalog.pg_index AS i ON c1.oid = i.indrelid JOIN pg_catalog.pg_class AS c2 ON i.indexrelid = c2.oid - LEFT JOIN pg_catalog.pg_attribute AS a ON c1.oid = a.attrelid AND a.attnum = ANY(i.indkey) + CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) + LEFT JOIN pg_catalog.pg_attribute AS a ON c1.oid = a.attrelid AND a.attnum = k.attnum WHERE c1.relkind IN ('r', 'p') AND c1.oid = ?::regclass + ORDER BY c2.relname, k.ord X, $this->delimiteFQN($table)); while ($row = $rows->fetch()) { @@ -283,10 +288,12 @@ public function getForeignKeys(string $table): array public function getColumnTypes(\PDOStatement $statement): array { - static $cache; - $item = &$cache[$statement->queryString]; - $item ??= Nette\Database\Helpers::detectTypes($statement); - return $item; + // per-instance cache, a process-wide one would leak between connections to different databases + if (count($this->columnTypesCache) > 1000) { + $this->columnTypesCache = []; + } + + return $this->columnTypesCache[$statement->queryString] ??= Nette\Database\Helpers::detectTypes($statement); } diff --git a/tests/Database/Reflection.foreignKeys.mysql.phpt b/tests/Database/Reflection.foreignKeys.mysql.phpt new file mode 100644 index 000000000..604d02c2b --- /dev/null +++ b/tests/Database/Reflection.foreignKeys.mysql.phpt @@ -0,0 +1,27 @@ +getConnection(); +$driver = $connection->getDriver(); + +$connection->query('SET foreign_key_checks = 0'); +$connection->query('DROP TABLE IF EXISTS fk_child, fk_parent'); +$connection->query('SET foreign_key_checks = 1'); +$connection->query('CREATE TABLE fk_parent (b INT NOT NULL, a INT NOT NULL, PRIMARY KEY (b, a))'); +$connection->query('CREATE TABLE fk_child (x INT, y INT, CONSTRAINT fk_comp FOREIGN KEY (y, x) REFERENCES fk_parent (b, a))'); + + +test('composite foreign key reports columns in constraint order', function () use ($driver) { + Assert::same([ + ['name' => 'fk_comp', 'local' => 'y', 'table' => 'fk_parent', 'foreign' => 'b'], + ['name' => 'fk_comp', 'local' => 'x', 'table' => 'fk_parent', 'foreign' => 'a'], + ], $driver->getForeignKeys('fk_child')); +}); diff --git a/tests/Database/Reflection.indexOrder.phpt b/tests/Database/Reflection.indexOrder.phpt new file mode 100644 index 000000000..09e61dc83 --- /dev/null +++ b/tests/Database/Reflection.indexOrder.phpt @@ -0,0 +1,35 @@ +getConnection(); +$driver = $connection->getDriver(); + +$connection->query('DROP TABLE IF EXISTS idx_test'); +$connection->query('CREATE TABLE idx_test (a INT NOT NULL, b INT NOT NULL)'); +$connection->query('CREATE INDEX idx_ba ON idx_test (b, a)'); + + +test('multi-column index reports columns in index order, not in table order', function () use ($driver) { + $indexes = array_column($driver->getIndexes('idx_test'), 'columns', 'name'); + Assert::same(['b', 'a'], $indexes['idx_ba']); +}); + + +test('expression index parts are reported as expressions (PostgreSQL)', function () use ($connection, $driver, $driverName) { + if ($driverName !== 'pgsql') { + return; + } + + $connection->query('DROP INDEX IF EXISTS idx_expr'); + $connection->query('CREATE INDEX idx_expr ON idx_test ((a + b), b)'); + $indexes = array_column($driver->getIndexes('idx_test'), 'columns', 'name'); + Assert::same(['(a + b)', 'b'], $indexes['idx_expr']); +}); From 18a9acf827e82946de45bf03c4ff30fb0993878e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:20:16 +0200 Subject: [PATCH 30/51] ConnectionPanel: fixed off-by-one in maxQueries and skips backtrace collection for queries over the cap --- src/Bridges/DatabaseTracy/ConnectionPanel.php | 18 ++++++++++-------- tests/Database.Tracy/ConnectionPanel.phpt | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 9c1b27c2e..63257dd62 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -73,6 +73,13 @@ private function logQuery(Connection $connection, Nette\Database\ResultSet|\PDOE } $this->count++; + if ($result instanceof Nette\Database\ResultSet) { + $this->totalTime += $result->getTime(); + } + + if ($this->count > $this->maxQueries) { // count/time are still tracked, only the query detail is dropped + return; + } $trace = $result instanceof \PDOException ? array_map(fn($row) => array_diff_key($row, ['args' => null]), $result->getTrace()) @@ -90,14 +97,9 @@ private function logQuery(Connection $connection, Nette\Database\ResultSet|\PDOE array_shift($trace); } - if ($result instanceof Nette\Database\ResultSet) { - $this->totalTime += $result->getTime(); - if ($this->count < $this->maxQueries) { - $this->queries[] = [$connection, $result->getQueryString(), $result->getParameters(), $trace, $result->getTime(), $result->getRowCount(), null]; - } - } elseif ($result instanceof \PDOException && $this->count < $this->maxQueries) { - $this->queries[] = [$connection, $result->queryString, null, $trace, null, null, $result->getMessage()]; - } + $this->queries[] = $result instanceof Nette\Database\ResultSet + ? [$connection, $result->getQueryString(), $result->getParameters(), $trace, $result->getTime(), $result->getRowCount(), null] + : [$connection, $result->queryString, null, $trace, null, null, $result->getMessage()]; } diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index ef979c55d..a22d0b5f4 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -58,3 +58,20 @@ test('deprecated initialization', function () { Assert::matchFile(__DIR__ . '/tab.html', $panel->getTab()); Assert::matchFile(__DIR__ . '/panel.html', $panel->getPanel()); }); + + +test('maxQueries caps stored query details but not the count', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'cap'); + $panel->maxQueries = 3; + + for ($i = 1; $i <= 5; $i++) { + $connection->query('SELECT ' . $i); + } + + $queries = (new ReflectionProperty($panel, 'queries'))->getValue($panel); + Assert::count(3, $queries); // exactly maxQueries stored + Assert::same('SELECT 1', $queries[0][1]); + Assert::same('SELECT 3', $queries[2][1]); + Assert::same(5, (new ReflectionProperty($panel, 'count'))->getValue($panel)); +}); From aee80cb3bc950b6d8ac3fef991f444de180e32b6 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:20:58 +0200 Subject: [PATCH 31/51] ConnectionPanel: the BlueScreen SQL panel is registered only once, multiple connections used to render duplicate panels --- src/Bridges/DatabaseTracy/ConnectionPanel.php | 7 ++++++- tests/Database.Tracy/ConnectionPanel.phpt | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 63257dd62..4e9f42de9 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -45,7 +45,12 @@ public static function initialize( ): ?self { $blueScreen ??= Tracy\Debugger::getBlueScreen(); - $blueScreen->addPanel(self::renderException(...)); + static $registered = null; + $registered ??= new \WeakMap; + if (!isset($registered[$blueScreen])) { // multiple connections must not register multiple identical panels + $registered[$blueScreen] = true; + $blueScreen->addPanel(self::renderException(...)); + } if ($addBarPanel) { $panel = new self($connection, $blueScreen); diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index a22d0b5f4..d63dbe571 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -75,3 +75,15 @@ test('maxQueries caps stored query details but not the count', function () { Assert::same('SELECT 3', $queries[2][1]); Assert::same(5, (new ReflectionProperty($panel, 'count'))->getValue($panel)); }); + + +test('BlueScreen panel is registered only once for multiple connections', function () { + $blueScreen = new Tracy\BlueScreen; + $before = count((new ReflectionProperty($blueScreen, 'panels'))->getValue($blueScreen)); + + ConnectionPanel::initialize(new Connection('sqlite::memory:'), blueScreen: $blueScreen); + ConnectionPanel::initialize(new Connection('sqlite::memory:'), blueScreen: $blueScreen); + + $panels = (new ReflectionProperty($blueScreen, 'panels'))->getValue($blueScreen); + Assert::count($before + 1, $panels); +}); From 7d2b897d41371d943abc231b81d0fcdb04f14e30 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:23:11 +0200 Subject: [PATCH 32/51] DatabaseExtension: a single connection is detected by the presence of 'dsn', key order in the config no longer changes semantics --- src/Bridges/DatabaseDI/DatabaseExtension.php | 6 ++--- .../Database.DI/DatabaseExtension.basic.phpt | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index ccec18c49..c6a9d8c63 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -10,7 +10,7 @@ use Nette; use Nette\Schema\Expect; use Tracy; -use function is_array, is_string; +use function array_key_exists, is_array, is_string; /** @@ -38,8 +38,8 @@ public function getConfigSchema(): Nette\Schema\Schema 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions 'autowired' => Expect::bool(), ]), - )->before(fn($val) => is_array(reset($val)) || reset($val) === null - ? $val + )->before(fn($val) => is_array($val) && $val && !array_key_exists('dsn', $val) + ? $val // a set of named connections; a single connection always has the mandatory 'dsn' key : ['default' => $val]); } diff --git a/tests/Database.DI/DatabaseExtension.basic.phpt b/tests/Database.DI/DatabaseExtension.basic.phpt index f5a5bfe0d..96e5978d7 100644 --- a/tests/Database.DI/DatabaseExtension.basic.phpt +++ b/tests/Database.DI/DatabaseExtension.basic.phpt @@ -50,3 +50,28 @@ test('', function () { Assert::same($connection, $container->getService('nette.database.default')); Assert::same($explorer, $container->getService('nette.database.default.context')); }); + + +test('single connection is detected by the dsn key, not by the type of the first value', function () { + $loader = new DI\Config\Loader; + $config = $loader->load(Tester\FileMock::create(' + database: + options: + lazy: yes + dsn: "sqlite::memory:" + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + eval($compiler->addConfig($config)->setClassName('Container3')->compile()); + + $container = new Container3; + $container->initialize(); + + $connection = $container->getService('database.default'); + Assert::type(Nette\Database\Connection::class, $connection); + Assert::same('sqlite::memory:', $connection->getDsn()); +}); From 31000b3ef812c6c4e176f50d446e4537d5172af3 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:31:17 +0200 Subject: [PATCH 33/51] ResultSet: duplicate-column check runs only once per result set, matching the documented behavior --- docs/internals/results-and-types.md | 2 +- src/Database/ResultSet.php | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/internals/results-and-types.md b/docs/internals/results-and-types.md index 833708c91..ca44f220f 100644 --- a/docs/internals/results-and-types.md +++ b/docs/internals/results-and-types.md @@ -11,7 +11,7 @@ special channel: it calls the named PDO method directly (this is how transaction The iterator is **one-way** — `rewind()` throws once the iterator has advanced past the first row. `fetchAssoc` is the core (each `fetch` → `normalizeRow`, with a -duplicate-column check on the first row only); `fetch` wraps it in a `Row`; `fetchAll` +one-time duplicate-column check on the first fetched row); `fetch` wraps it in a `Row`; `fetchAll` caches `iterator_to_array` into `$rows`. `getRowCount()` returns `rowCount()` (affected rows for non-SELECT; `null` for `::`-channel calls with no statement). diff --git a/src/Database/ResultSet.php b/src/Database/ResultSet.php index f53c46d99..710419875 100644 --- a/src/Database/ResultSet.php +++ b/src/Database/ResultSet.php @@ -22,6 +22,7 @@ class ResultSet implements \Iterator, IRowContainer private ?\PDOStatement $pdoStatement = null; private Row|false|null $lastRow = null; private int $lastRowKey = -1; + private bool $duplicatesChecked = false; /** @var list */ private array $rows; @@ -206,9 +207,12 @@ public function fetchAssoc(?string $path = null): ?array $this->pdoStatement?->closeCursor(); return null; - } elseif ($this->lastRow === null && count($data) !== $this->pdoStatement->columnCount()) { - $duplicates = Helpers::findDuplicates($this->pdoStatement); - trigger_error("Found duplicate columns in database result set: $duplicates."); + } elseif (!$this->duplicatesChecked) { + $this->duplicatesChecked = true; + if (count($data) !== $this->pdoStatement->columnCount()) { + $duplicates = Helpers::findDuplicates($this->pdoStatement); + trigger_error("Found duplicate columns in database result set: $duplicates."); + } } return $this->normalizeRow($data); From a59e2b370ca9425d0f1b3d27f1904b13d5f1e987 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:31:17 +0200 Subject: [PATCH 34/51] Helpers: loadFromFile() progress callback no longer divides by zero for streams reporting zero size --- src/Database/Helpers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 0544756b9..b499216cb 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -283,7 +283,7 @@ public static function loadFromFile(Connection $connection, string $file, ?calla $sql = ''; $count++; if ($onProgress) { - $onProgress($count, isset($stat['size']) ? $size * 100 / $stat['size'] : null); + $onProgress($count, empty($stat['size']) ? null : $size * 100 / $stat['size']); } } else { $sql .= $s; @@ -294,7 +294,7 @@ public static function loadFromFile(Connection $connection, string $file, ?calla $pdo->exec($sql); $count++; if ($onProgress) { - $onProgress($count, isset($stat['size']) ? 100 : null); + $onProgress($count, empty($stat['size']) ? null : 100); } } From efb41baf2746a4faaa164ee864087f38dac6e4b7 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:31:17 +0200 Subject: [PATCH 35/51] DriverException: removed dead assignment in from() --- src/Database/DriverException.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/DriverException.php b/src/Database/DriverException.php index 41a29d47d..2ed894a00 100644 --- a/src/Database/DriverException.php +++ b/src/Database/DriverException.php @@ -35,7 +35,6 @@ public static function from(\PDOException $src): static $e->code = $m[1]; } else { $e->errorInfo = $src->errorInfo; - $e->code = $src->code; $e->code = $e->errorInfo[0] ?? $src->code; } From 9ade77d33c9f71cc7c0d697f3763649ce5960209 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:31:17 +0200 Subject: [PATCH 36/51] fixed misleading phpDoc and messages: createActiveRow() promised absent feature, stale arrayMode comment, multi-insert message typo, AGENTS.md PHPStan level --- AGENTS.md | 2 +- phpstan.neon | 8 +------- src/Database/Explorer.php | 2 +- src/Database/SqlPreprocessor.php | 4 ++-- tests/Database/SqlPreprocessor.phpt | 2 +- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 882402e31..bf7331dff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ vendor/bin/tester tests -s -C vendor/bin/tester tests/Database/Explorer -s -C vendor/bin/tester tests/Database/Explorer/Explorer.basic.phpt -s -C -# Static analysis (PHPStan level 5 + phpstan-nette) +# Static analysis (PHPStan level 8 + nette/phpstan-rules) composer phpstan ``` diff --git a/phpstan.neon b/phpstan.neon index 3de03d356..ae6275a14 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -77,12 +77,6 @@ parameters: count: 2 path: src/Bridges/DatabaseTracy/ConnectionPanel.php - # Defensive instanceof check in elseif branch for readability - - - identifier: instanceof.alwaysTrue - count: 1 - path: src/Bridges/DatabaseTracy/ConnectionPanel.php - # DI extension: $this->config is array|object from Nette Schema - identifier: foreach.nonIterable @@ -142,7 +136,7 @@ parameters: path: src/Database/Helpers.php - identifier: isset.offset - count: 2 + count: 1 path: src/Database/Helpers.php # Latte-generated n:attr idiom: ($tmp = expr) === null ? '' : ... - diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index f030813b4..8cd06d55c 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -115,7 +115,7 @@ public function getConventions(): Conventions /** - * Creates an ActiveRow instance, using the configured row mapping class if available. + * Creates an ActiveRow instance. Override in a subclass to map tables to custom row classes. * @param array $data * @param Table\Selection $selection */ diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 4051866b3..cfa08a0a6 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -57,7 +57,7 @@ class SqlPreprocessor private int $counter; private bool $useParams; - /** values|set|and|order|items */ + /** values|set|and|or|order|list|name */ private ?string $arrayMode; @@ -254,7 +254,7 @@ private function formatInsert(array $items): string private function formatMultiInsert(array $groups): string { if (!is_array($groups[0]) && !$groups[0] instanceof Row) { - throw new Nette\InvalidArgumentException('Automaticaly detected multi-insert, but values aren\'t array. If you need try to change ?mode.'); + throw new Nette\InvalidArgumentException("Automatically detected multi-insert, but values aren't array. Use an explicit ?mode placeholder if needed."); } $cols = array_keys(is_array($groups[0]) ? $groups[0] : iterator_to_array($groups[0])); diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index b51f7542a..c62635a32 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -508,7 +508,7 @@ test('Detects incorrect multi-insert usage', function () use ($preprocessor) { Assert::exception( fn() => $preprocessor->process(['INSERT INTO author (name) SELECT name FROM user WHERE id ?', [11, 12]]), Nette\InvalidArgumentException::class, - 'Automaticaly detected multi-insert, but values aren\'t array. If you need try to change ?mode.', + "Automatically detected multi-insert, but values aren't array. Use an explicit ?mode placeholder if needed.", ); }); From 70b5de8a88113e141bf5925701866f35157e5633 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:36:16 +0200 Subject: [PATCH 37/51] ActiveRow: a column probed by isset() before it existed is no longer permanently invisible; __get() reloads all columns before giving up --- src/Database/Table/ActiveRow.php | 9 +++++ tests/Database/Explorer/Explorer.cache.phpt | 38 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 4164fe4a5..5c3816a11 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -265,6 +265,15 @@ public function &__get(string $key): mixed return $referenced; } + // the column may exist but be excluded from the narrowed SELECT, e.g. when it was + // probed by isset() before a migration added it; reload all columns and retry once + if ($this->table->getPreviousAccessedColumns() && !$this->table->getSqlBuilder()->getSelect()) { + $this->accessColumn(null); + if (array_key_exists($key, $this->data)) { + return $this->data[$key]; + } + } + $this->removeAccessColumn($key); $hint = Nette\Utils\Helpers::getSuggestion(array_keys($this->data), $key); throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); diff --git a/tests/Database/Explorer/Explorer.cache.phpt b/tests/Database/Explorer/Explorer.cache.phpt index 07fc80a41..5d4d44d89 100644 --- a/tests/Database/Explorer/Explorer.cache.phpt +++ b/tests/Database/Explorer/Explorer.cache.phpt @@ -247,3 +247,41 @@ test('table without primary key never narrows the select', function () use ($exp reformat('SELECT * FROM [note]'), // never narrowed, a re-query would need the primary key ], $sql); }); + + +test('a column probed by isset() before it existed is readable after being added', function () use ($explorer) { + $connection = $explorer->getConnection(); + $connection->query('DROP TABLE IF EXISTS probe_test'); + $connection->query('CREATE TABLE probe_test (id INTEGER NOT NULL PRIMARY KEY, a INTEGER)'); + $connection->query('INSERT INTO probe_test (id, a) VALUES (1, 10)'); + $explorer->getStructure()->rebuild(); + + $extraValues = []; + for ($i = 0; $i < 3; ++$i) { + if ($i === 1) { + $connection->query('ALTER TABLE probe_test ADD extra INTEGER'); // "migration" + $connection->query('UPDATE probe_test SET extra = 42'); + } + + $selection = $explorer->table('probe_test'); + foreach ($selection as $row) { + $row->a; + if ($i === 0) { + isset($row->extra); // probes a column that does not exist yet + } else { + if ($i === 1) { + // isset() deliberately does not reload the narrowed row: a reload here would permanently + // disable narrowing for every legitimate isset() probe of an absent column + Assert::false(isset($row->extra)); + } + + $extraValues[] = $row->extra; // __get() reloads and heals the row, must not throw + Assert::true(isset($row->extra)); + } + } + + $selection->__destruct(); + } + + Assert::same([42, 42], $extraValues); +}); From 6059bb9f82dfba5f2e96392aa83a19ce9adc2ab1 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:44:02 +0200 Subject: [PATCH 38/51] SqliteDriver: index primary flag comes from PRAGMA index_list origin; delimite() throws on ] instead of corrupting the identifier --- src/Database/Drivers/SqliteDriver.php | 21 ++++++--------------- tests/Database/Drivers/delimite.phpt | 10 ++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index e21f35fef..74133bc82 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -8,7 +8,7 @@ namespace Nette\Database\Drivers; use Nette; -use function addcslashes, array_values, in_array, preg_match, preg_quote, str_contains, strtoupper, strtr, substr; +use function addcslashes, array_values, in_array, preg_match, preg_quote, str_contains, strtoupper, substr; /** @@ -73,7 +73,9 @@ public function convertException(\PDOException $e): Nette\Database\DriverExcepti public function delimite(string $name): string { - return '[' . strtr($name, '[]', ' ') . ']'; + return str_contains($name, ']') // there is no escape for ] inside [...] + ? throw new Nette\InvalidArgumentException('Identifier must not contain the ] character.') + : "[$name]"; } @@ -189,24 +191,13 @@ public function getIndexes(string $table): array $indexes[$id] = [ 'name' => $id, 'unique' => (bool) $row['unique'], - 'primary' => false, + 'primary' => ($row['origin'] ?? null) === 'pk', 'columns' => $columns, ]; } - $tableColumns = $this->getColumns($table); - foreach ($indexes as $id => $index) { - $column = $index['columns'][0] ?? null; - foreach ($tableColumns as $info) { - if ($column === $info['name']) { - $indexes[$id]['primary'] = (bool) $info['primary']; - break; - } - } - } - if (!$indexes) { // @see http://www.sqlite.org/lang_createtable.html#rowid - foreach ($tableColumns as $column) { + foreach ($this->getColumns($table) as $column) { if ($column['vendor']['pk']) { $indexes[] = [ 'name' => 'ROWID', diff --git a/tests/Database/Drivers/delimite.phpt b/tests/Database/Drivers/delimite.phpt index 262f467c8..77c6654b1 100644 --- a/tests/Database/Drivers/delimite.phpt +++ b/tests/Database/Drivers/delimite.phpt @@ -15,3 +15,13 @@ foreach ([ Assert::same('[a[b]', $driver->delimite('a[b')); Assert::same('[a]]b]', $driver->delimite('a]b')); } + +// SQLite has no escape for ] inside [...] +$sqlite = new Nette\Database\Drivers\SqliteDriver; +Assert::same('[hello]', $sqlite->delimite('hello')); +Assert::same('[a[b]', $sqlite->delimite('a[b')); +Assert::exception( + fn() => $sqlite->delimite('a]b'), + Nette\InvalidArgumentException::class, + 'Identifier must not contain the ] character.', +); From 8b461bdae334e12e487d6f11defed3c8a001f372 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:44:02 +0200 Subject: [PATCH 39/51] PgSqlDriver: getTables() resolves shadowed table names in search_path order --- src/Database/Drivers/PgSqlDriver.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index c2ad5a0b8..92123b8ed 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -141,7 +141,8 @@ public function getTables(): array c.relkind IN ('r', 'v', 'm', 'p') AND n.nspname = ANY (pg_catalog.current_schemas(FALSE)) ORDER BY - c.relname + c.relname, + array_position(pg_catalog.current_schemas(FALSE), n.nspname) -- shadowed names resolve in search_path order X); while ($row = $rows->fetch()) { From 3549b8446b5fa4ab09b5211d933d1d04841ecca0 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:44:02 +0200 Subject: [PATCH 40/51] MsSqlDriver: reflection no longer fatals on table names without an explicit schema, defaults to dbo --- src/Database/Drivers/MsSqlDriver.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index 392359d4a..61a707bd8 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -140,7 +140,7 @@ public function getTables(): array public function getColumns(string $table): array { - [$table_schema, $table_name] = explode('.', $table); + [$table_schema, $table_name] = $this->splitTableName($table); $columns = []; $rows = $this->connection->query(<<<'X' @@ -186,7 +186,7 @@ public function getColumns(string $table): array public function getIndexes(string $table): array { - [, $table_name] = explode('.', $table); + [, $table_name] = $this->splitTableName($table); $indexes = []; $rows = $this->connection->query(<<<'X' @@ -223,7 +223,7 @@ public function getIndexes(string $table): array public function getForeignKeys(string $table): array { - [$table_schema, $table_name] = explode('.', $table); + [$table_schema, $table_name] = $this->splitTableName($table); $keys = []; $rows = $this->connection->query(<<<'X' @@ -267,4 +267,13 @@ public function getColumnTypes(\PDOStatement $statement): array { return Nette\Database\Helpers::detectTypes($statement); } + + + /** @return array{string, string} schema and table name */ + private function splitTableName(string $table): array + { + return str_contains($table, '.') + ? explode('.', $table, 2) + : ['dbo', $table]; + } } From f1bf7f79643255e8d05d8587b9bbcab7a444b034 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:44:02 +0200 Subject: [PATCH 41/51] Selection: page() with less than one item per page throws instead of DivisionByZeroError --- src/Database/Table/Selection.php | 4 ++++ tests/Database/Explorer/Selection.page().phpt | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 3ab2e2b61..c6a435f9f 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -419,6 +419,10 @@ public function limit(?int $limit, ?int $offset = null): static */ public function page(int $page, int $itemsPerPage, ?int &$numOfPages = null): static { + if ($itemsPerPage < 1) { + throw new Nette\InvalidArgumentException('Items per page must be at least 1.'); + } + if (func_num_args() > 2) { $numOfPages = (int) ceil($this->count('*') / $itemsPerPage); } diff --git a/tests/Database/Explorer/Selection.page().phpt b/tests/Database/Explorer/Selection.page().phpt index 858ba5bdb..4ebe6a750 100644 --- a/tests/Database/Explorer/Selection.page().phpt +++ b/tests/Database/Explorer/Selection.page().phpt @@ -60,3 +60,12 @@ test('less items than $itemsPerPage', function () use ($explorer) { $tags = $explorer->table('tag')->page(1, 100); Assert::same(4, count($tags)); //all four items from db }); + + +test('invalid items per page', function () use ($explorer) { + Assert::exception( + fn() => $explorer->table('tag')->page(1, 0), + Nette\InvalidArgumentException::class, + 'Items per page must be at least 1.', + ); +}); From c87d6ae26fcef732de96aa2f3043e3590d146990 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 14:44:02 +0200 Subject: [PATCH 42/51] Conventions: AmbiguousReferenceKeyException message names the table, key and candidates; fixed StaticConventions phpDoc --- src/Database/Conventions/DiscoveredConventions.php | 7 ++++++- src/Database/Conventions/StaticConventions.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Database/Conventions/DiscoveredConventions.php b/src/Database/Conventions/DiscoveredConventions.php index 352e4d9fc..65d549d35 100644 --- a/src/Database/Conventions/DiscoveredConventions.php +++ b/src/Database/Conventions/DiscoveredConventions.php @@ -69,7 +69,12 @@ public function getHasManyReference(string $nsTable, string $key): ?array } if (!empty($candidates)) { - throw new AmbiguousReferenceKeyException('Ambiguous joining column in related call.'); + throw new AmbiguousReferenceKeyException(sprintf( + "Ambiguous joining column in related('%s') called on table '%s', candidates: %s.", + $key, + $nsTable, + implode(', ', array_map(fn($c) => "{$c[1][0]}.{$c[1][1]}", $candidates)), + )); } if ($this->structure->isRebuilt()) { diff --git a/src/Database/Conventions/StaticConventions.php b/src/Database/Conventions/StaticConventions.php index 094484c41..a7df14ec5 100644 --- a/src/Database/Conventions/StaticConventions.php +++ b/src/Database/Conventions/StaticConventions.php @@ -18,7 +18,7 @@ class StaticConventions implements Conventions { /** * @param string $primary %s stands for table name - * @param string $foreign %1$s stands for key used after ->, %2$s for table name + * @param string $foreign %1$s stands for the referenced table (the key after -> in belongs-to, the parent table in has-many), %2$s for the opposite side * @param string $table %1$s stands for key used after ->, %2$s for table name */ public function __construct( From a4cbc2aee732534a92902c5cf07219fa2401a0e7 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 15:15:08 +0200 Subject: [PATCH 43/51] tests: connect & disconnect test acquires the DSN lock, a parallel fixture load could drop the database under its reconnect() --- tests/Database/connection.option.lazy.phpt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Database/connection.option.lazy.phpt b/tests/Database/connection.option.lazy.phpt index a8b48300c..7c0045129 100644 --- a/tests/Database/connection.option.lazy.phpt +++ b/tests/Database/connection.option.lazy.phpt @@ -44,6 +44,11 @@ test('', function () { test('connect & disconnect', function () { $options = Tester\Environment::loadData() + ['username' => null, 'password' => null]; + if ($options['dsn'] !== 'sqlite::memory:') { + // serializes with the other tests on this DSN, whose fixtures recreate the whole database + Tester\Environment::lock($options['dsn'], getTempDir()); + } + $connections = 1; try { From ea616036cf7faf28d1644d805a8b4980ca0abfbb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 15:26:40 +0200 Subject: [PATCH 44/51] added Selection::insertMany() - typed bulk insert returning the number of inserted rows --- docs/internals/explorer.md | 20 +++- src/Database/Table/Selection.php | 21 ++++ .../Explorer/Selection.insertMany().phpt | 110 ++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/Database/Explorer/Selection.insertMany().phpt diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index ce1817470..8fb604789 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -144,7 +144,7 @@ belongs-to batches), `['referencing']` (backward has-many batches + shared `acce by-ref array), so a mutation from one clone is seen by all clones of the same relation (they share the learned columns of the whole N+1 batch). -## insert() +## insert() & insertMany() `Selection::insert(iterable)` handles every form in one method (return type `ActiveRow|array|int`, never `null`): a **list, a Selection, or a PK-less table** @@ -159,6 +159,24 @@ All the early-return branches above (int / array) call `clearReferencingCache()` returned row is also registered into `rows`/`data` if the Selection was already executed. +`insertMany(iterable)` is a thin, typed wrapper over the bulk branch — it exists to +give callers a plain `int` instead of the `ActiveRow|array|int` union, and delegates +back to `insert()`. Three details are not obvious from the signature: + +- an **empty list returns 0 without touching the database**, whereas `insert([])` + inserts one row of database defaults (`?values` with an empty array) — the two are + deliberately not equivalent; +- rows are materialized by `Helpers::materializeRows()`, which drains a Traversable + **by position**, so nothing is lost when a generator yields rows under colliding + keys (`yield from` restarts at 0), and `Helpers::isRowList()` then accepts gaps left + by e.g. `array_filter()` — a plain `array_is_list()` would misread such rows as a + single associative row; +- a single associative row is **rejected up front** rather than inserted, so the + mistake surfaces before the write. + +`GroupedSelection` needs no override: `insertMany()` routes through `insert()`, whose +override there adds the grouping column to every row. + ## update() / delete() `ActiveRow::update($data)` runs UPDATE via `createSelectionInstance()->wherePrimary()` diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index c6a435f9f..27e521467 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -914,6 +914,27 @@ public function insert(iterable $data): ActiveRow|array|int } + /** + * Inserts multiple rows in a single query and returns the number of affected rows. + * @param iterable|Nette\Database\Row>|Selection $data + */ + public function insertMany(iterable $data): int + { + if ($data instanceof self) { + return $this->insert($data); + } + + $data = Nette\Database\Helpers::materializeRows($data); + if (!$data) { + return 0; + } elseif (!Nette\Database\Helpers::isRowList($data)) { + throw new Nette\InvalidArgumentException('insertMany() expects a list of rows or a Selection; use insert() for a single row.'); + } + + return $this->insert(array_values($data)); // the keys may have gaps, e.g. left by array_filter() + } + + /** * Updates all rows matching current conditions. JOINs in UPDATE are supported only by MySQL. * @param iterable $data diff --git a/tests/Database/Explorer/Selection.insertMany().phpt b/tests/Database/Explorer/Selection.insertMany().phpt new file mode 100644 index 000000000..93ff1c07c --- /dev/null +++ b/tests/Database/Explorer/Selection.insertMany().phpt @@ -0,0 +1,110 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('inserts a list of rows and returns affected count', function () use ($explorer) { + Assert::same(3, $explorer->table('author')->count()); + $result = $explorer->table('author')->insertMany([ + ['name' => 'Catelyn Stark', 'web' => 'http://example.com', 'born' => new DateTime('2011-11-11')], + ['name' => 'Sansa Stark', 'web' => 'http://example.com', 'born' => new DateTime('2021-11-11')], + ]); + Assert::same(2, $result); + Assert::same(5, $explorer->table('author')->count()); +}); + + +test('works on a GroupedSelection (related)', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + Assert::same(4, $explorer->table('book_tag')->count()); + $result = $explorer->table('book')->get(1)->related('book_tag')->insertMany([ + ['tag_id' => 21], + ['tag_id' => 22], + ]); + Assert::same(2, $result); + Assert::same(6, $explorer->table('book_tag')->count()); +}); + + +test('empty list inserts nothing and returns 0', function () use ($explorer) { + $count = $explorer->table('author')->count(); + Assert::same(0, $explorer->table('author')->insertMany([])); + Assert::same($count, $explorer->table('author')->count()); +}); + + +test('rejects a single associative row', function () use ($explorer) { + $count = $explorer->table('author')->count(); + Assert::exception( + fn() => $explorer->table('author')->insertMany(['name' => 'Arya Stark']), + Nette\InvalidArgumentException::class, + ); + Assert::same($count, $explorer->table('author')->count()); // nothing was inserted +}); + + +test('accepts a generator', function () use ($explorer) { + $rows = (function () { + yield ['name' => 'Rickon Stark', 'web' => 'http://example.com']; + })(); + + Assert::same(1, $explorer->table('author')->insertMany($rows)); +}); + + +test('accepts a generator with colliding keys (yield from)', function () use ($explorer) { + $batch1 = [['name' => 'Hodor', 'web' => 'http://example.com']]; + $batch2 = [['name' => 'Osha', 'web' => 'http://example.com']]; + $rows = (function () use ($batch1, $batch2) { + yield from $batch1; // both batches yield the key 0 + yield from $batch2; + })(); + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); + + +test('accepts rows under non-sequential keys, e.g. left by array_filter()', function () use ($explorer) { + $rows = array_filter([ + ['name' => 'Robb Stark', 'web' => 'http://example.com'], + ['name' => 'skip me', 'web' => ''], + ['name' => 'Bran Stark', 'web' => 'http://example.com'], + ], fn($row) => $row['web'] !== ''); // leaves keys 0 and 2 + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); + + +test('a GroupedSelection assigns the group to rows under non-sequential keys too', function () use ($explorer) { + $before = $explorer->table('book_tag')->where('book_id', 3)->count(); + + Assert::same(2, $explorer->table('book')->get(3)->related('book_tag')->insertMany([ + 0 => ['tag_id' => 23], + 2 => ['tag_id' => 24], + ])); + + Assert::same($before + 2, $explorer->table('book_tag')->where('book_id', 3)->count()); +}); + + +test('accepts Row objects, which the preprocessor supports', function () use ($explorer) { + $rows = [ + Nette\Database\Row::from(['name' => 'Jon Snow', 'web' => 'http://example.com']), + Nette\Database\Row::from(['name' => 'Ygritte', 'web' => 'http://example.com']), + ]; + + Assert::same(2, $explorer->table('author')->insertMany($rows)); +}); From eaf77541694e4638a3b1dd3078894cd7bc11b187 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 15:27:36 +0200 Subject: [PATCH 45/51] added Connection::isInTransaction() and Explorer::isInTransaction() --- src/Database/Connection.php | 9 +++++++++ src/Database/Explorer.php | 9 +++++++++ tests/Database/Connection.transaction.phpt | 18 ++++++++++++++++++ tests/Database/Explorer.transaction.phpt | 9 +++++++++ tests/Database/connection.option.lazy.phpt | 6 ++++++ 5 files changed, 51 insertions(+) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 220eb205e..e01e16059 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -222,6 +222,15 @@ public function rollBack(): void } + /** + * Checks whether a transaction is active, either via transaction() or manual beginTransaction(). + */ + public function isInTransaction(): bool + { + return $this->transactionDepth > 0 || ($this->pdo?->inTransaction() ?? false); + } + + /** * Executes callback inside a transaction. Supports nesting. * @param callable(static): mixed $callback diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index 8cd06d55c..8f891ba2b 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -49,6 +49,15 @@ public function rollBack(): void } + /** + * Checks whether a transaction is active, either via transaction() or manual beginTransaction(). + */ + public function isInTransaction(): bool + { + return $this->connection->isInTransaction(); + } + + /** * Executes callback inside a transaction. * @param callable(static): mixed $callback diff --git a/tests/Database/Connection.transaction.phpt b/tests/Database/Connection.transaction.phpt index 1d0625a12..9eb31398d 100644 --- a/tests/Database/Connection.transaction.phpt +++ b/tests/Database/Connection.transaction.phpt @@ -127,3 +127,21 @@ test('failed ROLLBACK does not mask the original exception', function () use ($c 'original exception', ); }); + + +test('isInTransaction() reflects both transaction() and manual control', function () use ($connection) { + Assert::false($connection->isInTransaction()); + + $connection->transaction(function (Connection $connection) { + Assert::true($connection->isInTransaction()); + $connection->transaction( + fn() => Assert::true($connection->isInTransaction()), // nested + ); + }); + Assert::false($connection->isInTransaction()); + + $connection->beginTransaction(); + Assert::true($connection->isInTransaction()); + $connection->rollBack(); + Assert::false($connection->isInTransaction()); +}); diff --git a/tests/Database/Explorer.transaction.phpt b/tests/Database/Explorer.transaction.phpt index d23732cec..338a24f8c 100644 --- a/tests/Database/Explorer.transaction.phpt +++ b/tests/Database/Explorer.transaction.phpt @@ -46,3 +46,12 @@ test('commits explorer transaction successfully', function () use ($explorer) { Assert::null($explorer->fetchField('SELECT id FROM book WHERE id = ', 3)); }); + + +test('isInTransaction() mirrors the connection state', function () use ($explorer) { + Assert::false($explorer->isInTransaction()); + $explorer->transaction( + fn() => Assert::true($explorer->isInTransaction()), + ); + Assert::false($explorer->isInTransaction()); +}); diff --git a/tests/Database/connection.option.lazy.phpt b/tests/Database/connection.option.lazy.phpt index 7c0045129..021544f77 100644 --- a/tests/Database/connection.option.lazy.phpt +++ b/tests/Database/connection.option.lazy.phpt @@ -42,6 +42,12 @@ test('', function () { }); +test('isInTransaction() does not force a connect', function () { + $connection = new Nette\Database\Connection('dsn', 'user', 'password', ['lazy' => true]); + Assert::false($connection->isInTransaction()); +}); + + test('connect & disconnect', function () { $options = Tester\Environment::loadData() + ['username' => null, 'password' => null]; if ($options['dsn'] !== 'sqlite::memory:') { From 7ee753910f833fe63999cd2c6ae2f85324d6bd31 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 15:29:34 +0200 Subject: [PATCH 46/51] phpstan.neon: every ignore entry is pinned by count and message where stable, so new errors of the same identifier cannot slip through silently --- phpstan.neon | 146 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 103 insertions(+), 43 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index ae6275a14..63156a3fa 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,75 +16,114 @@ parameters: # $refPath is populated via @param-out by getRefTable() — PHPStan doesn't track this - identifier: variable.undefined + message: '#^Undefined variable: \$refPath$#' count: 1 path: src/Database/Table/Selection.php - # Readonly lazy-loading via __get magic + # Readonly lazy-loading via __get magic (Reflection::$tables, Table::$columns/$indexes/$primaryKey/$foreignKeys) - identifier: property.uninitializedReadonly - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 2 + path: src/Database/Reflection.php + - + identifier: property.uninitializedReadonly + count: 8 + path: src/Database/Reflection/Table.php - identifier: unset.readOnlyProperty - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 1 + path: src/Database/Reflection.php + - + identifier: unset.readOnlyProperty + count: 4 + path: src/Database/Reflection/Table.php + - + identifier: property.readOnlyAssignNotInConstructor + count: 1 + path: src/Database/Reflection.php - identifier: property.readOnlyAssignNotInConstructor - paths: - - src/Database/Reflection.php - - src/Database/Reflection/Table.php + count: 4 + path: src/Database/Reflection/Table.php # Deprecated interfaces without generic types - identifier: missingType.generics - paths: - - src/Database/IRow.php - - src/Database/IRowContainer.php + count: 2 + path: src/Database/IRow.php + - + identifier: missingType.generics + count: 1 + path: src/Database/IRowContainer.php # Generic conflicts from deprecated IRowContainer/IRow extending Traversable without type params - identifier: generics.interfaceConflict - paths: - - src/Database/ResultSet.php - - src/Database/Row.php - - src/Database/Table/GroupedSelection.php - - src/Database/Table/ActiveRow.php - - src/Database/Table/Selection.php + count: 2 + path: src/Database/ResultSet.php + - + identifier: generics.interfaceConflict + count: 3 + path: src/Database/Row.php + - + identifier: generics.interfaceConflict + count: 2 + path: src/Database/Table/GroupedSelection.php + - + identifier: generics.interfaceConflict + count: 1 + path: src/Database/Table/ActiveRow.php + - + identifier: generics.interfaceConflict + count: 2 + path: src/Database/Table/Selection.php - # Iterator/ArrayAccess covariance/contravariance (PHP interface limitation) + # Iterator/ArrayAccess covariance/contravariance (PHP interface limitation): current() may return false, offset methods take int|string - identifier: method.childReturnType - paths: - - src/Database/ResultSet.php - - src/Database/Table/Selection.php + message: '#::current\(\) should be covariant#' + count: 1 + path: src/Database/ResultSet.php + - + identifier: method.childReturnType + message: '#::current\(\) should be covariant#' + count: 1 + path: src/Database/Table/Selection.php - identifier: method.childParameterType - paths: - - src/Database/Row.php - - src/Database/Table/Selection.php + message: '#\$key \(int\|string\) of method Nette\\Database\\Row::offset#' + count: 4 + path: src/Database/Row.php + - + identifier: method.childParameterType + message: '#::offsetSet\(\) should be contravariant#' + count: 1 + path: src/Database/Table/Selection.php # Intentional new static() in exception hierarchy - identifier: new.static + message: '#^Unsafe usage of new static\(\)\.$#' count: 1 path: src/Database/DriverException.php # Closure variables consumed by require'd phtml template - identifier: closure.unusedUse + message: '#unused use \$(connection|queries)#' count: 2 path: src/Bridges/DatabaseTracy/ConnectionPanel.php # DI extension: $this->config is array|object from Nette Schema - identifier: foreach.nonIterable + count: 2 path: src/Bridges/DatabaseDI/DatabaseExtension.php # PDOException::$queryString is set by PDO engine, not formally declared - identifier: property.notFound + message: '#PDOException::\$queryString#' count: 1 path: src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -97,6 +136,7 @@ parameters: # Defensive runtime checks unreachable per @param type - identifier: instanceof.alwaysTrue + message: '#Nette\\Database\\Row#' count: 1 path: src/Database/SqlPreprocessor.php - @@ -104,29 +144,49 @@ parameters: count: 1 path: src/Database/SqlPreprocessor.php + # getPrimary() may return string[] for composite keys, but here it is always a string - identifier: array.invalidKey + message: '#array\|string#' count: 1 path: src/Database/Table/ActiveRow.php - # Return type mismatches from generic covariance and internal caching + # fetchAssoc(): Arrays::associate() returns array|stdClass, but never stdClass for a string path - identifier: return.type - paths: - - src/Database/ResultSet.php - - src/Database/Structure.php + message: '#fetchAssoc\(\) should return array\|null but returns array\|stdClass#' + count: 1 + path: src/Database/ResultSet.php + + # structure array shape is built dynamically and re-loaded from cache + - + identifier: return.type + count: 2 + path: src/Database/Structure.php # Internal SQL is dynamically assembled from trusted components, not literal-string - - message: '#expects literal-string, .+ given#' - paths: - - src/Database/Table/Selection.php - - src/Database/Table/GroupedSelection.php + identifier: argument.type + message: '#expects literal-string, [\w-]+ given#' + count: 5 + path: src/Database/Table/Selection.php + - + identifier: argument.type + message: '#expects literal-string, [\w-]+ given#' + count: 1 + path: src/Database/Table/GroupedSelection.php - # Array offset access on Row/ActiveRow objects and nullable arrays + # parseColumnType(): regex match offsets guaranteed by the pattern - identifier: offsetAccess.notFound - count: 7 + message: '#^Offset \d might not exist on array#' + count: 4 + path: src/Database/Helpers.php + # toPairs(): offset access on array|Row|ActiveRow, all of them support it + - + identifier: offsetAccess.notFound + message: '#^Offset int\|string might not exist#' + count: 3 path: src/Database/Helpers.php # Defensive checks that are always true/false per PHPStan type narrowing @@ -134,13 +194,13 @@ parameters: identifier: empty.offset count: 1 path: src/Database/Helpers.php - - - identifier: isset.offset - count: 1 - path: src/Database/Helpers.php + # Latte-generated n:attr idiom: ($tmp = expr) === null ? '' : ... - identifier: identical.alwaysFalse - paths: - - src/Bridges/DatabaseTracy/dist/panel.phtml - - src/Bridges/DatabaseTracy/dist/tab.phtml + count: 3 + path: src/Bridges/DatabaseTracy/dist/panel.phtml + - + identifier: identical.alwaysFalse + count: 1 + path: src/Bridges/DatabaseTracy/dist/tab.phtml From 00b2ef1eff49ae02040c02aca985917728d427df Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 15:35:16 +0200 Subject: [PATCH 47/51] tests: database matrix extended with PostgreSQL 16 and MariaDB 11.4, locally and in CI; runner bumped to ubuntu-24.04 --- .github/workflows/tests.yml | 4 +- docker-compose.yml | 27 +++++++++ .../Connection.exceptions.mariadb.phpt | 55 +++++++++++++++++++ tests/Database/Reflection.driver.phpt | 4 +- tests/Database/Reflection.phpt | 4 +- tests/databases.docker.ini | 13 +++++ 6 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 tests/Database/Connection.exceptions.mariadb.phpt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d17174eb8..c79cab1b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,12 +3,12 @@ name: Tests on: [push, pull_request] env: - php-extensions: mbstring, intl, pdo_sqlsrv-5.12.0 + php-extensions: mbstring, intl, pdo_sqlsrv-5.12.0 # pinned: pecl builds of newer pdo_sqlsrv are not verified across the whole PHP matrix php-tools: "composer:v2, pecl" jobs: tests: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] diff --git a/docker-compose.yml b/docker-compose.yml index 04906d074..af57b92a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,33 @@ services: timeout: 5s retries: 5 + postgres16: + image: postgres:16 + ports: + - "5435:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nette_test + healthcheck: + test: pg_isready + interval: 10s + timeout: 5s + retries: 5 + + mariadb: + image: mariadb:11.4 + ports: + - "3308:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: nette_test + healthcheck: + test: healthcheck.sh --connect --innodb_initialized + interval: 10s + timeout: 5s + retries: 5 + mssql: image: mcr.microsoft.com/mssql/server:2022-latest ports: diff --git a/tests/Database/Connection.exceptions.mariadb.phpt b/tests/Database/Connection.exceptions.mariadb.phpt new file mode 100644 index 000000000..649bef43c --- /dev/null +++ b/tests/Database/Connection.exceptions.mariadb.phpt @@ -0,0 +1,55 @@ +getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/files/{$driverName}-nette_test1.sql"); + + +test('Exception thrown for unique constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO author (id, name, web, born) VALUES (11, "", "", NULL)'), + Nette\Database\UniqueConstraintViolationException::class, + ); + + Assert::same(1062, $e->getDriverCode()); +}); + + +test('Exception thrown for not null constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO author (name, web, born) VALUES (NULL, "", NULL)'), + Nette\Database\NotNullConstraintViolationException::class, + ); + + Assert::same(1048, $e->getDriverCode()); +}); + + +test('Exception thrown for foreign key constraint violation', function () use ($connection) { + $e = Assert::exception( + fn() => $connection->query('INSERT INTO book (author_id, translator_id, title) VALUES (999, 12, "")'), + Nette\Database\ForeignKeyConstraintViolationException::class, + ); + + Assert::same(1452, $e->getDriverCode()); +}); + + +test('Exception thrown for check constraint violation', function () use ($connection) { + $connection->query('CREATE TEMPORARY TABLE check_test (id int, price int CHECK (price >= 0))'); + + $e = Assert::exception( + fn() => $connection->query('INSERT INTO check_test (id, price) VALUES (1, -5)'), + Nette\Database\CheckConstraintViolationException::class, + ); + + Assert::same(4025, $e->getDriverCode()); // ER_CONSTRAINT_FAILED, differs from MySQL's 3819 +}); diff --git a/tests/Database/Reflection.driver.phpt b/tests/Database/Reflection.driver.phpt index 6d0c79060..ab6fd71cf 100644 --- a/tests/Database/Reflection.driver.phpt +++ b/tests/Database/Reflection.driver.phpt @@ -104,8 +104,8 @@ $expectedColumns = [ switch ($driverName) { case 'mysql': $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($version, '8.0', '>=')) { - $expectedColumns[0]['size'] = null; + if (stripos($version, 'MariaDB') === false && version_compare($version, '8.0', '>=')) { + $expectedColumns[0]['size'] = null; // MySQL 8 dropped integer display width, MariaDB keeps it } break; case 'pgsql': diff --git a/tests/Database/Reflection.phpt b/tests/Database/Reflection.phpt index 608a652fe..ae9c7bf94 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -121,8 +121,8 @@ $expectedColumns = [ switch ($driverName) { case 'mysql': $version = $connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($version, '8.0', '>=')) { - $expectedColumns['id']['size'] = null; + if (stripos($version, 'MariaDB') === false && version_compare($version, '8.0', '>=')) { + $expectedColumns['id']['size'] = null; // MySQL 8 dropped integer display width, MariaDB keeps it } break; case 'pgsql': diff --git a/tests/databases.docker.ini b/tests/databases.docker.ini index a689c1054..d43a3836e 100644 --- a/tests/databases.docker.ini +++ b/tests/databases.docker.ini @@ -17,6 +17,19 @@ username = postgres password = postgres options[newDateTime] = yes +[postgresql 16] +dsn = "pgsql:host=127.0.0.1;port=5435;dbname=nette_test" +username = postgres +password = postgres +options[newDateTime] = yes + +[mariadb] +dsn = "mysql:host=127.0.0.1;port=3308;dbname=nette_test" +username = root +password = root +options[convertBoolean] = yes +options[newDateTime] = yes + [sqlsrv] dsn = "sqlsrv:Server=localhost,1434;Database=nette_test" username = SA From b7852658a8bcf6a654c7a8492d04cee39dce57ac Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 16:00:25 +0200 Subject: [PATCH 48/51] tests: covered DatabaseExtension config paths (PDO:: constants, reflection BC key, empty conventions, custom class, debugger wiring) and disabled ConnectionPanel --- .../DatabaseExtension.options.phpt | 113 ++++++++++++++++++ tests/Database.Tracy/ConnectionPanel.phpt | 13 ++ 2 files changed, 126 insertions(+) create mode 100644 tests/Database.DI/DatabaseExtension.options.phpt diff --git a/tests/Database.DI/DatabaseExtension.options.phpt b/tests/Database.DI/DatabaseExtension.options.phpt new file mode 100644 index 000000000..e1baca525 --- /dev/null +++ b/tests/Database.DI/DatabaseExtension.options.phpt @@ -0,0 +1,113 @@ +load(Tester\FileMock::create($config, 'neon')); + + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension($debugMode)); + eval($compiler->addConfig($config)->setClassName($class)->compile()); + + $container = new $class; + $container->initialize(); + return $container; +} + + +test('PDO:: constants in options are translated to their values', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + options: + PDO::ATTR_CASE: PDO::CASE_LOWER + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt1'); + + $connection = $container->getService('database.default'); + Assert::same(PDO::CASE_LOWER, $connection->getPdo()->getAttribute(PDO::ATTR_CASE)); +}); + + +test('BC option reflection: creates the conventions service under the historical name', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + reflection: discovered + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt2'); + + Assert::true($container->hasService('database.default.reflection')); + Assert::false($container->hasService('database.default.conventions')); + Assert::type( + Nette\Database\Conventions\DiscoveredConventions::class, + $container->getService('database.default.reflection'), + ); +}); + + +test('empty conventions registers no conventions service and Explorer falls back to StaticConventions', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + conventions: "" + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt3'); + + Assert::false($container->hasService('database.default.conventions')); + $explorer = $container->getService('database.default.explorer'); + Assert::type(Nette\Database\Conventions\StaticConventions::class, $explorer->getConventions()); +}); + + +test('conventions accepts a custom class name', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + conventions: Nette\Database\Conventions\StaticConventions + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt4'); + + Assert::type( + Nette\Database\Conventions\StaticConventions::class, + $container->getService('database.default.conventions'), + ); +}); + + +test('debugger: yes wires the Tracy bar panel to the connection', function () { + $container = buildContainer(' + database: + dsn: "sqlite::memory:" + debugger: yes + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'ContainerOpt5', debugMode: true); + + $container->getService('database.default'); + Assert::type( + Nette\Bridges\DatabaseTracy\ConnectionPanel::class, + Tracy\Debugger::getBar()->getPanel(Nette\Bridges\DatabaseTracy\ConnectionPanel::class), + ); +}); diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index d63dbe571..78a0499c0 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -87,3 +87,16 @@ test('BlueScreen panel is registered only once for multiple connections', functi $panels = (new ReflectionProperty($blueScreen, 'panels'))->getValue($blueScreen); Assert::count($before + 1, $panels); }); + + +test('disabled panel logs nothing', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'off'); + $panel->disabled = true; + + $connection->query('SELECT 1'); + + Assert::same(0, (new ReflectionProperty($panel, 'count'))->getValue($panel)); + Assert::count(0, (new ReflectionProperty($panel, 'queries'))->getValue($panel)); + Assert::null($panel->getPanel()); +}); From b191ef5b8904deb494d7120b7761413fa034408f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 21:33:08 +0200 Subject: [PATCH 49/51] tests: waiting for the DSN lock no longer counts towards the test time limit A stopgap until the fix in Tester's Environment::lock() is released; on Windows the limit measures real time, so a test queued on a busy DSN was killed before it started. --- tests/bootstrap.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9386a542a..6f038bdb5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -29,7 +29,11 @@ function connectToDB(array $options = []): Nette\Database\Explorer $args['options'] = $options + $args['options']; if ($args['dsn'] !== 'sqlite::memory:') { + // TODO: drop once nette/tester with the fix in Environment::lock() is released + $limit = (int) ini_get('max_execution_time'); + set_time_limit(0); Tester\Environment::lock($args['dsn'], getTempDir()); + set_time_limit($limit); } $connection = new Nette\Database\Connection($args['dsn'], $args['username'], $args['password'], $args['options']); From 426f44e4ad7e6582535d5dd7d3909f958c29f163 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 17 Jul 2026 00:19:41 +0200 Subject: [PATCH 50/51] GroupedSelection: insert() no longer modifies the caller's Row objects Assigning the referencing group wrote the grouping column straight into the rows it was given. For an array that is harmless, but a Nette\Database\Row is an object, so the column leaked back into the caller's instance. Rows are now cloned before the group is assigned to them. --- src/Database/Table/GroupedSelection.php | 7 ++++--- tests/Database/Explorer/Selection.insertMany().phpt | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index 8280fd0a3..82d75f85a 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -10,7 +10,6 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; -use function array_keys, count, iterator_to_array, preg_match, reset; /** @@ -269,8 +268,10 @@ public function insert(iterable $data): ActiveRow|array|int $data = Nette\Database\Helpers::materializeRows($data); if (Nette\Database\Helpers::isRowList($data)) { - foreach (array_keys($data) as $key) { - $data[$key][$this->column] = $this->active; + foreach ($data as $key => $row) { + $row = $row instanceof Nette\Database\Row ? clone $row : $row; // must not modify the caller's row + $row[$this->column] = $this->active; + $data[$key] = $row; } } else { $data[$this->column] = $this->active; // a single row (an empty one too) diff --git a/tests/Database/Explorer/Selection.insertMany().phpt b/tests/Database/Explorer/Selection.insertMany().phpt index 93ff1c07c..be547ce38 100644 --- a/tests/Database/Explorer/Selection.insertMany().phpt +++ b/tests/Database/Explorer/Selection.insertMany().phpt @@ -108,3 +108,13 @@ test('accepts Row objects, which the preprocessor supports', function () use ($e Assert::same(2, $explorer->table('author')->insertMany($rows)); }); + + +test('does not modify the caller\'s Row objects', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + $row = Nette\Database\Row::from(['tag_id' => 21]); + $explorer->table('book')->get(1)->related('book_tag')->insertMany([$row]); + + Assert::same(['tag_id' => 21], (array) $row); // no book_id leaked into it +}); From e66e1093e7612309781ca9f8bf31762d94479665 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 17 Jul 2026 00:45:21 +0200 Subject: [PATCH 51/51] CI: unpinned pdo_sqlsrv, the pin could not cover the whole PHP matrix No single release supports PHP 8.1 through 8.5 (5.12 covers 8.1-8.3, 5.13 covers 8.3-8.5), so the 5.12.0 pin could never install on the 8.4 and 8.5 jobs. Without a version setup-php resolves the newest release compatible with each PHP. --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c79cab1b6..2baec1798 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,7 +3,9 @@ name: Tests on: [push, pull_request] env: - php-extensions: mbstring, intl, pdo_sqlsrv-5.12.0 # pinned: pecl builds of newer pdo_sqlsrv are not verified across the whole PHP matrix + # pdo_sqlsrv is deliberately unpinned: no single release covers the whole matrix + # (5.12 = PHP 8.1-8.3, 5.13 = PHP 8.3-8.5), so let setup-php pick a compatible one + php-extensions: mbstring, intl, pdo_sqlsrv php-tools: "composer:v2, pecl" jobs: