From b692bec76728801c5f5dc30bf698cd1e8aecc867 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 6 Jun 2026 20:21:10 +0200 Subject: [PATCH 01/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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: From 3f8283047e990b050cc715f7c29f37cbea0135ff Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 7 Jun 2026 16:18:18 +0200 Subject: [PATCH 52/75] opened 3.3-dev --- composer.json | 2 +- readme.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 3b047bcbf..16c3c2e3d 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-master": "3.3-dev" } }, "config": { diff --git a/readme.md b/readme.md index 0457ab40f..3f674ee84 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ Nette Database ============== [![Downloads this Month](https://img.shields.io/packagist/dm/nette/database.svg)](https://packagist.org/packages/nette/database) -[![Tests](https://github.com/nette/database/actions/workflows/tests.yml/badge.svg?branch=v3.2)](https://github.com/nette/database/actions) +[![Tests](https://github.com/nette/database/actions/workflows/tests.yml/badge.svg?branch=v3.3)](https://github.com/nette/database/actions) [![Latest Stable Version](https://poser.pugx.org/nette/database/v/stable)](https://github.com/nette/database/releases) [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/database/blob/master/license.md) From 6719dd2b058107b13f0a5051608a45fa9458d8e7 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 7 Jun 2026 16:19:13 +0200 Subject: [PATCH 53/75] requires PHP 8.3 --- .github/workflows/tests.yml | 4 ++-- AGENTS.md | 2 +- composer.json | 2 +- readme.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2baec1798..3467a8248 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] fail-fast: false @@ -51,7 +51,7 @@ jobs: - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 coverage: none - name: Create databases.ini diff --git a/AGENTS.md b/AGENTS.md index bf7331dff..be15a26d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ context-detected preprocessor modes). Read `docs/internals/` before touching the Supports MySQL, PostgreSQL, SQLite, MS SQL Server, and Oracle. -- **PHP Version**: 8.1 - 8.5 +- **PHP Version**: 8.3 - 8.5 - **Package**: `nette/database` ## Essential Commands diff --git a/composer.json b/composer.json index 16c3c2e3d..d85f155bb 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.3 - 8.5", "ext-pdo": "*", "nette/caching": "^3.2", "nette/utils": "^4.0" diff --git a/readme.md b/readme.md index 3f674ee84..3f349b131 100644 --- a/readme.md +++ b/readme.md @@ -40,7 +40,7 @@ The recommended way to install is via Composer: composer require nette/database ``` -It requires PHP version 8.1 and supports PHP up to 8.5. +It requires PHP version 8.3 and supports PHP up to 8.5. Running Tests From 3d0b4775ecfee1539d7b987c27ea259663ca85ab Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 23 Dec 2025 22:46:53 +0100 Subject: [PATCH 54/75] used PHP 8.3 features --- src/Database/SqlPreprocessor.php | 2 +- src/Database/Table/ActiveRow.php | 4 +--- src/Database/Table/Selection.php | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index cfa08a0a6..5830a96a6 100644 --- a/src/Database/SqlPreprocessor.php +++ b/src/Database/SqlPreprocessor.php @@ -257,7 +257,7 @@ private function formatMultiInsert(array $groups): string 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])); + $cols = array_keys(iterator_to_array($groups[0])); $vals = []; foreach ($groups as $group) { $rowVals = []; diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 5c3816a11..03d0af74c 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -155,9 +155,7 @@ public function related(string $key, ?string $throughColumn = null): GroupedSele */ public function update(iterable $data): bool { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } + $data = iterator_to_array($data); $primary = $this->getPrimary(); if (!is_array($primary)) { diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 27e521467..85cb8916d 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -942,10 +942,7 @@ public function insertMany(iterable $data): int */ public function update(iterable $data): int { - if ($data instanceof \Traversable) { - $data = iterator_to_array($data); - } - + $data = iterator_to_array($data); if (!$data) { return 0; } From 6527169f2b81425eeaf73123781fdf8a6230d157 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 May 2024 15:05:25 +0200 Subject: [PATCH 55/75] readme: added jumbo --- readme.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 3f349b131..abfd8a922 100644 --- a/readme.md +++ b/readme.md @@ -1,25 +1,26 @@ -Nette Database -============== +[![Nette Database](https://github.com/nette/database/assets/194960/97d8f31b-096c-466c-a76f-f5b9e511ea8d)](https://doc.nette.org/database) [![Downloads this Month](https://img.shields.io/packagist/dm/nette/database.svg)](https://packagist.org/packages/nette/database) [![Tests](https://github.com/nette/database/actions/workflows/tests.yml/badge.svg?branch=v3.3)](https://github.com/nette/database/actions) [![Latest Stable Version](https://poser.pugx.org/nette/database/v/stable)](https://github.com/nette/database/releases) [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/database/blob/master/license.md) +  Introduction ------------ Nette provides a powerful layer for accessing your database easily. -- composes SQL queries with ease -- easily fetches data -- uses efficient queries and does not transmit unnecessary data +✅ composes SQL queries with ease
+✅ significantly simplifies retrieving data without writing SQL queries
+✅ uses efficient queries and does not transmit unnecessary data -The [Nette Database Core](https://doc.nette.org/database-core) is a wrapper around the PDO and provides core functionality. +The [Nette Database Core](https://doc.nette.org/en/database/core) is a wrapper around the PDO and provides core functionality. -The [Nette Database Explorer](https://doc.nette.org/database-explorer) layer helps you to fetch database data more easily and in a more optimized way. +The [Nette Database Explorer](https://doc.nette.org/en/database/explorer) layer helps you to fetch database data more easily and in a more optimized way. +  [Support Me](https://github.com/sponsors/dg) -------------------------------------------- @@ -30,6 +31,7 @@ Do you like Nette Database? Are you looking forward to the new features? Thank you! +  Installation ------------ @@ -42,6 +44,7 @@ composer require nette/database It requires PHP version 8.3 and supports PHP up to 8.5. +  Running Tests ------------- @@ -66,6 +69,7 @@ Usage This is just a piece of documentation. [Please see our website](https://doc.nette.org/database). +  Database Core ------------- @@ -89,6 +93,8 @@ $database->query('UPDATE users SET ? WHERE id=?', $data, $id); $database->query('SELECT * FROM categories WHERE id=?', 123)->dump(); ``` +  + Database Explorer ----------------- From 480b672dba0914c69a50f0144fbfc12a2af263ef Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 19 Jan 2022 14:05:47 +0100 Subject: [PATCH 56/75] some deprecated methods trigger notices --- src/Database/Helpers.php | 2 ++ tests/Database.Tracy/ConnectionPanel.phpt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index b499216cb..5cd6f740b 100644 --- a/src/Database/Helpers.php +++ b/src/Database/Helpers.php @@ -312,6 +312,7 @@ public static function createDebugPanel( Tracy\BlueScreen $blueScreen, ): ?ConnectionPanel { + trigger_error(__METHOD__ . '() is deprecated, use Nette\Bridges\DatabaseTracy\ConnectionPanel::initialize()', E_USER_DEPRECATED); return ConnectionPanel::initialize($connection, true, $name, $explain, $bar, $blueScreen); } @@ -326,6 +327,7 @@ public static function initializeTracy( ?Tracy\BlueScreen $blueScreen = null, ): ?ConnectionPanel { + trigger_error(__METHOD__ . '() is deprecated, use Nette\Bridges\DatabaseTracy\ConnectionPanel::initialize()', E_USER_DEPRECATED); return ConnectionPanel::initialize($connection, $addBarPanel, $name, $explain, $bar, $blueScreen); } diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index 78a0499c0..d0c3caed6 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -45,7 +45,7 @@ test('Bluescreen Panel', function () { test('deprecated initialization', function () { $connection = new Connection('sqlite::memory:'); - $panel = Nette\Database\Helpers::initializeTracy($connection, addBarPanel: true, name: 'foo'); + $panel = @Nette\Database\Helpers::initializeTracy($connection, addBarPanel: true, name: 'foo'); // deprecated $connection->beginTransaction(); $connection->query('SELECT 1'); From 420bdda5dace68f4ff3b50451ea21d74f39c4a37 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 7 Jun 2026 16:22:43 +0200 Subject: [PATCH 57/75] composer: increased dependencies versions --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index d85f155bb..52b6e2b5b 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,8 @@ "require": { "php": "8.3 - 8.5", "ext-pdo": "*", - "nette/caching": "^3.2", - "nette/utils": "^4.0" + "nette/caching": "^3.4", + "nette/utils": "^4.1" }, "require-dev": { "nette/tester": "^2.6", From 7f67c8c675ad38dacb3c4fcfefced131645cd19f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 27 Dec 2024 08:46:22 +0100 Subject: [PATCH 58/75] Reflection: added 'scale' field --- .claude/settings.local.json | 16 +++++++++ src/Database/Driver.php | 2 +- src/Database/Drivers/MsSqlDriver.php | 2 ++ src/Database/Drivers/MySqlDriver.php | 1 + src/Database/Drivers/PgSqlDriver.php | 5 +++ src/Database/Drivers/SqliteDriver.php | 1 + src/Database/Drivers/SqlsrvDriver.php | 2 ++ src/Database/Reflection/Column.php | 1 + src/Database/Reflection/Table.php | 2 +- tests/Database/Reflection.columns.mysql.phpt | 32 +++++++++++++++++ .../Database/Reflection.columns.postgre.phpt | 33 ++++++++++++++++++ tests/Database/Reflection.columns.sqlite.phpt | 29 ++++++++++++++++ tests/Database/Reflection.columns.sqlsrv.phpt | 31 +++++++++++++++++ tests/Database/Reflection.driver.phpt | 4 +++ tests/Database/Reflection.phpt | 5 +++ tests/Database/_create_db.php | 6 ++++ tests/Database/_sqlbuilder.phpt | 34 +++++++++++++++++++ 17 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 tests/Database/_create_db.php create mode 100644 tests/Database/_sqlbuilder.phpt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..8533a6382 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(wc:*)", + "Bash(composer run phpstan:*)", + "mcp__jetbrains__list_directory_tree", + "mcp__jetbrains__get_file_text_by_path", + "Bash(git stash:*)", + "Bash(grep:*)", + "Bash(cd W:/Nette/Database/src/Bridges/DatabaseTracy && cmd //c compile.bat 2>&1)", + "Bash(cd \"W:/Nette/Database/src/Bridges/DatabaseTracy\" && cmd /c \"compile.bat\" 2>&1)", + "Bash(cd:*)" + ] + } +} diff --git a/src/Database/Driver.php b/src/Database/Driver.php index 3a4564aca..382af4034 100644 --- a/src/Database/Driver.php +++ b/src/Database/Driver.php @@ -75,7 +75,7 @@ function getTables(): array; /** * Returns metadata for all columns in a table. - * @return list}> + * @return list}> */ function getColumns(string $table): array; diff --git a/src/Database/Drivers/MsSqlDriver.php b/src/Database/Drivers/MsSqlDriver.php index 61a707bd8..54a4ae2c2 100644 --- a/src/Database/Drivers/MsSqlDriver.php +++ b/src/Database/Drivers/MsSqlDriver.php @@ -149,6 +149,7 @@ public function getColumns(string $table): array c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, + c.NUMERIC_SCALE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.DOMAIN_NAME, @@ -170,6 +171,7 @@ public function getColumns(string $table): array 'table' => $table, 'nativetype' => strtoupper($row['DATA_TYPE']), 'size' => $row['CHARACTER_MAXIMUM_LENGTH'] ?? $row['NUMERIC_PRECISION'], + 'scale' => (int) $row['NUMERIC_SCALE'] ?: null, 'unsigned' => false, 'nullable' => $row['IS_NULLABLE'] === 'YES', 'default' => $row['COLUMN_DEFAULT'], diff --git a/src/Database/Drivers/MySqlDriver.php b/src/Database/Drivers/MySqlDriver.php index b0d69e892..b6390bad9 100644 --- a/src/Database/Drivers/MySqlDriver.php +++ b/src/Database/Drivers/MySqlDriver.php @@ -174,6 +174,7 @@ public function getColumns(string $table): array 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? ''), 'size' => $typeInfo['size'], + 'scale' => $typeInfo['scale'], 'nullable' => $row['null'] === 'YES', 'default' => $row['default'], 'autoincrement' => $row['extra'] === 'auto_increment', diff --git a/src/Database/Drivers/PgSqlDriver.php b/src/Database/Drivers/PgSqlDriver.php index 92123b8ed..2b5fcebc0 100644 --- a/src/Database/Drivers/PgSqlDriver.php +++ b/src/Database/Drivers/PgSqlDriver.php @@ -172,6 +172,10 @@ public function getColumns(string $table): array WHEN t.typlen > 0 THEN t.typlen -- length for fixed-length types ELSE NULL END AS size, + CASE + WHEN a.atttypid IN (1700, 1231) THEN (a.atttypmod - 4) & 65535 + ELSE null + END AS scale, NOT (a.attnotnull OR t.typtype = 'd' AND t.typnotnull) AS nullable, pg_catalog.pg_get_expr(ad.adbin, 'pg_catalog.pg_attrdef'::regclass)::varchar AS default, coalesce(co.contype = 'p' AND (seq.relname IS NOT NULL OR strpos(pg_catalog.pg_get_expr(ad.adbin, ad.adrelid), 'nextval') = 1), FALSE) AS autoincrement, @@ -202,6 +206,7 @@ public function getColumns(string $table): array 'table' => (string) $row['table'], 'nativetype' => (string) $row['nativetype'], 'size' => $row['size'] !== null ? (int) $row['size'] : null, + 'scale' => $row['scale'] !== null ? (int) $row['scale'] : null, 'nullable' => (bool) $row['nullable'], 'default' => $row['default'], 'autoincrement' => (bool) $row['autoincrement'], diff --git a/src/Database/Drivers/SqliteDriver.php b/src/Database/Drivers/SqliteDriver.php index 74133bc82..05a77eab0 100644 --- a/src/Database/Drivers/SqliteDriver.php +++ b/src/Database/Drivers/SqliteDriver.php @@ -163,6 +163,7 @@ public function getColumns(string $table): array 'table' => $table, 'nativetype' => strtoupper($typeInfo['type'] ?? 'BLOB'), 'size' => $typeInfo['size'], + 'scale' => $typeInfo['scale'], 'nullable' => $row['notnull'] == 0, 'default' => $row['dflt_value'], 'autoincrement' => $createSql && preg_match($pattern, $createSql['sql']), diff --git a/src/Database/Drivers/SqlsrvDriver.php b/src/Database/Drivers/SqlsrvDriver.php index 064057a2b..b02930e5d 100644 --- a/src/Database/Drivers/SqlsrvDriver.php +++ b/src/Database/Drivers/SqlsrvDriver.php @@ -158,6 +158,7 @@ public function getColumns(string $table): array WHEN c.max_length <> -1 THEN c.max_length ELSE NULL END AS size, + c.scale AS scale, c.is_nullable AS nullable, OBJECT_DEFINITION(c.default_object_id) AS [default], c.is_identity AS autoincrement, @@ -187,6 +188,7 @@ public function getColumns(string $table): array 'table' => (string) $row['table'], 'nativetype' => (string) $row['nativetype'], 'size' => $row['size'] !== null ? (int) $row['size'] : null, + 'scale' => (int) $row['scale'] ?: null, 'nullable' => (bool) $row['nullable'], 'default' => $row['default'], 'autoincrement' => (bool) $row['autoincrement'], diff --git a/src/Database/Reflection/Column.php b/src/Database/Reflection/Column.php index 16a6dba13..6db439da6 100644 --- a/src/Database/Reflection/Column.php +++ b/src/Database/Reflection/Column.php @@ -19,6 +19,7 @@ public function __construct( public readonly ?Table $table = null, public readonly string $nativeType = '', public readonly ?int $size = null, + public readonly ?int $scale = null, public readonly bool $nullable = false, public readonly mixed $default = null, public readonly bool $autoIncrement = false, diff --git a/src/Database/Reflection/Table.php b/src/Database/Reflection/Table.php index 8dc3cc651..b24e92ac5 100644 --- a/src/Database/Reflection/Table.php +++ b/src/Database/Reflection/Table.php @@ -53,7 +53,7 @@ private function initColumns(): void { $res = []; foreach ($this->reflection->getDriver()->getColumns($this->name) as $row) { - $res[$row['name']] = new Column($row['name'], $this, $row['nativetype'], $row['size'], $row['nullable'], $row['default'], $row['autoincrement'], $row['primary'], $row['comment'] ?? null, $row['vendor']); + $res[$row['name']] = new Column($row['name'], $this, $row['nativetype'], $row['size'], $row['scale'], $row['nullable'], $row['default'], $row['autoincrement'], $row['primary'], $row['comment'] ?? null, $row['vendor']); } $this->columns = $res; } diff --git a/tests/Database/Reflection.columns.mysql.phpt b/tests/Database/Reflection.columns.mysql.phpt index aea563ff8..681e14706 100644 --- a/tests/Database/Reflection.columns.mysql.phpt +++ b/tests/Database/Reflection.columns.mysql.phpt @@ -23,6 +23,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => $version80 ? 'INT UNSIGNED' : 'INT', 'size' => $version80 ? null : 11, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -33,6 +34,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => $version80 ? null : 11, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -43,6 +45,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => $version80 ? null : 6, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -53,6 +56,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => $version80 ? null : 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -63,6 +67,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMINT', 'size' => $version80 ? null : 9, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -73,6 +78,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => $version80 ? null : 20, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -83,6 +89,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -93,6 +100,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -103,6 +111,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 0, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -113,6 +122,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -123,6 +133,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -133,6 +144,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -143,6 +155,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -153,6 +166,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -163,6 +177,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -173,6 +188,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMP', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -183,6 +199,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'YEAR', 'size' => $version80 ? null : 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -193,6 +210,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHAR', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -203,6 +221,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -213,6 +232,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BINARY', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -223,6 +243,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBINARY', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -233,6 +254,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -243,6 +265,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -253,6 +276,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -263,6 +287,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LONGBLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -273,6 +298,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -283,6 +309,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -293,6 +320,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -303,6 +331,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LONGTEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -313,6 +342,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'ENUM', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -323,6 +353,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SET', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -337,6 +368,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.postgre.phpt b/tests/Database/Reflection.columns.postgre.phpt index cede867a6..bee303f4c 100644 --- a/tests/Database/Reflection.columns.postgre.phpt +++ b/tests/Database/Reflection.columns.postgre.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT2', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT4', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT8', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 3, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT4', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT8', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MONEY', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOOL', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => 4, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMP', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIMESTAMPTZ', 'size' => 8, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INTERVAL', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BPCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TSQUERY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TSVECTOR', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UUID', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'XML', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CIDR', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INET', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MACADDR', 'size' => 6, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => -3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBIT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BYTEA', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOX', 'size' => 32, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CIRCLE', 'size' => 24, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -302,6 +330,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'LSEG', 'size' => 32, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -312,6 +341,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'PATH', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -322,6 +352,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'POINT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -332,6 +363,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'POLYGON', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -346,6 +378,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.sqlite.phpt b/tests/Database/Reflection.columns.sqlite.phpt index f88fe1f45..18e6369d4 100644 --- a/tests/Database/Reflection.columns.sqlite.phpt +++ b/tests/Database/Reflection.columns.sqlite.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INTEGER', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MEDIUMINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UNSIGNED BIG INT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT2', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT8', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHARACTER', 'size' => 20, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 255, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARYING CHARACTER', 'size' => 255, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NCHAR', 'size' => 55, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NATIVE CHARACTER', 'size' => 70, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NVARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'REAL', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DOUBLE PRECISION', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 10, + 'scale' => 5, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BOOLEAN', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BLOB', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -306,6 +334,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.columns.sqlsrv.phpt b/tests/Database/Reflection.columns.sqlsrv.phpt index f2878f06b..5502e719b 100644 --- a/tests/Database/Reflection.columns.sqlsrv.phpt +++ b/tests/Database/Reflection.columns.sqlsrv.phpt @@ -22,6 +22,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIGINT', 'size' => 19, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -32,6 +33,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BINARY', 'size' => 3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -42,6 +44,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'BIT', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -52,6 +55,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'CHAR', 'size' => 5, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -62,6 +66,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATE', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -72,6 +77,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME', 'size' => 23, + 'scale' => 3, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -82,6 +88,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DATETIME2', 'size' => 27, + 'scale' => 7, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -92,6 +99,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'DECIMAL', 'size' => 18, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -102,6 +110,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'FLOAT', 'size' => 53, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -112,6 +121,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'GEOGRAPHY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -122,6 +132,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'GEOMETRY', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -132,6 +143,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'HIERARCHYID', 'size' => 892, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -142,6 +154,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'INT', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -152,6 +165,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'MONEY', 'size' => 19, + 'scale' => 4, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -162,6 +176,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NCHAR', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -172,6 +187,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NTEXT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -182,6 +198,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 10, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -192,6 +209,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NUMERIC', 'size' => 10, + 'scale' => 2, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -202,6 +220,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'NVARCHAR', 'size' => 2, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -212,6 +231,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'REAL', 'size' => 24, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -222,6 +242,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLDATETIME', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -232,6 +253,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLINT', 'size' => 5, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -242,6 +264,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'SMALLMONEY', 'size' => 10, + 'scale' => 4, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -252,6 +275,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TEXT', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -262,6 +286,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TIME', 'size' => 16, + 'scale' => 7, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -272,6 +297,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'TINYINT', 'size' => 3, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -282,6 +308,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'UNIQUEIDENTIFIER', 'size' => 16, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -292,6 +319,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARBINARY', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -302,6 +330,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'VARCHAR', 'size' => 1, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -312,6 +341,7 @@ $expectedColumns = [ 'table' => 'types', 'nativeType' => 'XML', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -326,6 +356,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/Reflection.driver.phpt b/tests/Database/Reflection.driver.phpt index ab6fd71cf..af3b11449 100644 --- a/tests/Database/Reflection.driver.phpt +++ b/tests/Database/Reflection.driver.phpt @@ -60,6 +60,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'INT', 'size' => 11, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => true, @@ -71,6 +72,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => false, @@ -82,6 +84,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'VARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoincrement' => false, @@ -93,6 +96,7 @@ $expectedColumns = [ 'table' => 'author', 'nativetype' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoincrement' => false, diff --git a/tests/Database/Reflection.phpt b/tests/Database/Reflection.phpt index ae9c7bf94..52b3e66d8 100644 --- a/tests/Database/Reflection.phpt +++ b/tests/Database/Reflection.phpt @@ -81,6 +81,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'INT', 'size' => 11, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => true, @@ -91,6 +92,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'VARCHAR', 'size' => 30, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => false, @@ -101,6 +103,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'VARCHAR', 'size' => 100, + 'scale' => null, 'nullable' => false, 'default' => null, 'autoIncrement' => false, @@ -111,6 +114,7 @@ $expectedColumns = [ 'table' => 'author', 'nativeType' => 'DATE', 'size' => null, + 'scale' => null, 'nullable' => true, 'default' => null, 'autoIncrement' => false, @@ -155,6 +159,7 @@ Assert::same( 'table' => $c->table->name, 'nativeType' => $c->nativeType, 'size' => $c->size, + 'scale' => $c->scale, 'nullable' => $c->nullable, 'default' => $c->default, 'autoIncrement' => $c->autoIncrement, diff --git a/tests/Database/_create_db.php b/tests/Database/_create_db.php new file mode 100644 index 000000000..3eab99157 --- /dev/null +++ b/tests/Database/_create_db.php @@ -0,0 +1,6 @@ +query('CREATE DATABASE IF NOT EXISTS nette_test'); diff --git a/tests/Database/_sqlbuilder.phpt b/tests/Database/_sqlbuilder.phpt new file mode 100644 index 000000000..a40e5154f --- /dev/null +++ b/tests/Database/_sqlbuilder.phpt @@ -0,0 +1,34 @@ +getConnection()); + +[$sql, $params] = $preprocessor->process(['SELECT id FROM author WHERE', [ + 'b', +]]); +//dump($sql); +//dump($params); + + +$_POST = ['0) UNION SELECT name, salary FROM users WHERE (0']; + +try { + $explorer->table('Operator1') + ->where($_POST) + ->fetch(); +} catch (Throwable $e) { + echo $e->getMessage(), "\n\n"; +} + +echo $explorer->getConnection()->getLastQueryString(); From 27ddcc3b887566f371227fe8b30a02e861a1346f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 4 Nov 2024 13:49:27 +0100 Subject: [PATCH 59/75] DatabaseExtension: added 'username' as alias for 'user' --- src/Bridges/DatabaseDI/DatabaseExtension.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index c6a9d8c63..46379de8a 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -30,6 +30,7 @@ public function getConfigSchema(): Nette\Schema\Schema Expect::structure([ 'dsn' => Expect::string()->required()->dynamic(), 'user' => Expect::string()->nullable()->dynamic(), + 'username' => Expect::string()->nullable()->dynamic(), 'password' => Expect::string()->nullable()->dynamic(), 'options' => Expect::array(), 'debugger' => Expect::bool(), @@ -88,7 +89,7 @@ private function setupDatabase(\stdClass $config, string $name): void } $connection = $builder->addDefinition($this->prefix("$name.connection")) - ->setFactory(Nette\Database\Connection::class, [$config->dsn, $config->user, $config->password, $config->options]) + ->setFactory(Nette\Database\Connection::class, [$config->dsn, $config->username ?? $config->user, $config->password, $config->options]) ->setAutowired($config->autowired); $structure = $builder->addDefinition($this->prefix("$name.structure")) From 63262c0008b09c771bf5c8469ffe624d3e494843 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 13 Apr 2026 02:58:04 +0200 Subject: [PATCH 60/75] requires tracy 2.12 --- composer.json | 5 ++++- src/Bridges/DatabaseTracy/ConnectionPanel.php | 21 ++++--------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index 52b6e2b5b..5f648e145 100644 --- a/composer.json +++ b/composer.json @@ -24,12 +24,15 @@ "nette/tester": "^2.6", "nette/di": "^3.1", "mockery/mockery": "^1.6@stable", - "tracy/tracy": "^2.9", + "tracy/tracy": "^2.12", "phpstan/phpstan": "^2.1@stable", "phpstan/extension-installer": "^1.4@stable", "nette/phpstan-rules": "^1.0", "jetbrains/phpstorm-attributes": "^1.2" }, + "conflict": { + "tracy/tracy": "<2.12" + }, "autoload": { "classmap": ["src/"], "psr-4": { diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 4e9f42de9..51fed099b 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -11,7 +11,6 @@ use Nette\Database\Connection; use Nette\Database\Helpers; use Tracy; -use function is_string; /** @@ -29,7 +28,6 @@ class ConnectionPanel implements Tracy\IBarPanel /** @var list, list>, ?float, ?int, ?string}> */ private array $queries = []; - private Tracy\BlueScreen $blueScreen; /** @@ -53,7 +51,7 @@ public static function initialize( } if ($addBarPanel) { - $panel = new self($connection, $blueScreen); + $panel = new self($connection); $panel->explain = $explain; $panel->name = $name; $bar ??= Tracy\Debugger::getBar(); @@ -64,10 +62,9 @@ public static function initialize( } - public function __construct(Connection $connection, Tracy\BlueScreen $blueScreen) + public function __construct(Connection $connection) { $connection->onQuery[] = $this->logQuery(...); - $this->blueScreen = $blueScreen; } @@ -87,20 +84,10 @@ private function logQuery(Connection $connection, Nette\Database\ResultSet|\PDOE } $trace = $result instanceof \PDOException - ? array_map(fn($row) => array_diff_key($row, ['args' => null]), $result->getTrace()) + ? array_map(fn($row) => ['args' => []] + $row, $result->getTrace()) : debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - foreach ($trace as $row) { - $file = $row['file'] ?? null; - if (is_string($file) - && preg_match('~\.(php.?|phtml)$~', $file) - && !$this->blueScreen->isCollapsed($file) - ) { - break; - } - - array_shift($trace); - } + $trace = array_slice($trace, Tracy\Helpers::countTransparentFrames($trace)); $this->queries[] = $result instanceof Nette\Database\ResultSet ? [$connection, $result->getQueryString(), $result->getParameters(), $trace, $result->getTime(), $result->getRowCount(), null] From cd6863feb4a2fbea1e5a1d0a2512bc6424c1089c Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 22 Apr 2026 15:31:18 +0200 Subject: [PATCH 61/75] ConnectionPanel: added support for AI agents --- src/Bridges/DatabaseTracy/ConnectionPanel.php | 16 +++++ .../DatabaseTracy/dist/panel.agent.phtml | 64 +++++++++++++++++++ src/Bridges/DatabaseTracy/panel.agent.latte | 22 +++++++ tests/Database.Tracy/ConnectionPanel.phpt | 7 ++ tests/Database.Tracy/panel.agent.md | 17 +++++ 5 files changed, 126 insertions(+) create mode 100644 src/Bridges/DatabaseTracy/dist/panel.agent.phtml create mode 100644 src/Bridges/DatabaseTracy/panel.agent.latte create mode 100644 tests/Database.Tracy/panel.agent.md diff --git a/src/Bridges/DatabaseTracy/ConnectionPanel.php b/src/Bridges/DatabaseTracy/ConnectionPanel.php index 51fed099b..38db718c4 100644 --- a/src/Bridges/DatabaseTracy/ConnectionPanel.php +++ b/src/Bridges/DatabaseTracy/ConnectionPanel.php @@ -164,4 +164,20 @@ public function getPanel(): ?string require __DIR__ . '/dist/panel.phtml'; }); } + + + public function getAgentInfo(): ?string + { + if (!$this->count) { + return null; + } + + return Nette\Utils\Helpers::capture(function () { + $queries = $this->queries; + $name = $this->name; + $count = $this->count; + $totalTime = $this->totalTime; + require __DIR__ . '/dist/panel.agent.phtml'; + }); + } } diff --git a/src/Bridges/DatabaseTracy/dist/panel.agent.phtml b/src/Bridges/DatabaseTracy/dist/panel.agent.phtml new file mode 100644 index 000000000..4e4f2007c --- /dev/null +++ b/src/Bridges/DatabaseTracy/dist/panel.agent.phtml @@ -0,0 +1,64 @@ + $queries */ +/** @var string $name */ +/** @var int $count */ +/** @var float $totalTime */ +if (!$count) /* pos 5:1 */ return; +echo '## Database queries'; +if ($name !== '') /* pos 6:20 */ { + echo ' ('; + echo Tracy\Helpers::escapeMd($name) /* pos 6:39 */; + echo ')'; +} +echo ' + +'; +echo Tracy\Helpers::escapeMd($count) /* pos 8:1 */; +echo ' '; +echo Tracy\Helpers::escapeMd($count === 1 ? 'query' : 'queries') /* pos 8:10 */; +if ($totalTime) /* pos 8:42 */ { + echo ', time '; + echo Tracy\Helpers::escapeMd(sprintf('%0.3f', $totalTime * 1000)) /* pos 8:64 */; + echo ' ms'; +} +echo ' + +```sql +'; +foreach ($queries as $i => [$connection, $sql, $params, $trace, $time, $rows, $error]) /* pos 11:1 */ { + if ($i > 0) /* pos 12:2 */ { + echo "\n"; + } + echo '-- '; + if ($error) /* pos 15:5 */ { + echo 'ERROR: '; + echo Tracy\Helpers::escapeMd($error) /* pos 15:23 */; + } else /* pos 15:31 */ { + echo Tracy\Helpers::escapeMd(sprintf('%0.3f', $time * 1000)) /* pos 15:37 */; + echo ' ms'; + if ($rows !== null) /* pos 15:72 */ { + echo ', '; + echo Tracy\Helpers::escapeMd($rows) /* pos 15:93 */; + echo ' row'; + if ($rows !== 1) /* pos 15:104 */ { + echo 's'; + } + } + } + echo "\n"; + echo Tracy\Helpers::escapeMd(trim($sql)) /* pos 16:2 */; + echo '; +'; + +} + +echo '``` +'; +if (count($queries) < $count) /* pos 19:1 */ { + echo ' +...and '; + echo Tracy\Helpers::escapeMd($count - count($queries)) /* pos 21:9 */; + echo ' more +'; +} diff --git a/src/Bridges/DatabaseTracy/panel.agent.latte b/src/Bridges/DatabaseTracy/panel.agent.latte new file mode 100644 index 000000000..b97099110 --- /dev/null +++ b/src/Bridges/DatabaseTracy/panel.agent.latte @@ -0,0 +1,22 @@ +{varType list $queries} +{varType string $name} +{varType int $count} +{varType float $totalTime} +{exitIf !$count} +## Database queries{if $name !== ''} ({$name}){/if} + +{$count} {$count === 1 ? query : queries}{if $totalTime}, time {sprintf('%0.3f', $totalTime * 1000)} ms{/if} + +```sql +{foreach $queries as $i => [$connection, $sql, $params, $trace, $time, $rows, $error]} + {if $i > 0} + + {/if} + -- {if $error}ERROR: {$error}{else}{sprintf('%0.3f', $time * 1000)} ms{if $rows !== null}, {$rows} row{if $rows !== 1}s{/if}{/if}{/if} + {=trim($sql)}; +{/foreach} +``` +{if count($queries) < $count} + + ...and {$count - count($queries)} more +{/if} diff --git a/tests/Database.Tracy/ConnectionPanel.phpt b/tests/Database.Tracy/ConnectionPanel.phpt index d0c3caed6..cde7d6254 100644 --- a/tests/Database.Tracy/ConnectionPanel.phpt +++ b/tests/Database.Tracy/ConnectionPanel.phpt @@ -25,6 +25,13 @@ test('Tracy Bar', function () { Assert::matchFile(__DIR__ . '/tab.html', $panel->getTab()); Assert::matchFile(__DIR__ . '/panel.html', $panel->getPanel()); + Assert::matchFile(__DIR__ . '/panel.agent.md', $panel->getAgentInfo()); +}); + +test('getAgentInfo() returns null when no queries', function () { + $connection = new Connection('sqlite::memory:'); + $panel = ConnectionPanel::initialize($connection, addBarPanel: true, name: 'foo'); + Assert::null($panel->getAgentInfo()); }); test('Bluescreen Panel', function () { diff --git a/tests/Database.Tracy/panel.agent.md b/tests/Database.Tracy/panel.agent.md new file mode 100644 index 000000000..8f6aef5e1 --- /dev/null +++ b/tests/Database.Tracy/panel.agent.md @@ -0,0 +1,17 @@ +## Database queries (foo) + +4 queries, time %a% ms + +```sql +-- %a% ms +::beginTransaction; + +-- %a% ms, 0 rows +SELECT 1; + +-- %a% ms +::commit; + +-- ERROR: %A% +SELECT; +``` From eaddfe6aec524343de80c44974887bbd0f74f939 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 22 Apr 2026 01:15:44 +0200 Subject: [PATCH 62/75] Connection, Explorer: transaction() supports retry on deadlock, added RetryableException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added optional $attempts parameter. When greater than 1, any exception implementing RetryableException on the outermost transaction triggers a retry of the whole callback. The callback must be idempotent. A new RetryableException marker interface is introduced; DeadlockException, LockTimeoutException and ConnectionLostException all implement it. Applications can mark their own transient errors with the interface (e.g. optimistic lock conflicts) to opt into automatic retries. Nested transactions never retry on their own — the exception bubbles up to the outermost transaction, which honors its own $attempts setting. --- AGENTS.md | 3 +- docs/internals/connection-drivers.md | 11 +- docs/internals/readme.md | 2 +- docs/internals/transactions.md | 18 +- src/Database/Connection.php | 69 +++-- src/Database/Explorer.php | 10 +- src/Database/exceptions.php | 16 +- .../Connection.transaction.retry.phpt | 272 ++++++++++++++++++ 8 files changed, 355 insertions(+), 46 deletions(-) create mode 100644 tests/Database/Connection.transaction.retry.phpt diff --git a/AGENTS.md b/AGENTS.md index be15a26d0..8f79e351d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,8 +69,7 @@ aren't up yet, not a broken test. (`?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`). + `transaction()` issues a real BEGIN/COMMIT/ROLLBACK; there is no partial rollback. 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 diff --git a/docs/internals/connection-drivers.md b/docs/internals/connection-drivers.md index 39d87e89c..b93986d13 100644 --- a/docs/internals/connection-drivers.md +++ b/docs/internals/connection-drivers.md @@ -35,17 +35,16 @@ interval). ``` \PDOException → DriverException - ├── ConnectionException → ConnectionLostException + ├── ConnectionException → ConnectionLostException (Retryable) ├── ConstraintViolationException │ ├── ForeignKey / NotNull / Unique / CheckConstraintViolation - ├── DeadlockException - └── LockTimeoutException + ├── DeadlockException (Retryable) + └── LockTimeoutException (Retryable) ``` 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 +constraint hierarchy, and the three retryable ones implement the `RetryableException` +marker (used by `transaction()` retries). 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 diff --git a/docs/internals/readme.md b/docs/internals/readme.md index 598c52f26..9bf2ccc48 100644 --- a/docs/internals/readme.md +++ b/docs/internals/readme.md @@ -12,4 +12,4 @@ worlds — the low-level core and the Explorer (ActiveRow) layer — so split by `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. +- **[transactions.md](transactions.md)** — `transaction()`, nesting, and retries. diff --git a/docs/internals/transactions.md b/docs/internals/transactions.md index 89e7b5e61..5724d8fe1 100644 --- a/docs/internals/transactions.md +++ b/docs/internals/transactions.md @@ -4,26 +4,24 @@ 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`: +`transaction(callable $callback, int $attempts = 1)` runs a phase machine +(`begin`/`body`/`commit`) inside a retry loop. 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. 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. + back to depth 0 (wrapped in try/catch, since the server may have rolled back + already); +- a **retry** happens only at the outermost level, when `$attempt < $attempts` and the + exception implements `RetryableException` (deadlock / lock timeout / connection + lost) — firing `onRetry` between attempts. **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 diff --git a/src/Database/Connection.php b/src/Database/Connection.php index e01e16059..3e2679704 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -25,6 +25,9 @@ class Connection /** @var array Occurs after query is executed */ public array $onQuery = []; + + /** @var array Occurs before a transaction() retry */ + public array $onRetry = []; private Driver $driver; private SqlPreprocessor $preprocessor; private ?PDO $pdo = null; @@ -233,36 +236,60 @@ public function isInTransaction(): bool /** * Executes callback inside a transaction. Supports nesting. + * When $attempts > 1, a RetryableException raised during begin, commit + * or inside the callback on the outermost transaction triggers a retry + * of the whole callback. Callbacks must be idempotent. The $onRetry + * event fires before each retry and is the place to apply backoff. * @param callable(static): mixed $callback */ - public function transaction(callable $callback): mixed + public function transaction(callable $callback, int $attempts = 1): mixed { - if ($this->transactionDepth === 0) { - $this->beginTransaction(); + if ($attempts < 1) { + throw new Nette\InvalidArgumentException('Number of attempts must be at least 1.'); } - $this->transactionDepth++; - try { - $res = $callback($this); - } catch (\Throwable $e) { - $this->transactionDepth--; - if ($this->transactionDepth === 0) { - try { - $this->rollBack(); - } catch (\Throwable) { - // e.g. after a deadlock the server has already rolled back; the original exception matters more + for ($attempt = 1; ; $attempt++) { + $phase = 'begin'; + try { + if ($this->transactionDepth === 0) { + $this->beginTransaction(); } - } - throw $e; - } + $this->transactionDepth++; + $phase = 'body'; + $res = $callback($this); + $this->transactionDepth--; + $phase = 'commit'; + if ($this->transactionDepth === 0) { + $this->commit(); + } - $this->transactionDepth--; - if ($this->transactionDepth === 0) { - $this->commit(); - } + return $res; + } catch (\Throwable $e) { + if ($phase === 'body') { + $this->transactionDepth--; + } + + if ($this->transactionDepth === 0 && $phase !== 'begin') { + try { + $this->rollBack(); + } catch (\Throwable) { + // server may have already rolled back (deadlock) or the + // connection may be gone; the original $e is what matters + } + } - return $res; + if ($this->transactionDepth === 0 + && $attempt < $attempts + && $e instanceof RetryableException + ) { + Arrays::invoke($this->onRetry, $this, $attempt, $e); + continue; + } + + throw $e; + } + } } diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index 8f891ba2b..4bb4f27aa 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -59,12 +59,16 @@ public function isInTransaction(): bool /** - * Executes callback inside a transaction. + * Executes callback inside a transaction. Supports nesting. + * When $attempts > 1, a RetryableException raised during begin, commit + * or inside the callback on the outermost transaction triggers a retry + * of the whole callback. Callbacks must be idempotent. Subscribe to + * Connection::$onRetry to plug in backoff between attempts. * @param callable(static): mixed $callback */ - public function transaction(callable $callback): mixed + public function transaction(callable $callback, int $attempts = 1): mixed { - return $this->connection->transaction(fn() => $callback($this)); + return $this->connection->transaction(fn() => $callback($this), $attempts); } diff --git a/src/Database/exceptions.php b/src/Database/exceptions.php index 800130046..aa0a0c905 100644 --- a/src/Database/exceptions.php +++ b/src/Database/exceptions.php @@ -8,6 +8,16 @@ namespace Nette\Database; +/** + * Marks transient exceptions that are safe to retry, such as deadlocks, + * lock timeouts, or lost connections. Connection::transaction() retries + * the callback automatically when $attempts > 1. + */ +interface RetryableException +{ +} + + /** * Failed to connect to the database server. */ @@ -21,7 +31,7 @@ class ConnectionException extends DriverException * restart, network failure, idle-timeout). A reconnect is needed before * the connection can be used again. */ -class ConnectionLostException extends ConnectionException +class ConnectionLostException extends ConnectionException implements RetryableException { } @@ -70,7 +80,7 @@ class CheckConstraintViolationException extends ConstraintViolationException * Deadlock or serialization failure detected by the server; the transaction * was rolled back and can be retried. */ -class DeadlockException extends DriverException +class DeadlockException extends DriverException implements RetryableException { } @@ -79,6 +89,6 @@ class DeadlockException extends DriverException * A lock wait exceeded the configured timeout. The statement was aborted, * typically leaving the surrounding transaction alive. */ -class LockTimeoutException extends DriverException +class LockTimeoutException extends DriverException implements RetryableException { } diff --git a/tests/Database/Connection.transaction.retry.phpt b/tests/Database/Connection.transaction.retry.phpt new file mode 100644 index 000000000..40218928d --- /dev/null +++ b/tests/Database/Connection.transaction.retry.phpt @@ -0,0 +1,272 @@ +errorInfo = ['40001', 1213, 'Deadlock found']; + return DeadlockException::from($pdo); +} + + +test('retries on DeadlockException and eventually succeeds', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + throw makeDeadlock(); + } + return 'success'; + }, attempts: 5); + + Assert::same('success', $result); + Assert::same(3, $attempts); +}); + + +test('gives up after exhausting attempts and rethrows last deadlock', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw makeDeadlock(); + }, attempts: 3); + }, + DeadlockException::class, + ); + + Assert::same(3, $attempts); +}); + + +test('does not retry on non-deadlock exceptions', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw new Exception('something else'); + }, attempts: 5); + }, + Exception::class, + 'something else', + ); + + Assert::same(1, $attempts); +}); + + +test('inner nested transaction does not retry on its own', function () { + $connection = new Connection('sqlite::memory:'); + $outerAttempts = 0; + $innerAttempts = 0; + + // outer attempts=1 → inner deadlock bubbles up, outer rethrows without retry + // verifies that inner nested transaction does NOT retry by itself + Assert::exception( + function () use ($connection, &$outerAttempts, &$innerAttempts) { + $connection->transaction(function (Connection $connection) use (&$outerAttempts, &$innerAttempts) { + $outerAttempts++; + $connection->transaction(function () use (&$innerAttempts) { + $innerAttempts++; + throw makeDeadlock(); + }, attempts: 5); + }); + }, + DeadlockException::class, + ); + + Assert::same(1, $outerAttempts); + Assert::same(1, $innerAttempts); +}); + + +test('outer transaction retries even when inner transaction throws deadlock', function () { + $connection = new Connection('sqlite::memory:'); + $outerAttempts = 0; + + $result = $connection->transaction(function (Connection $connection) use (&$outerAttempts) { + $outerAttempts++; + if ($outerAttempts < 3) { + $connection->transaction(function () { + throw makeDeadlock(); + }); + } + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(3, $outerAttempts); +}); + + +test('default attempts = 1 does not retry', function () { + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + Assert::exception( + function () use ($connection, &$attempts) { + $connection->transaction(function () use (&$attempts) { + $attempts++; + throw makeDeadlock(); + }); + }, + DeadlockException::class, + ); + + Assert::same(1, $attempts); +}); + + +test('attempts < 1 throws InvalidArgumentException', function () { + $connection = new Connection('sqlite::memory:'); + + Assert::exception( + fn() => $connection->transaction(fn() => null, attempts: 0), + Nette\InvalidArgumentException::class, + 'Number of attempts must be at least 1.', + ); +}); + + +test('retries any user-defined RetryableException', function () { + $userException = new class ('optimistic lock conflict') extends \RuntimeException implements RetryableException { + }; + + $connection = new Connection('sqlite::memory:'); + $attempts = 0; + + $result = $connection->transaction(function () use (&$attempts, $userException) { + $attempts++; + if ($attempts < 2) { + throw $userException; + } + return 'ok'; + }, attempts: 3); + + Assert::same('ok', $result); + Assert::same(2, $attempts); +}); + + +test('onRetry hook fires before each retry with attempt number and exception', function () { + $connection = new Connection('sqlite::memory:'); + $hookCalls = []; + $connection->onRetry[] = function (Connection $conn, int $attempt, RetryableException $e) use (&$hookCalls) { + $hookCalls[] = [$attempt, $e::class]; + }; + + $attempts = 0; + $connection->transaction(function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + throw makeDeadlock(); + } + return 'ok'; + }, attempts: 5); + + Assert::same([[1, DeadlockException::class], [2, DeadlockException::class]], $hookCalls); +}); + + +test('onRetry hook does not fire when retry is exhausted', function () { + $connection = new Connection('sqlite::memory:'); + $hookCalls = 0; + $connection->onRetry[] = function () use (&$hookCalls) { + $hookCalls++; + }; + + Assert::exception( + fn() => $connection->transaction(fn() => throw makeDeadlock(), attempts: 3), + DeadlockException::class, + ); + + // fires before attempts 2 and 3; not before the final failed throw + Assert::same(2, $hookCalls); +}); + + +test('retries when commit() throws RetryableException', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public int $commitFailuresLeft = 2; + + public function commit(): void + { + if ($this->commitFailuresLeft-- > 0) { + try { + parent::rollBack(); + } catch (\Throwable) { + } + throw makeDeadlock(); + } + parent::commit(); + } + }; + + $attempts = 0; + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(3, $attempts); +}); + + +test('retries when beginTransaction() throws RetryableException', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public int $beginFailuresLeft = 2; + + public function beginTransaction(): void + { + if ($this->beginFailuresLeft-- > 0) { + throw makeDeadlock(); + } + parent::beginTransaction(); + } + }; + + $attempts = 0; + $result = $connection->transaction(function () use (&$attempts) { + $attempts++; + return 'ok'; + }, attempts: 5); + + Assert::same('ok', $result); + Assert::same(1, $attempts); +}); + + +test('failure inside rollBack() does not mask the original exception', function () { + $connection = new class ('sqlite::memory:') extends Connection { + public function rollBack(): void + { + throw new RuntimeException('rollback failed'); + } + }; + + $original = makeDeadlock(); + Assert::exception( + fn() => $connection->transaction(fn() => throw $original), + DeadlockException::class, + ); +}); From c5c1c2399b21dcb78ba959243c886b8f39911e84 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 9 Mar 2026 01:30:52 +0100 Subject: [PATCH 63/75] added EntityMapping for row class mapping and `mapping` config option --- src/Bridges/DatabaseDI/DatabaseExtension.php | 12 +- src/Database/DefaultEntityMapping.php | 69 +++++++ src/Database/EntityMapping.php | 21 ++ src/Database/Explorer.php | 11 +- .../DatabaseExtension.entityMapping.phpt | 183 ++++++++++++++++++ 5 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/Database/DefaultEntityMapping.php create mode 100644 src/Database/EntityMapping.php create mode 100644 tests/Database.DI/DatabaseExtension.entityMapping.phpt diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 46379de8a..46bae585b 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -38,6 +38,12 @@ public function getConfigSchema(): Nette\Schema\Schema 'reflection' => Expect::string(), // BC 'conventions' => Expect::string('discovered'), // Nette\Database\Conventions\DiscoveredConventions 'autowired' => Expect::bool(), + 'mapping' => Expect::structure([ + 'tables' => Expect::anyOf( + Expect::string()->transform(fn(string $v) => ['*' => $v]), + Expect::arrayOf('string', 'string'), + )->default([]), + ]), ]), )->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 @@ -119,8 +125,12 @@ private function setupDatabase(\stdClass $config, string $name): void ->setAutowired($config->autowired); } + $entityMapping = $config->mapping?->tables + ? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables]) + : null; + $builder->addDefinition($this->prefix("$name.explorer")) - ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions]) + ->setFactory(Nette\Database\Explorer::class, [$connection, $structure, $conventions, null, $entityMapping]) ->setAutowired($config->autowired); $builder->addAlias($this->prefix("$name.context"), $this->prefix("$name.explorer")); diff --git a/src/Database/DefaultEntityMapping.php b/src/Database/DefaultEntityMapping.php new file mode 100644 index 000000000..4a16efd7b --- /dev/null +++ b/src/Database/DefaultEntityMapping.php @@ -0,0 +1,69 @@ +|string> $tables table-to-class map; keys + * may contain a single '*' wildcard (e.g. 'forum_*'), and a bare '*' acts as a catch-all + * fallback. Class names may contain '*' which is replaced with PascalCase of the captured + * portion (or the full table name for exact keys). Exact keys take precedence; wildcard + * entries are tried in declaration order. + */ + public function __construct( + private readonly array $tables = [], + ) { + } + + + public function getClassName(string $table): ?string + { + if (isset($this->tables[$table])) { + return $this->expandClass($this->tables[$table], $table); + } + + foreach ($this->tables as $pattern => $class) { + if (!str_contains($pattern, '*')) { + continue; + } + $regex = '#^' . str_replace('\*', '(.*)', preg_quote($pattern, '#')) . '$#D'; + if (preg_match($regex, $table, $m)) { + return $this->expandClass($class, $m[1]); + } + } + + return null; + } + + + /** + * Substitutes '*' in the class pattern with PascalCase of the captured name. + * @return class-string + */ + private function expandClass(string $class, string $capture): string + { + /** @var class-string $result */ + $result = str_contains($class, '*') + ? str_replace('*', self::toPascalCase($capture), $class) + : $class; + return $result; + } + + + private static function toPascalCase(string $name): string + { + $name = preg_replace('#^.*\.#', '', $name); // strip schema prefix + return str_replace(' ', '', ucwords(strtr($name, '_', ' '))); + } +} diff --git a/src/Database/EntityMapping.php b/src/Database/EntityMapping.php new file mode 100644 index 000000000..8076455ce --- /dev/null +++ b/src/Database/EntityMapping.php @@ -0,0 +1,21 @@ + + */ + function getClassName(string $table): ?string; +} diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index 4bb4f27aa..3593baaa8 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -26,6 +26,7 @@ public function __construct( private readonly IStructure $structure, ?Conventions $conventions = null, private readonly ?Nette\Caching\Storage $cacheStorage = null, + private readonly ?EntityMapping $entityMapping = null, ) { $this->conventions = $conventions ?: new StaticConventions; } @@ -127,6 +128,12 @@ public function getConventions(): Conventions } + public function getEntityMapping(): ?EntityMapping + { + return $this->entityMapping; + } + + /** * Creates an ActiveRow instance. Override in a subclass to map tables to custom row classes. * @param array $data @@ -134,7 +141,9 @@ public function getConventions(): Conventions */ public function createActiveRow(array $data, Table\Selection $selection): Table\ActiveRow { - return new Table\ActiveRow($data, $selection); + $class = $this->entityMapping?->getClassName($selection->getName()); + $class = $class && class_exists($class) ? $class : Table\ActiveRow::class; + return new $class($data, $selection); } diff --git a/tests/Database.DI/DatabaseExtension.entityMapping.phpt b/tests/Database.DI/DatabaseExtension.entityMapping.phpt new file mode 100644 index 000000000..4a9de0dba --- /dev/null +++ b/tests/Database.DI/DatabaseExtension.entityMapping.phpt @@ -0,0 +1,183 @@ +load(Tester\FileMock::create($neon, 'neon')); + $compiler = new DI\Compiler; + $compiler->addExtension('database', new DatabaseExtension(false)); + eval($compiler->addConfig($config)->setClassName($className)->compile()); + $container = new $className; + $container->initialize(); + return $container; +} + + +function getEntityMapping(Explorer $explorer): ?Nette\Database\EntityMapping +{ + return (new ReflectionProperty($explorer, 'entityMapping'))->getValue($explorer); +} + + +test('full mapping with tables map', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + mapping: + tables: + special: App\Entity\SpecialRow + "*": App\Entity\*Row + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container2'); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(DefaultEntityMapping::class, getEntityMapping($explorer)); +}); + + +test('tables string shortcut', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + mapping: + tables: App\Entity\*Row + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container2b'); + + $explorer = $container->getService('database.default.explorer'); + Assert::type(DefaultEntityMapping::class, getEntityMapping($explorer)); +}); + + +test('no mapping by default', function () { + $container = createContainer(' + database: + dsn: "sqlite::memory:" + debugger: no + + services: + cache: Nette\Caching\Storages\DevNullStorage + ', 'Container3'); + + $explorer = $container->getService('database.default.explorer'); + Assert::null(getEntityMapping($explorer)); +}); + + +test('DefaultEntityMapping: explicit tables', function () { + $mapping = new DefaultEntityMapping([ + 'my_table' => 'Nette\Database\Table\ActiveRow', + ]); + + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping->getClassName('my_table')); + Assert::null($mapping->getClassName('other')); +}); + + +test('DefaultEntityMapping: exact key overrides wildcard fallback', function () { + $mapping = new DefaultEntityMapping([ + 'special' => 'Nette\Database\Table\ActiveRow', + '*' => 'Nette\Database\Table\*', + ]); + + Assert::same(Nette\Database\Table\ActiveRow::class, $mapping->getClassName('special')); + Assert::same('Nette\Database\Table\ActiveRow', $mapping->getClassName('active_row')); +}); + + +test('DefaultEntityMapping: bare wildcard with snake_case to PascalCase', function () { + $mapping = new DefaultEntityMapping(['*' => 'Nette\Database\Table\*']); + + Assert::same('Nette\Database\Table\ActiveRow', $mapping->getClassName('active_row')); + Assert::same('Nette\Database\Table\NonexistentTable', $mapping->getClassName('nonexistent_table')); +}); + + +test('DefaultEntityMapping: empty map returns null', function () { + $mapping = new DefaultEntityMapping; + + Assert::null($mapping->getClassName('any_table')); +}); + + +test('DefaultEntityMapping: schema prefix is stripped in class name', function () { + $mapping = new DefaultEntityMapping(['*' => 'App\Model\*Row']); + + Assert::same('App\Model\UserAccountRow', $mapping->getClassName('public.user_account')); + Assert::same('App\Model\UserAccountRow', $mapping->getClassName('user_account')); +}); + + +test('DefaultEntityMapping: wildcard pattern in tables key', function () { + $mapping = new DefaultEntityMapping([ + 'forum_*' => 'App\Forum\*Row', + 'shop_*' => 'App\Shop\*Row', + ]); + + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); + Assert::same('App\Forum\UserAccountRow', $mapping->getClassName('forum_user_account')); + Assert::same('App\Shop\OrderRow', $mapping->getClassName('shop_order')); + Assert::null($mapping->getClassName('unmatched_table')); +}); + + +test('DefaultEntityMapping: exact key wins over wildcard', function () { + $mapping = new DefaultEntityMapping([ + 'forum_post' => 'App\Forum\Post', + 'forum_*' => 'App\Forum\*Row', + ]); + + Assert::same('App\Forum\Post', $mapping->getClassName('forum_post')); + Assert::same('App\Forum\TagRow', $mapping->getClassName('forum_tag')); +}); + + +test('DefaultEntityMapping: wildcard patterns tried in declaration order', function () { + $mapping = new DefaultEntityMapping([ + 'forum_admin_*' => 'App\Admin\*Row', + 'forum_*' => 'App\Forum\*Row', + ]); + + Assert::same('App\Admin\UserRow', $mapping->getClassName('forum_admin_user')); + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); +}); + + +test('DefaultEntityMapping: bare * acts as catch-all fallback', function () { + $mapping = new DefaultEntityMapping([ + 'forum_*' => 'App\Forum\*Row', + '*' => 'App\Entity\*Row', + ]); + + Assert::same('App\Forum\PostRow', $mapping->getClassName('forum_post')); + Assert::same('App\Entity\UserRow', $mapping->getClassName('user')); +}); + + +test('DefaultEntityMapping: wildcard value without * is fixed class', function () { + $mapping = new DefaultEntityMapping([ + 'log_*' => 'App\Logging\LogRow', + ]); + + Assert::same('App\Logging\LogRow', $mapping->getClassName('log_access')); + Assert::same('App\Logging\LogRow', $mapping->getClassName('log_error')); +}); From 3efab6ddb196c64a646684301c200ff9b5d9e857 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 20 May 2026 15:13:27 +0200 Subject: [PATCH 64/75] EntityMapping: added column-to-property name translation --- docs/internals/explorer.md | 15 +- src/Bridges/DatabaseDI/DatabaseExtension.php | 5 +- src/Database/DefaultEntityMapping.php | 35 +++ src/Database/EntityMapping.php | 15 +- src/Database/Helpers.php | 25 +- src/Database/Table/ActiveRow.php | 48 ++-- src/Database/Table/Selection.php | 10 + src/Database/Table/SqlBuilder.php | 31 ++- .../DatabaseExtension.entityMapping.phpt | 36 +++ .../EntityMapping.integration.phpt | 221 ++++++++++++++++++ 10 files changed, 413 insertions(+), 28 deletions(-) create mode 100644 tests/Database.DI/EntityMapping.integration.phpt diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index 8fb604789..6f7c34948 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -51,13 +51,14 @@ separate property, so every read falls into `__get` and flows through `$data` 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)`. +read-side enum conversion — `BackedEnum` handling exists only on the write side, in +the preprocessor.) + +- `__get($key)`: maps property→column via `EntityMapping` → `accessColumn` → returns + `$data[$column]`; 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. diff --git a/src/Bridges/DatabaseDI/DatabaseExtension.php b/src/Bridges/DatabaseDI/DatabaseExtension.php index 46bae585b..9e147d7da 100644 --- a/src/Bridges/DatabaseDI/DatabaseExtension.php +++ b/src/Bridges/DatabaseDI/DatabaseExtension.php @@ -43,6 +43,7 @@ public function getConfigSchema(): Nette\Schema\Schema Expect::string()->transform(fn(string $v) => ['*' => $v]), Expect::arrayOf('string', 'string'), )->default([]), + 'camelCase' => Expect::bool(false), ]), ]), )->before(fn($val) => is_array($val) && $val && !array_key_exists('dsn', $val) @@ -125,8 +126,8 @@ private function setupDatabase(\stdClass $config, string $name): void ->setAutowired($config->autowired); } - $entityMapping = $config->mapping?->tables - ? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables]) + $entityMapping = $config->mapping?->tables || $config->mapping?->camelCase + ? new Nette\DI\Definitions\Statement(Nette\Database\DefaultEntityMapping::class, [$config->mapping->tables, $config->mapping->camelCase]) : null; $builder->addDefinition($this->prefix("$name.explorer")) diff --git a/src/Database/DefaultEntityMapping.php b/src/Database/DefaultEntityMapping.php index 4a16efd7b..5c27e877e 100644 --- a/src/Database/DefaultEntityMapping.php +++ b/src/Database/DefaultEntityMapping.php @@ -14,15 +14,24 @@ */ final class DefaultEntityMapping implements EntityMapping { + /** @var array */ + private array $propertyCache = []; + + /** @var array */ + private array $columnCache = []; + + /** * @param array|string> $tables table-to-class map; keys * may contain a single '*' wildcard (e.g. 'forum_*'), and a bare '*' acts as a catch-all * fallback. Class names may contain '*' which is replaced with PascalCase of the captured * portion (or the full table name for exact keys). Exact keys take precedence; wildcard * entries are tried in declaration order. + * @param bool $camelCase whether to convert snake_case column names to camelCase properties */ public function __construct( private readonly array $tables = [], + private readonly bool $camelCase = false, ) { } @@ -61,6 +70,32 @@ private function expandClass(string $class, string $capture): string } + /** + * With camelCase enabled, expects a snake_case column name (e.g. 'first_name') + * and returns its camelCase property form (e.g. 'firstName'). + */ + public function getPropertyName(string $name): string + { + return $this->camelCase + ? $this->propertyCache[$name] ??= lcfirst(self::toPascalCase($name)) + : $name; + } + + + /** + * With camelCase enabled, expects a camelCase property name (e.g. 'firstName') + * and returns its snake_case column form (e.g. 'first_name'). PascalCase + * input would produce a leading underscore (e.g. 'FirstName' → '_first_name'), + * so the first letter is expected to be lowercase. + */ + public function getColumnName(string $name): string + { + return $this->camelCase + ? $this->columnCache[$name] ??= strtolower((string) preg_replace('#[A-Z]#', '_$0', $name)) + : $name; + } + + private static function toPascalCase(string $name): string { $name = preg_replace('#^.*\.#', '', $name); // strip schema prefix diff --git a/src/Database/EntityMapping.php b/src/Database/EntityMapping.php index 8076455ce..0c8a59816 100644 --- a/src/Database/EntityMapping.php +++ b/src/Database/EntityMapping.php @@ -9,7 +9,7 @@ /** - * Resolves PHP class name for each database table. + * Translates identifier names between PHP conventions and database conventions. */ interface EntityMapping { @@ -18,4 +18,17 @@ interface EntityMapping * @return ?class-string */ function getClassName(string $table): ?string; + + /** + * Translates database column name to PHP property name. + */ + function getPropertyName(string $name): string; + + /** + * Translates PHP property name to database column name. In dotted paths + * (e.g. 'book.title' in WHERE/ORDER fragments) it is invoked only on the + * last segment; preceding segments are treated as table/alias names and + * left untouched. + */ + function getColumnName(string $name): string; } diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php index 5cd6f740b..f90ea247b 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_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; +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_int, 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; /** @@ -445,6 +445,29 @@ public static function isRowList(array $data): bool } + /** + * Translates array keys from PHP property names to database column names via EntityMapping. + * Preserves integer keys and compound assignment operator suffixes (e.g. `firstName+=`). + * @param array $data + * @return array + * @internal + */ + public static function translateColumns(array $data, EntityMapping $mapping): array + { + $result = []; + foreach ($data as $key => $value) { + if (is_int($key)) { + $result[$key] = $value; + } elseif (preg_match('#^(.*?)([+\-]?=)$#D', $key, $m)) { + $result[$mapping->getColumnName($m[1]) . $m[2]] = $value; + } else { + $result[$mapping->getColumnName($key)] = $value; + } + } + return $result; + } + + /** * Parses a SQL column type string into its components. * @return array{type: ?string, size: ?int, scale: ?int, parameters: ?string} diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index 03d0af74c..b21bd1c45 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, is_string, iterator_to_array; +use function array_intersect_key, array_key_exists, array_keys, array_map, implode, is_array, is_string, iterator_to_array; /** @@ -19,6 +19,7 @@ class ActiveRow implements \IteratorAggregate, IRow { private bool $dataRefreshed = false; + private readonly ?Nette\Database\EntityMapping $entityMapping; public function __construct( @@ -27,6 +28,7 @@ public function __construct( /** @var Selection */ private Selection $table, ) { + $this->entityMapping = $table->getExplorer()->getEntityMapping(); } @@ -66,12 +68,23 @@ public function __toString(): string public function toArray(): array { $this->accessColumn(null); + $entityMapping = $this->entityMapping; + if ($entityMapping) { + $translated = []; + foreach ($this->data as $key => $value) { + $translated[$entityMapping->getPropertyName($key)] = $value; + } + return $translated; + } return $this->data; } /** * Returns primary key value, or an array of values for composite primary keys. + * Composite key arrays are keyed by database column names (unlike toArray(), + * which uses property names) so the result can be passed directly to + * Selection::wherePrimary(). */ public function getPrimary(bool $throw = true): mixed { @@ -166,7 +179,10 @@ public function update(iterable $data): bool ->wherePrimary($primary); if ($selection->update($data)) { - if ($tmp = array_intersect_key($data, $primary)) { + $columnData = $this->entityMapping + ? Nette\Database\Helpers::translateColumns($data, $this->entityMapping) + : $data; + if ($tmp = array_intersect_key($columnData, $primary)) { $selection = $this->table->createSelectionInstance() ->wherePrimary($tmp + $primary); } @@ -208,8 +224,7 @@ public function delete(): int /** @return \ArrayIterator */ public function getIterator(): \Iterator { - $this->accessColumn(null); - return new \ArrayIterator($this->data); + return new \ArrayIterator($this->toArray()); } @@ -253,8 +268,10 @@ public function __set(string $column, mixed $value): void */ public function &__get(string $key): mixed { - if ($this->accessColumn($key)) { - return $this->data[$key]; + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + return $this->data[$column]; } $referenced = $this->table->getReferencedTable($this, $key); @@ -267,21 +284,26 @@ public function &__get(string $key): mixed // 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]; + if (array_key_exists($column, $this->data)) { + return $this->data[$column]; } } - $this->removeAccessColumn($key); - $hint = Nette\Utils\Helpers::getSuggestion(array_keys($this->data), $key); + $this->removeAccessColumn($column); + $available = $this->entityMapping + ? array_map(fn(string $col) => $this->entityMapping->getPropertyName($col), array_keys($this->data)) + : array_keys($this->data); + $hint = Nette\Utils\Helpers::getSuggestion($available, $key); throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); } public function __isset(string $key): bool { - if ($this->accessColumn($key)) { - return isset($this->data[$key]); + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + return isset($this->data[$column]); } $referenced = $this->table->getReferencedTable($this, $key); @@ -290,7 +312,7 @@ public function __isset(string $key): bool return (bool) $referenced; } - $this->removeAccessColumn($key); + $this->removeAccessColumn($column); return false; } diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 85cb8916d..f9a2a123c 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -849,6 +849,12 @@ public function insert(iterable $data): ActiveRow|array|int $data = array_values($data); // keys may have gaps, ?values needs a list } + if ($mapping = $this->explorer->getEntityMapping()) { + $data = $bulk + ? array_map(fn(array $row) => Nette\Database\Helpers::translateColumns($row, $mapping), $data) + : Nette\Database\Helpers::translateColumns($data, $mapping); + } + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); $this->loadRefCache(); @@ -947,6 +953,10 @@ public function update(iterable $data): int return 0; } + if ($mapping = $this->explorer->getEntityMapping()) { + $data = Nette\Database\Helpers::translateColumns($data, $mapping); + } + return $this->explorer ->query($this->sqlBuilder->buildUpdateQuery(), ...array_merge([$data], $this->sqlBuilder->getParameters())) ->getRowCount() diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 6191439fe..4a4cbe904 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -10,6 +10,7 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Driver; +use Nette\Database\EntityMapping; use Nette\Database\Explorer; use Nette\Database\IStructure; use Nette\Database\SqlLiteral; @@ -65,6 +66,7 @@ class SqlBuilder private string $conditionScope = ''; private readonly Driver $driver; private readonly IStructure $structure; + private readonly ?EntityMapping $entityMapping; /** @var array table fullName => exists */ private array $cacheTableList = []; @@ -79,6 +81,7 @@ public function __construct(string $tableName, Explorer $explorer) $this->driver = $explorer->getConnection()->getDriver(); $this->conventions = $explorer->getConventions(); $this->structure = $explorer->getStructure(); + $this->entityMapping = $explorer->getEntityMapping(); $tableNameParts = explode('.', $tableName); $this->delimitedTable = implode('.', array_map($this->driver->delimite(...), $tableNameParts)); $this->checkUniqueTableName(end($tableNameParts), $tableName); @@ -922,11 +925,31 @@ protected function buildQueryEnd(): string */ protected function tryDelimite(string $s): string { + if (!$this->entityMapping) { + return preg_replace_callback( + '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di', + fn(array $m): string => strtoupper($m[0]) === $m[0] + ? $m[0] + : $this->driver->delimite($m[0]), + $s, + ); + } + return preg_replace_callback( - '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di', - fn(array $m): string => strtoupper($m[0]) === $m[0] - ? $m[0] - : $this->driver->delimite($m[0]), + '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*(?=[^\w`"(\]]|$)#Di', + function (array $m): string { + $parts = explode('.', $m[0]); + $last = count($parts) - 1; + foreach ($parts as $i => &$part) { + if (strtoupper($part) !== $part) { + if ($i === $last) { + $part = $this->entityMapping->getColumnName($part); + } + $part = $this->driver->delimite($part); + } + } + return implode('.', $parts); + }, $s, ); } diff --git a/tests/Database.DI/DatabaseExtension.entityMapping.phpt b/tests/Database.DI/DatabaseExtension.entityMapping.phpt index 4a9de0dba..4bc98d64e 100644 --- a/tests/Database.DI/DatabaseExtension.entityMapping.phpt +++ b/tests/Database.DI/DatabaseExtension.entityMapping.phpt @@ -119,6 +119,42 @@ test('DefaultEntityMapping: empty map returns null', function () { }); +test('DefaultEntityMapping: camelCase off = identity', function () { + $mapping = new DefaultEntityMapping; + + Assert::same('first_name', $mapping->getPropertyName('first_name')); + Assert::same('firstName', $mapping->getColumnName('firstName')); +}); + + +test('DefaultEntityMapping: camelCase on', function () { + $mapping = new DefaultEntityMapping(camelCase: true); + + Assert::same('firstName', $mapping->getPropertyName('first_name')); + Assert::same('authorId', $mapping->getPropertyName('author_id')); + Assert::same('id', $mapping->getPropertyName('id')); + Assert::same('name', $mapping->getPropertyName('name')); + Assert::same('createdAt', $mapping->getPropertyName('created_at')); + Assert::same('address2', $mapping->getPropertyName('address2')); + + Assert::same('first_name', $mapping->getColumnName('firstName')); + Assert::same('author_id', $mapping->getColumnName('authorId')); + Assert::same('id', $mapping->getColumnName('id')); + Assert::same('name', $mapping->getColumnName('name')); + Assert::same('created_at', $mapping->getColumnName('createdAt')); + Assert::same('address2', $mapping->getColumnName('address2')); +}); + + +test('DefaultEntityMapping: camelCase roundtrip', function () { + $mapping = new DefaultEntityMapping(camelCase: true); + + foreach (['id', 'name', 'first_name', 'author_id', 'created_at', 'address2'] as $column) { + Assert::same($column, $mapping->getColumnName($mapping->getPropertyName($column))); + } +}); + + test('DefaultEntityMapping: schema prefix is stripped in class name', function () { $mapping = new DefaultEntityMapping(['*' => 'App\Model\*Row']); diff --git a/tests/Database.DI/EntityMapping.integration.phpt b/tests/Database.DI/EntityMapping.integration.phpt new file mode 100644 index 000000000..53c3d0c74 --- /dev/null +++ b/tests/Database.DI/EntityMapping.integration.phpt @@ -0,0 +1,221 @@ +query('CREATE TABLE user_account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email_address TEXT NOT NULL + )'); + $connection->query("INSERT INTO user_account (first_name, last_name, email_address) VALUES ('John', 'Doe', 'john@example.com')"); + $connection->query("INSERT INTO user_account (first_name, last_name, email_address) VALUES ('Jane', 'Smith', 'jane@example.com')"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('__get translates property name to column name', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same('John', $row->FIRST_NAME); + Assert::same('Doe', $row->LAST_NAME); + Assert::same('john@example.com', $row->EMAIL_ADDRESS); + Assert::same(1, $row->ID); +}); + + +test('__get suggestion is in property names', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::exception( + fn() => $row->FIRST_NAM, + Nette\MemberAccessException::class, + "Cannot read an undeclared column 'FIRST_NAM', did you mean 'FIRST_NAME'?", + ); +}); + + +test('__isset translates property name', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::true(isset($row->FIRST_NAME)); + Assert::true(isset($row->ID)); + Assert::false(isset($row->NONEXISTENT)); +}); + + +test('toArray returns translated keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same(['ID', 'FIRST_NAME', 'LAST_NAME', 'EMAIL_ADDRESS'], array_keys($row->toArray())); + Assert::same('John', $row->toArray()['FIRST_NAME']); +}); + + +test('getIterator returns translated keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->fetch(); + + Assert::same(['ID', 'FIRST_NAME', 'LAST_NAME', 'EMAIL_ADDRESS'], array_keys(iterator_to_array($row))); +}); + + +test('update translates property keys to column names', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->get(1); + + $row->update(['FIRST_NAME' => 'Johnny', 'LAST_NAME' => 'Updated']); + + Assert::same('Johnny', $row->FIRST_NAME); + Assert::same('Updated', $row->LAST_NAME); + + $fresh = $explorer->table('user_account')->get(1); + Assert::same('Johnny', $fresh->FIRST_NAME); +}); + + +test('update handles compound assignment operators', function () { + $explorer = createExplorer(new UpperCaseMapping); + $connection = $explorer->getConnection(); + $connection->query('CREATE TABLE product (id INTEGER PRIMARY KEY, total_score INTEGER NOT NULL)'); + $connection->query('INSERT INTO product (total_score) VALUES (10)'); + + $row = $explorer->table('product')->get(1); + $row->update(['TOTAL_SCORE+=' => 5]); + + $fresh = $explorer->table('product')->get(1); + Assert::same(15, $fresh->TOTAL_SCORE); +}); + + +test('where translates via tryDelimite', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account') + ->where('FIRST_NAME', 'Jane') + ->fetch(); + + Assert::same('Jane', $row->FIRST_NAME); + Assert::same('Smith', $row->LAST_NAME); +}); + + +test('order by translates via tryDelimite', function () { + $explorer = createExplorer(new UpperCaseMapping); + $rows = array_values($explorer->table('user_account') + ->order('FIRST_NAME DESC') + ->fetchAll()); + + Assert::same('John', $rows[0]->FIRST_NAME); + Assert::same('Jane', $rows[1]->FIRST_NAME); +}); + + +test('Selection::insert translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $row = $explorer->table('user_account')->insert([ + 'FIRST_NAME' => 'Alice', + 'LAST_NAME' => 'Wonder', + 'EMAIL_ADDRESS' => 'alice@example.com', + ]); + + Assert::same('Alice', $row->FIRST_NAME); + Assert::same('Wonder', $row->LAST_NAME); +}); + + +test('Selection::insert multi-row translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $explorer->table('user_account')->insert([ + ['FIRST_NAME' => 'Bob', 'LAST_NAME' => 'Builder', 'EMAIL_ADDRESS' => 'bob@example.com'], + ['FIRST_NAME' => 'Cara', 'LAST_NAME' => 'Coder', 'EMAIL_ADDRESS' => 'cara@example.com'], + ]); + + $rows = array_values($explorer->table('user_account')->where('FIRST_NAME', ['Bob', 'Cara'])->order('FIRST_NAME')->fetchAll()); + Assert::count(2, $rows); + Assert::same('Bob', $rows[0]->FIRST_NAME); + Assert::same('Cara', $rows[1]->FIRST_NAME); +}); + + +test('Selection::update translates property keys', function () { + $explorer = createExplorer(new UpperCaseMapping); + $affected = $explorer->table('user_account') + ->where('ID', 1) + ->update(['FIRST_NAME' => 'Renamed']); + + Assert::same(1, $affected); + Assert::same('Renamed', $explorer->table('user_account')->get(1)->FIRST_NAME); +}); + + +test('Selection::update translates compound assignment', function () { + $explorer = createExplorer(new UpperCaseMapping); + $connection = $explorer->getConnection(); + $connection->query('CREATE TABLE counter (id INTEGER PRIMARY KEY, total_score INTEGER NOT NULL)'); + $connection->query('INSERT INTO counter (total_score) VALUES (10)'); + + $explorer->table('counter')->where('ID', 1)->update(['TOTAL_SCORE+=' => 7]); + + Assert::same(17, $explorer->table('counter')->get(1)->TOTAL_SCORE); +}); + + +test('round-trip: toArray feeds insert', function () { + $explorer = createExplorer(new UpperCaseMapping); + $source = $explorer->table('user_account')->get(1)->toArray(); + unset($source['ID']); + $source['EMAIL_ADDRESS'] = 'copy@example.com'; + + $row = $explorer->table('user_account')->insert($source); + Assert::same('John', $row->FIRST_NAME); + Assert::same('copy@example.com', $row->EMAIL_ADDRESS); +}); From cecc9bf5bdc94205682527af54b46f7405cc6429 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 16:50:08 +0200 Subject: [PATCH 65/75] ActiveRow: split into Row interface and RowBehavior trait ActiveRow becomes an empty shell over RowBehavior and must stay so. A row class can now compose the trait and implement the Row contract while extending a plain value base class, which enables detached, database-free row values; see the test for the userland recipe. The Row interface is deliberately minimal and meant to be implemented solely via RowBehavior, so it may gain members in minor releases. --- src/Database/Table/ActiveRow.php | 336 +---------------- src/Database/Table/Row.php | 74 ++++ src/Database/Table/RowBehavior.php | 347 ++++++++++++++++++ .../Database/Explorer/RowBehavior.entity.phpt | 147 ++++++++ 4 files changed, 574 insertions(+), 330 deletions(-) create mode 100644 src/Database/Table/Row.php create mode 100644 src/Database/Table/RowBehavior.php create mode 100644 tests/Database/Explorer/RowBehavior.entity.phpt diff --git a/src/Database/Table/ActiveRow.php b/src/Database/Table/ActiveRow.php index b21bd1c45..94129ceca 100644 --- a/src/Database/Table/ActiveRow.php +++ b/src/Database/Table/ActiveRow.php @@ -7,341 +7,17 @@ namespace Nette\Database\Table; -use Nette; -use function array_intersect_key, array_key_exists, array_keys, array_map, implode, is_array, is_string, iterator_to_array; - /** * Represents database row with support for relations. * ActiveRow is based on the great library NotORM http://www.notorm.com written by Jakub Vrana. + * + * Must stay an empty shell over RowBehavior: any state or behavior belongs to the trait, + * so that row classes composing RowBehavior themselves behave identically to ActiveRow. + * * @implements \IteratorAggregate */ -class ActiveRow implements \IteratorAggregate, IRow +class ActiveRow implements Row, \IteratorAggregate, IRow { - private bool $dataRefreshed = false; - private readonly ?Nette\Database\EntityMapping $entityMapping; - - - public function __construct( - /** @var array */ - private array $data, - /** @var Selection */ - private Selection $table, - ) { - $this->entityMapping = $table->getExplorer()->getEntityMapping(); - } - - - /** - * @internal - * @param Selection $table - */ - public function setTable(Selection $table): void - { - $this->table = $table; - } - - - /** - * @internal - * @return Selection - */ - public function getTable(): Selection - { - return $this->table; - } - - - public function getExplorer(): Nette\Database\Explorer - { - return $this->table->getExplorer(); - } - - - public function __toString(): string - { - return (string) $this->getPrimary(); - } - - - /** @return array */ - public function toArray(): array - { - $this->accessColumn(null); - $entityMapping = $this->entityMapping; - if ($entityMapping) { - $translated = []; - foreach ($this->data as $key => $value) { - $translated[$entityMapping->getPropertyName($key)] = $value; - } - return $translated; - } - return $this->data; - } - - - /** - * Returns primary key value, or an array of values for composite primary keys. - * Composite key arrays are keyed by database column names (unlike toArray(), - * which uses property names) so the result can be passed directly to - * Selection::wherePrimary(). - */ - public function getPrimary(bool $throw = true): mixed - { - $primary = $this->table->getPrimary($throw); - if ($primary === null) { - return null; - - } elseif (!is_array($primary)) { - if (isset($this->data[$primary])) { - return $this->data[$primary]; - } elseif ($throw) { - throw new Nette\InvalidStateException("Row does not contain primary $primary column data."); - } else { - return null; - } - } else { - $primaryVal = []; - foreach ($primary as $key) { - if (!isset($this->data[$key])) { - if ($throw) { - throw new Nette\InvalidStateException("Row does not contain primary $key column data."); - } else { - return null; - } - } - - $primaryVal[$key] = $this->data[$key]; - } - - return $primaryVal; - } - } - - - /** - * Returns row signature (composition of primary keys). - */ - public function getSignature(bool $throw = true): string - { - return implode('|', (array) $this->getPrimary($throw)); - } - - - /** - * Returns referenced row, or null if the row does not exist. - */ - public function ref(string $key, ?string $throughColumn = null): ?self - { - $row = $this->table->getReferencedTable($this, $key, $throughColumn); - if ($row === false) { - throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->ref($key)."); - } - - return $row; - } - - - /** - * Returns referencing rows collection. - * @return GroupedSelection - */ - public function related(string $key, ?string $throughColumn = null): GroupedSelection - { - $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)."); - } - - return $groupedSelection; - } - - - /** - * Updates row data and refreshes the instance from database. Returns true if the row was changed. - * @param iterable $data - */ - public function update(iterable $data): bool - { - $data = iterator_to_array($data); - - $primary = $this->getPrimary(); - if (!is_array($primary)) { - $primary = [$this->table->getPrimary() => $primary]; - } - - $selection = $this->table->createSelectionInstance() - ->wherePrimary($primary); - - if ($selection->update($data)) { - $columnData = $this->entityMapping - ? Nette\Database\Helpers::translateColumns($data, $this->entityMapping) - : $data; - if ($tmp = array_intersect_key($columnData, $primary)) { - $selection = $this->table->createSelectionInstance() - ->wherePrimary($tmp + $primary); - } - - $selection->select('*'); - if (($row = $selection->fetch()) === null) { - throw new Nette\InvalidStateException('Database refetch failed; row does not exist!'); - } - - $this->data = $row->data; - return true; - } else { - return false; - } - } - - - /** - * Deletes the row from database. - * @return int number of affected rows - */ - public function delete(): int - { - $res = $this->table->createSelectionInstance() - ->wherePrimary($this->getPrimary()) - ->delete(); - - if ($res > 0 && ($signature = $this->getSignature(throw: false))) { - unset($this->table[$signature]); - } - - return $res; - } - - - /********************* interface IteratorAggregate ****************d*g**/ - - - /** @return \ArrayIterator */ - public function getIterator(): \Iterator - { - return new \ArrayIterator($this->toArray()); - } - - - /********************* interface ArrayAccess & magic accessors ****************d*g**/ - - - public function offsetSet($column, $value): void - { - $this->__set($column, $value); - } - - - public function offsetGet($column): mixed - { - return $this->__get($column); - } - - - public function offsetExists($column): bool - { - return $this->__isset($column); - } - - - public function offsetUnset($column): void - { - $this->__unset($column); - } - - - public function __set(string $column, mixed $value): void - { - throw new Nette\DeprecatedException('ActiveRow is read-only; use update() method instead.'); - } - - - /** - * Returns column value, or a referenced row if the key matches a relationship. - * @return ActiveRow|mixed - * @throws Nette\MemberAccessException if the column does not exist and no relationship is found - */ - public function &__get(string $key): mixed - { - $column = $this->entityMapping?->getColumnName($key) ?? $key; - - if ($this->accessColumn($column)) { - return $this->data[$column]; - } - - $referenced = $this->table->getReferencedTable($this, $key); - if ($referenced !== false) { - $this->accessColumn($key, selectColumn: false); - 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($column, $this->data)) { - return $this->data[$column]; - } - } - - $this->removeAccessColumn($column); - $available = $this->entityMapping - ? array_map(fn(string $col) => $this->entityMapping->getPropertyName($col), array_keys($this->data)) - : array_keys($this->data); - $hint = Nette\Utils\Helpers::getSuggestion($available, $key); - throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); - } - - - public function __isset(string $key): bool - { - $column = $this->entityMapping?->getColumnName($key) ?? $key; - - if ($this->accessColumn($column)) { - return isset($this->data[$column]); - } - - $referenced = $this->table->getReferencedTable($this, $key); - if ($referenced !== false) { - $this->accessColumn($key, selectColumn: false); - return (bool) $referenced; - } - - $this->removeAccessColumn($column); - return false; - } - - - public function __unset(string $key): void - { - throw new Nette\DeprecatedException('ActiveRow is read-only.'); - } - - - /** @internal */ - public function accessColumn(?string $key, bool $selectColumn = true): bool - { - if ($this->table->accessColumn($key, $selectColumn) && !$this->dataRefreshed) { - if (!isset($this->table[$this->getSignature()])) { - throw new Nette\InvalidStateException("Database refetch failed; row with signature '{$this->getSignature()}' does not exist!"); - } - - $this->data = $this->table[$this->getSignature()]->data; - $this->dataRefreshed = true; - } - - $key ??= ''; - return isset($this->data[$key]) || array_key_exists($key, $this->data); - } - - - protected function removeAccessColumn(string $key): void - { - $this->table->removeAccessColumn($key); - } + use RowBehavior; } diff --git a/src/Database/Table/Row.php b/src/Database/Table/Row.php new file mode 100644 index 000000000..f7e7a1986 --- /dev/null +++ b/src/Database/Table/Row.php @@ -0,0 +1,74 @@ + $table + */ + function setTable(Selection $table): void; + + /** + * @internal + * @return Selection + */ + function getTable(): Selection; + + function getExplorer(): Nette\Database\Explorer; + + /** @return array */ + function toArray(): array; + + /** + * Returns primary key value, or an array of values for composite primary keys. + */ + function getPrimary(bool $throw = true): mixed; + + /** + * Returns row signature (composition of primary keys). + */ + function getSignature(bool $throw = true): string; + + /** + * Returns referenced row, or null if the row does not exist. + */ + function ref(string $key, ?string $throughColumn = null): ?ActiveRow; + + /** + * Returns referencing rows collection. + * @return GroupedSelection + */ + function related(string $key, ?string $throughColumn = null): GroupedSelection; + + /** + * Updates row data and refreshes the instance from database. Returns true if the row was changed. + * @param iterable $data + */ + function update(iterable $data): bool; + + /** + * Deletes the row from database. + * @return int number of affected rows + */ + function delete(): int; + + /** @internal */ + function accessColumn(?string $key, bool $selectColumn = true): bool; +} diff --git a/src/Database/Table/RowBehavior.php b/src/Database/Table/RowBehavior.php new file mode 100644 index 000000000..2c7578484 --- /dev/null +++ b/src/Database/Table/RowBehavior.php @@ -0,0 +1,347 @@ + */ + private array $data, + /** @var Selection */ + private Selection $table, + ) { + $this->entityMapping = $table->getExplorer()->getEntityMapping(); + } + + + /** + * @internal + * @param Selection $table + */ + public function setTable(Selection $table): void + { + $this->table = $table; + } + + + /** + * @internal + * @return Selection + */ + public function getTable(): Selection + { + return $this->table; + } + + + public function getExplorer(): Nette\Database\Explorer + { + return $this->table->getExplorer(); + } + + + public function __toString(): string + { + return (string) $this->getPrimary(); + } + + + /** @return array */ + public function toArray(): array + { + $this->accessColumn(null); + $entityMapping = $this->entityMapping; + if ($entityMapping) { + $translated = []; + foreach ($this->data as $key => $value) { + $translated[$entityMapping->getPropertyName($key)] = $value; + } + return $translated; + } + return $this->data; + } + + + /** + * Returns primary key value, or an array of values for composite primary keys. + * Composite key arrays are keyed by database column names (unlike toArray(), + * which uses property names) so the result can be passed directly to + * Selection::wherePrimary(). + */ + public function getPrimary(bool $throw = true): mixed + { + $primary = $this->table->getPrimary($throw); + if ($primary === null) { + return null; + + } elseif (!is_array($primary)) { + if (isset($this->data[$primary])) { + return $this->data[$primary]; + } elseif ($throw) { + throw new Nette\InvalidStateException("Row does not contain primary $primary column data."); + } else { + return null; + } + } else { + $primaryVal = []; + foreach ($primary as $key) { + if (!isset($this->data[$key])) { + if ($throw) { + throw new Nette\InvalidStateException("Row does not contain primary $key column data."); + } else { + return null; + } + } + + $primaryVal[$key] = $this->data[$key]; + } + + return $primaryVal; + } + } + + + /** + * Returns row signature (composition of primary keys). + */ + public function getSignature(bool $throw = true): string + { + return implode('|', (array) $this->getPrimary($throw)); + } + + + /** + * Returns referenced row, or null if the row does not exist. + */ + public function ref(string $key, ?string $throughColumn = null): ?ActiveRow + { + $row = $this->table->getReferencedTable($this, $key, $throughColumn); + if ($row === false) { + throw new Nette\MemberAccessException("No reference found for \${$this->table->getName()}->ref($key)."); + } + + return $row; + } + + + /** + * Returns referencing rows collection. + * @return GroupedSelection + */ + public function related(string $key, ?string $throughColumn = null): GroupedSelection + { + $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)."); + } + + return $groupedSelection; + } + + + /** + * Updates row data and refreshes the instance from database. Returns true if the row was changed. + * @param iterable $data + */ + public function update(iterable $data): bool + { + $data = iterator_to_array($data); + + $primary = $this->getPrimary(); + if (!is_array($primary)) { + $primary = [$this->table->getPrimary() => $primary]; + } + + $selection = $this->table->createSelectionInstance() + ->wherePrimary($primary); + + if ($selection->update($data)) { + $columnData = $this->entityMapping + ? Nette\Database\Helpers::translateColumns($data, $this->entityMapping) + : $data; + if ($tmp = array_intersect_key($columnData, $primary)) { + $selection = $this->table->createSelectionInstance() + ->wherePrimary($tmp + $primary); + } + + $selection->select('*'); + if (($row = $selection->fetch()) === null) { + throw new Nette\InvalidStateException('Database refetch failed; row does not exist!'); + } + + $this->data = $row->data; + return true; + } else { + return false; + } + } + + + /** + * Deletes the row from database. + * @return int number of affected rows + */ + public function delete(): int + { + $res = $this->table->createSelectionInstance() + ->wherePrimary($this->getPrimary()) + ->delete(); + + if ($res > 0 && ($signature = $this->getSignature(throw: false))) { + unset($this->table[$signature]); + } + + return $res; + } + + + /********************* interface IteratorAggregate ****************d*g**/ + + + /** @return \ArrayIterator */ + public function getIterator(): \Iterator + { + return new \ArrayIterator($this->toArray()); + } + + + /********************* interface ArrayAccess & magic accessors ****************d*g**/ + + + public function offsetSet($column, $value): void + { + $this->__set($column, $value); + } + + + public function offsetGet($column): mixed + { + return $this->__get($column); + } + + + public function offsetExists($column): bool + { + return $this->__isset($column); + } + + + public function offsetUnset($column): void + { + $this->__unset($column); + } + + + public function __set(string $column, mixed $value): void + { + throw new Nette\DeprecatedException('ActiveRow is read-only; use update() method instead.'); + } + + + /** + * Returns column value, or a referenced row if the key matches a relationship. + * @return ActiveRow|mixed + * @throws Nette\MemberAccessException if the column does not exist and no relationship is found + */ + public function &__get(string $key): mixed + { + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + return $this->data[$column]; + } + + $referenced = $this->table->getReferencedTable($this, $key); + if ($referenced !== false) { + $this->accessColumn($key, selectColumn: false); + 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($column, $this->data)) { + return $this->data[$column]; + } + } + + $this->removeAccessColumn($column); + $available = $this->entityMapping + ? array_map(fn(string $col) => $this->entityMapping->getPropertyName($col), array_keys($this->data)) + : array_keys($this->data); + $hint = Nette\Utils\Helpers::getSuggestion($available, $key); + throw new Nette\MemberAccessException("Cannot read an undeclared column '$key'" . ($hint ? ", did you mean '$hint'?" : '.')); + } + + + public function __isset(string $key): bool + { + $column = $this->entityMapping?->getColumnName($key) ?? $key; + + if ($this->accessColumn($column)) { + return isset($this->data[$column]); + } + + $referenced = $this->table->getReferencedTable($this, $key); + if ($referenced !== false) { + $this->accessColumn($key, selectColumn: false); + return (bool) $referenced; + } + + $this->removeAccessColumn($column); + return false; + } + + + public function __unset(string $key): void + { + throw new Nette\DeprecatedException('ActiveRow is read-only.'); + } + + + /** @internal */ + public function accessColumn(?string $key, bool $selectColumn = true): bool + { + if ($this->table->accessColumn($key, $selectColumn) && !$this->dataRefreshed) { + if (!isset($this->table[$this->getSignature()])) { + throw new Nette\InvalidStateException("Database refetch failed; row with signature '{$this->getSignature()}' does not exist!"); + } + + $this->data = $this->table[$this->getSignature()]->data; + $this->dataRefreshed = true; + } + + $key ??= ''; + return isset($this->data[$key]) || array_key_exists($key, $this->data); + } + + + protected function removeAccessColumn(string $key): void + { + $this->table->removeAccessColumn($key); + } +} diff --git a/tests/Database/Explorer/RowBehavior.entity.phpt b/tests/Database/Explorer/RowBehavior.entity.phpt new file mode 100644 index 000000000..a68346e77 --- /dev/null +++ b/tests/Database/Explorer/RowBehavior.entity.phpt @@ -0,0 +1,147 @@ +translator_id !== null; + } +} + + +// attached row in its target form: composes RowBehavior, plus a userland unset +// bridging declared value properties to magic column access +final class BookRow extends Book implements Table\Row +{ + use Table\RowBehavior { + __construct as private constructRow; + } + + public function __construct(array $data, Table\Selection $table, bool $deferredFetch = false) + { + $this->constructRow($data, $table, $deferredFetch); + foreach ((new ReflectionClass($this))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if (!$property->isStatic()) { + unset($this->{$property->getName()}); + } + } + } +} + + +class EntityExplorer extends Nette\Database\Explorer +{ + public function createActiveRow(array $data, Table\Selection $selection, bool $deferredFetch = false): Table\ActiveRow + { + return $selection->getName() === 'book' + ? new BookRow($data, $selection, $deferredFetch) + : parent::createActiveRow($data, $selection, $deferredFetch); + } +} + + +$explorer = connectToDB(); +$connection = $explorer->getConnection(); +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + +$cacheMemoryStorage = new Nette\Caching\Storages\MemoryStorage; +$structure = new Nette\Database\Structure($connection, $cacheMemoryStorage); +$conventions = new Nette\Database\Conventions\DiscoveredConventions($structure); +$explorer = new EntityExplorer($connection, $structure, $conventions, $cacheMemoryStorage); + + +test('hydrated row is the entity class and reads columns through magic access', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::type(BookRow::class, $book); + Assert::true($book instanceof Book); + Assert::true($book instanceof Table\Row); + Assert::true($book instanceof Table\ActiveRow); + Assert::same('1001 tipu a triku pro PHP', $book->title); + Assert::same(11, $book->author_id); + Assert::true($book->isTranslated()); +}); + + +test('relations work from the entity row', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::same('Jakub Vrana', $book->author->name); + Assert::same('Jakub Vrana', $book->ref('author', 'author_id')->name); + + $tags = []; + foreach ($book->related('book_tag') as $bookTag) { + $tags[] = $bookTag->tag->name; + } + + sort($tags); + Assert::same(['MySQL', 'PHP'], $tags); +}); + + +test('attached row stays read-only', function () use ($explorer) { + $book = $explorer->table('book')->get(1); + Assert::exception( + fn() => $book->title = 'x', + Nette\DeprecatedException::class, + 'ActiveRow is read-only; use update() method instead.', + ); +}); + + +test('update() writes to database and refreshes declared properties', function () use ($explorer) { + $book = $explorer->table('book')->get(2); + $book->update(['title' => 'JUSH 2']); + Assert::same('JUSH 2', $book->title); + Assert::same('JUSH 2', $explorer->table('book')->get(2)->title); +}); + + +test('insert() returns a lazy entity row completed on first access', function () use ($explorer) { + $row = $explorer->table('book')->insert([ + 'author_id' => 12, + 'title' => 'Value objects in practice', + ]); + Assert::type(BookRow::class, $row); + Assert::same('Value objects in practice', $row->title); + Assert::false($row->isTranslated()); +}); + + +test('detached value is constructible and mutable without database', function () { + $draft = new Book(title: 'Draft', author_id: 12); + Assert::same('Draft', $draft->title); + Assert::false($draft->isTranslated()); + + $draft->translator_id = 11; + Assert::true($draft->isTranslated()); +}); + + +test('detached value refuses database operations', function () { + $draft = new Book(title: 'Draft'); + Assert::exception( + fn() => $draft->update(['title' => 'x']), + Error::class, + '%a%$table must not be accessed before initialization', + ); +}); From 0fe2b92a642232986852026774af3f3f7ae1ce00 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 7 Jun 2026 16:16:49 +0200 Subject: [PATCH 66/75] ActiveRow: subclasses can declare typed public properties for IDE/static analysis --- docs/internals/explorer.md | 13 ++- src/Database/Table/RowBehavior.php | 26 +++++ .../ActiveRow.typedProperties.phpt | 99 +++++++++++++++++++ .../Database/Explorer/RowBehavior.entity.phpt | 18 +--- 4 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 tests/Database.DI/ActiveRow.typedProperties.phpt diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index 6f7c34948..8fc557b92 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -47,17 +47,16 @@ 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 -read-side enum conversion — `BackedEnum` handling exists only on the write side, in -the preprocessor.) +separate property. A subclass may declare typed properties (`public int $id`) for +IDE/PHPStan, but the constructor **`unset()`s them**, so reads fall into `__get` and +flow through `$data`. That is the only way to track which columns are actually read +(SELECT narrowing). (There is no read-side enum conversion — `BackedEnum` handling +exists only on the write side, in the preprocessor.) - `__get($key)`: maps property→column via `EntityMapping` → `accessColumn` → returns `$data[$column]`; 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 + hint). `__set` is read-only (throws). `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. diff --git a/src/Database/Table/RowBehavior.php b/src/Database/Table/RowBehavior.php index 2c7578484..0aa5e9ed1 100644 --- a/src/Database/Table/RowBehavior.php +++ b/src/Database/Table/RowBehavior.php @@ -18,6 +18,9 @@ */ trait RowBehavior { + /** @var array> */ + private static array $declaredProperties = []; + private bool $dataRefreshed = false; private readonly ?Nette\Database\EntityMapping $entityMapping; @@ -29,6 +32,29 @@ public function __construct( private Selection $table, ) { $this->entityMapping = $table->getExplorer()->getEntityMapping(); + foreach (self::declaredProperties(static::class) as $name) { + unset($this->$name); + } + } + + + /** + * Returns names of declared public non-static properties of the row class, so they can be unset to fall through to __get. + * @param class-string $class + * @return list + */ + private static function declaredProperties(string $class): array + { + if (isset(self::$declaredProperties[$class])) { + return self::$declaredProperties[$class]; + } + $result = []; + foreach ((new \ReflectionClass($class))->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + if (!$prop->isStatic()) { + $result[] = $prop->getName(); + } + } + return self::$declaredProperties[$class] = $result; } diff --git a/tests/Database.DI/ActiveRow.typedProperties.phpt b/tests/Database.DI/ActiveRow.typedProperties.phpt new file mode 100644 index 000000000..4ae2c11d2 --- /dev/null +++ b/tests/Database.DI/ActiveRow.typedProperties.phpt @@ -0,0 +1,99 @@ +query('CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + nickname TEXT NULL + )'); + $connection->query("INSERT INTO users (email, nickname) VALUES ('a@x.com', 'al')"); + $connection->query("INSERT INTO users (email, nickname) VALUES ('b@x.com', NULL)"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $mapping = new DefaultEntityMapping(['users' => UserRow::class]); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('typed properties are readable via __get', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::type(UserRow::class, $row); + Assert::same(1, $row->id); + Assert::same('a@x.com', $row->email); + Assert::same('al', $row->nickname); +}); + + +test('nullable typed property returns null when NULL in database', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(2); + + Assert::null($row->nickname); +}); + + +test('isset works on typed properties', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $row2 = $explorer->table('users')->get(2); + + Assert::true(isset($row->id)); + Assert::true(isset($row->email)); + Assert::true(isset($row->nickname)); + Assert::false(isset($row2->nickname)); +}); + + +test('iterator yields all columns', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $arr = iterator_to_array($row); + + Assert::same(['id' => 1, 'email' => 'a@x.com', 'nickname' => 'al'], $arr); +}); + + +test('plain ActiveRow without typed props still works', function () { + $connection = new Connection('sqlite::memory:'); + $connection->query('CREATE TABLE thing (id INTEGER PRIMARY KEY, label TEXT NOT NULL)'); + $connection->query("INSERT INTO thing (label) VALUES ('x')"); + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $explorer = new Explorer($connection, $structure, $conventions, $storage); + + $row = $explorer->table('thing')->get(1); + Assert::type(ActiveRow::class, $row); + Assert::same('x', $row->label); +}); diff --git a/tests/Database/Explorer/RowBehavior.entity.phpt b/tests/Database/Explorer/RowBehavior.entity.phpt index a68346e77..e35c53f7d 100644 --- a/tests/Database/Explorer/RowBehavior.entity.phpt +++ b/tests/Database/Explorer/RowBehavior.entity.phpt @@ -30,23 +30,11 @@ class Book extends Table\ActiveRow } -// attached row in its target form: composes RowBehavior, plus a userland unset -// bridging declared value properties to magic column access +// attached row in its target form: composing RowBehavior is enough, its constructor +// unsets the declared value properties so they fall through to magic column access final class BookRow extends Book implements Table\Row { - use Table\RowBehavior { - __construct as private constructRow; - } - - public function __construct(array $data, Table\Selection $table, bool $deferredFetch = false) - { - $this->constructRow($data, $table, $deferredFetch); - foreach ((new ReflectionClass($this))->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if (!$property->isStatic()) { - unset($this->{$property->getName()}); - } - } - } + use Table\RowBehavior; } From 32e0c80f358c15b33ef39d0e667bf206f9de7ed0 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 18 May 2026 01:53:36 +0200 Subject: [PATCH 67/75] ActiveRow: auto-converts BackedEnum columns based on declared property type When a subclass declares a BackedEnum-typed public property, the column value is converted via Enum::from() on read (__get, toArray, iterator). Writes already work thanks to SqlPreprocessor handling BackedEnum. --- docs/internals/explorer.md | 11 +- src/Database/Table/RowBehavior.php | 52 +++++++-- .../Database.DI/ActiveRow.enumProperties.phpt | 110 ++++++++++++++++++ 3 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 tests/Database.DI/ActiveRow.enumProperties.phpt diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index 8fc557b92..63cba1077 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -50,14 +50,13 @@ not to dirty the original. separate property. A subclass may declare typed properties (`public int $id`) for IDE/PHPStan, but the constructor **`unset()`s them**, so reads fall into `__get` and flow through `$data`. That is the only way to track which columns are actually read -(SELECT narrowing). (There is no read-side enum conversion — `BackedEnum` handling -exists only on the write side, in the preprocessor.) +(SELECT narrowing) — and the reflectable real-property type is also what enables +**enum conversion** (a `@property` annotation would not). - `__get($key)`: maps property→column via `EntityMapping` → `accessColumn` → returns - `$data[$column]`; if the column is absent it tries a **relation** - (`getReferencedTable`), else throws `MemberAccessException` (with a did-you-mean - hint). `__set` is read-only (throws). `toArray()` forces all columns via - `accessColumn(null)`. + `$data[$column]` (with `BackedEnum` conversion by the declared type); if the column + is absent it tries a **relation**, else throws `MemberAccessException`. `__set` is + read-only (throws). `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. diff --git a/src/Database/Table/RowBehavior.php b/src/Database/Table/RowBehavior.php index 0aa5e9ed1..f84f377fc 100644 --- a/src/Database/Table/RowBehavior.php +++ b/src/Database/Table/RowBehavior.php @@ -8,7 +8,7 @@ namespace Nette\Database\Table; use Nette; -use function array_intersect_key, array_key_exists, array_keys, array_map, implode, is_array, is_string, iterator_to_array; +use function array_intersect_key, array_key_exists, array_keys, array_map, implode, is_array, is_string, is_subclass_of, iterator_to_array; /** @@ -21,6 +21,9 @@ trait RowBehavior /** @var array> */ private static array $declaredProperties = []; + /** @var array>> */ + private static array $enumProperties = []; + private bool $dataRefreshed = false; private readonly ?Nette\Database\EntityMapping $entityMapping; @@ -58,6 +61,31 @@ private static function declaredProperties(string $class): array } + /** + * Returns map of property name to BackedEnum class for typed properties declared on the row class. + * @param class-string $class + * @return array> + */ + private static function enumProperties(string $class): array + { + if (isset(self::$enumProperties[$class])) { + return self::$enumProperties[$class]; + } + $result = []; + foreach ((new \ReflectionClass($class))->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + $type = $prop->getType(); + if (!$prop->isStatic() + && $type instanceof \ReflectionNamedType + && !$type->isBuiltin() + && is_subclass_of($type->getName(), \BackedEnum::class) + ) { + $result[$prop->getName()] = $type->getName(); + } + } + return self::$enumProperties[$class] = $result; + } + + /** * @internal * @param Selection $table @@ -95,14 +123,19 @@ public function toArray(): array { $this->accessColumn(null); $entityMapping = $this->entityMapping; - if ($entityMapping) { - $translated = []; - foreach ($this->data as $key => $value) { - $translated[$entityMapping->getPropertyName($key)] = $value; + $enums = self::enumProperties(static::class); + if (!$entityMapping && !$enums) { + return $this->data; + } + $result = []; + foreach ($this->data as $key => $value) { + $propName = $entityMapping ? $entityMapping->getPropertyName($key) : $key; + if ($value !== null && isset($enums[$propName])) { + $value = $enums[$propName]::from($value); } - return $translated; + $result[$propName] = $value; } - return $this->data; + return $result; } @@ -297,6 +330,11 @@ public function &__get(string $key): mixed $column = $this->entityMapping?->getColumnName($key) ?? $key; if ($this->accessColumn($column)) { + $enums = self::enumProperties(static::class); + if ($this->data[$column] !== null && isset($enums[$key])) { + $value = $enums[$key]::from($this->data[$column]); + return $value; + } return $this->data[$column]; } diff --git a/tests/Database.DI/ActiveRow.enumProperties.phpt b/tests/Database.DI/ActiveRow.enumProperties.phpt new file mode 100644 index 000000000..4266bf44e --- /dev/null +++ b/tests/Database.DI/ActiveRow.enumProperties.phpt @@ -0,0 +1,110 @@ +query('CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + status TEXT NOT NULL, + role INTEGER NULL + )'); + $connection->query("INSERT INTO users (email, status, role) VALUES ('a@x.com', 'active', 1)"); + $connection->query("INSERT INTO users (email, status, role) VALUES ('b@x.com', 'suspended', NULL)"); + + $storage = new MemoryStorage; + $structure = new Structure($connection, $storage); + $conventions = new DiscoveredConventions($structure); + $mapping = new DefaultEntityMapping(['users' => UserRow::class]); + return new Explorer($connection, $structure, $conventions, $storage, $mapping); +} + + +test('BackedEnum column is converted on __get', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::same(UserStatus::Active, $row->status); + Assert::same(Role::Admin, $row->role); +}); + + +test('nullable enum property handles NULL', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(2); + + Assert::same(UserStatus::Suspended, $row->status); + Assert::null($row->role); +}); + + +test('non-enum typed property returns scalar unchanged', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + Assert::same(1, $row->id); + Assert::same('a@x.com', $row->email); +}); + + +test('toArray and iterator return converted enum values', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + + $arr = $row->toArray(); + Assert::same(UserStatus::Active, $arr['status']); + Assert::same(Role::Admin, $arr['role']); + + $iter = iterator_to_array($row); + Assert::same(UserStatus::Active, $iter['status']); + Assert::same(Role::Admin, $iter['role']); +}); + + +test('update accepts enum values, refetched row exposes converted enum', function () { + $explorer = createExplorer(); + $row = $explorer->table('users')->get(1); + $row->update(['status' => UserStatus::Suspended]); + + Assert::same(UserStatus::Suspended, $row->status); + + $fresh = $explorer->table('users')->get(1); + Assert::same(UserStatus::Suspended, $fresh->status); +}); From 69617db9bbf231de9cde122fe655966afa03afbe Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 7 Jun 2026 17:12:27 +0200 Subject: [PATCH 68/75] Selection: insertMany() took over the bulk logic, bulk insert() is deprecated The delegation is reversed: insertMany() now performs the bulk insert itself (a Selection source or a list of rows), while insert() routes its bulk cases to it, triggers a deprecation notice and keeps only the single-row path - which also reduces the entity mapping there to a single translateColumns() call. Both methods share Helpers::materializeRows() and isRowList(). Rows coming from a Traversable are drained by position, so none is lost when a generator yields them under colliding keys (`yield from`), and an array with gaps left by array_filter() is accepted as well. An empty array stays a single row of database defaults, it is not a bulk insert. GroupedSelection assigns the referencing group to a copy of each row, never to the caller's Row object. --- docs/internals/explorer.md | 29 +++--- phpstan.neon | 2 +- src/Database/Table/GroupedSelection.php | 38 +++++--- src/Database/Table/Selection.php | 63 +++++++------ .../EntityMapping.integration.phpt | 4 +- .../Explorer/GroupedSelection.insert().phpt | 8 ++ .../Selection.insert().deprecated.phpt | 69 ++++++++++++++ .../Explorer/Selection.insert().lazy.phpt | 92 +++++++++++++++++++ .../Explorer/Selection.insert().multi.phpt | 81 ---------------- .../Database/Explorer/Selection.insert().phpt | 2 +- tests/types/database-types.php | 12 +++ 11 files changed, 260 insertions(+), 140 deletions(-) create mode 100644 tests/Database/Explorer/Selection.insert().deprecated.phpt create mode 100644 tests/Database/Explorer/Selection.insert().lazy.phpt delete mode 100644 tests/Database/Explorer/Selection.insert().multi.phpt diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index 63cba1077..69d793043 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -145,22 +145,16 @@ by-ref array), so a mutation from one clone is seen by all clones of the same re ## 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** -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 +`Selection::insert(iterable)`: 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. +part missing → the original `$data` array. A **list or a Selection** is routed to +`insertMany()`, and doing that through `insert()` is **deprecated**. -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. - -`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: +`insertMany(iterable)` owns the bulk logic and always returns an int. 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 @@ -173,8 +167,13 @@ back to `insert()`. Three details are not obvious from the signature: - 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. +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. + +`GroupedSelection` overrides both: `insert()` assigns the grouping column to a single +row, `insertMany()` to every row of the list — on a **clone** of each `Row`, never on +the caller's object. ## update() / delete() diff --git a/phpstan.neon b/phpstan.neon index 63156a3fa..81d751d3d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -168,7 +168,7 @@ parameters: - identifier: argument.type message: '#expects literal-string, [\w-]+ given#' - count: 5 + count: 6 path: src/Database/Table/Selection.php - identifier: argument.type diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index 82d75f85a..616316ebb 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -10,6 +10,7 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; +use function count, preg_match, reset; /** @@ -258,26 +259,39 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data - * @return ($data is list|Selection ? int : T|array|int) + * @return ($data is non-empty-list|Selection ? int : T|array|int) */ public function insert(iterable $data): ActiveRow|array|int { - if ($data instanceof Selection) { - return parent::insert($data); + if (!$data instanceof Selection) { + $data = Nette\Database\Helpers::materializeRows($data); + if (!Nette\Database\Helpers::isRowList($data)) { + $data[$this->column] = $this->active; // single row (an empty one too): assign to the referencing group + } } - $data = Nette\Database\Helpers::materializeRows($data); - if (Nette\Database\Helpers::isRowList($data)) { - 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; + // bulk (list / Selection) is routed to insertMany() by parent, which assigns the group per row + return parent::insert($data); + } + + + /** + * @param iterable|Nette\Database\Row>|Selection $data + */ + public function insertMany(iterable $data): int + { + if (!$data instanceof Selection) { + $data = Nette\Database\Helpers::materializeRows($data); + if (Nette\Database\Helpers::isRowList($data)) { // anything else is left to parent to reject + 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) } - return parent::insert($data); + return parent::insertMany($data); } diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index f9a2a123c..4fa2602fc 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -824,41 +824,37 @@ public function getDataRefreshed(): bool /** * Inserts one or more rows into the table. * 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 list|Selection ? int : T|array|int) + * Passing a list of rows or a Selection is deprecated, use insertMany() instead. + * @param iterable|list>|Selection $data + * @return ($data is non-empty-list|Selection ? int : 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.'); + if ($data instanceof self) { + trigger_error(__METHOD__ . '() with a Selection is deprecated, use insertMany() instead.', E_USER_DEPRECATED); + return $this->insertMany($data); } - //should be called before query for not to spoil PDO::lastInsertId - $primarySequenceName = $this->getPrimarySequence(); - $primaryAutoincrementKey = $this->explorer->getStructure()->getPrimaryAutoincrementKey($this->name); - // 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 + if (Nette\Database\Helpers::isRowList($data)) { + trigger_error(__METHOD__ . '() with a list of rows is deprecated, use insertMany() instead.', E_USER_DEPRECATED); + return $this->insertMany($data); } + //should be called before query for not to spoil PDO::lastInsertId + $primarySequenceName = $this->getPrimarySequence(); + $primaryAutoincrementKey = $this->explorer->getStructure()->getPrimaryAutoincrementKey($this->name); + if ($mapping = $this->explorer->getEntityMapping()) { - $data = $bulk - ? array_map(fn(array $row) => Nette\Database\Helpers::translateColumns($row, $mapping), $data) - : Nette\Database\Helpers::translateColumns($data, $mapping); + $data = Nette\Database\Helpers::translateColumns($data, $mapping); } $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); + $this->loadRefCache(); - if ($bulk || $this->primary === null) { + if ($this->primary === null) { $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); @@ -927,17 +923,28 @@ public function insert(iterable $data): ActiveRow|array|int public function insertMany(iterable $data): int { if ($data instanceof self) { - return $this->insert($data); - } + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ' . $data->getSql(), ...$data->getSqlBuilder()->getParameters()); - $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.'); + } else { + $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.'); + } + + $data = array_values($data); // the keys may have gaps, e.g. left by array_filter() + if ($mapping = $this->explorer->getEntityMapping()) { + $data = array_map(fn($row) => Nette\Database\Helpers::translateColumns(iterator_to_array($row), $mapping), $data); + } + + $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); } - return $this->insert(array_values($data)); // the keys may have gaps, e.g. left by array_filter() + $this->loadRefCache(); + $this->clearReferencingCache(); + return $return->getRowCount() + ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } diff --git a/tests/Database.DI/EntityMapping.integration.phpt b/tests/Database.DI/EntityMapping.integration.phpt index 53c3d0c74..8a9d3f288 100644 --- a/tests/Database.DI/EntityMapping.integration.phpt +++ b/tests/Database.DI/EntityMapping.integration.phpt @@ -172,9 +172,9 @@ test('Selection::insert translates property keys', function () { }); -test('Selection::insert multi-row translates property keys', function () { +test('Selection::insertMany translates property keys', function () { $explorer = createExplorer(new UpperCaseMapping); - $explorer->table('user_account')->insert([ + $explorer->table('user_account')->insertMany([ ['FIRST_NAME' => 'Bob', 'LAST_NAME' => 'Builder', 'EMAIL_ADDRESS' => 'bob@example.com'], ['FIRST_NAME' => 'Cara', 'LAST_NAME' => 'Coder', 'EMAIL_ADDRESS' => 'cara@example.com'], ]); diff --git a/tests/Database/Explorer/GroupedSelection.insert().phpt b/tests/Database/Explorer/GroupedSelection.insert().phpt index 067cf8b79..4363153cc 100644 --- a/tests/Database/Explorer/GroupedSelection.insert().phpt +++ b/tests/Database/Explorer/GroupedSelection.insert().phpt @@ -36,3 +36,11 @@ test('insert works after iteration conversion', function () use ($explorer) { $book->related('book_tag')->insert(['tag_id' => 23]); Assert::same(3, $book->related('book_tag')->count()); }); + + +test('insert with an empty array still assigns the referencing group', function () use ($explorer) { + $explorer->table('note')->where('book_id', 1)->delete(); + + $explorer->table('book')->get(1)->related('note.book_id')->insert([]); // all columns left to defaults + Assert::same(1, $explorer->table('note')->where('book_id', 1)->count()); // the group column is filled anyway +}); diff --git a/tests/Database/Explorer/Selection.insert().deprecated.phpt b/tests/Database/Explorer/Selection.insert().deprecated.phpt new file mode 100644 index 000000000..ec62e5017 --- /dev/null +++ b/tests/Database/Explorer/Selection.insert().deprecated.phpt @@ -0,0 +1,69 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('insert() with a list of rows is deprecated but still inserts', function () use ($explorer) { + $result = null; + Assert::error( + function () use ($explorer, &$result) { + $result = $explorer->table('author')->insert([ + ['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')], + ]); + }, + E_USER_DEPRECATED, + 'Nette\Database\Table\Selection::insert() with a list of rows is deprecated, use insertMany() instead.', + ); + Assert::same(2, $result); + Assert::same(5, $explorer->table('author')->count()); +}); + + +test('insert() with a list on a GroupedSelection is deprecated but still inserts', function () use ($explorer) { + $explorer->table('book_tag')->where('book_id', 1)->delete(); + + $result = null; + Assert::error( + function () use ($explorer, &$result) { + $result = $explorer->table('book')->get(1)->related('book_tag')->insert([ + ['tag_id' => 21], + ['tag_id' => 22], + ['tag_id' => 23], + ]); + }, + E_USER_DEPRECATED, + 'Nette\Database\Table\Selection::insert() with a list of rows is deprecated, use insertMany() instead.', + ); + Assert::same(3, $result); + Assert::same(7, $explorer->table('book_tag')->count()); +}); + + +test('deprecated insert() with a generator does not lose rows to colliding keys', function () use ($explorer) { + $rows = (function () { + yield from [['name' => 'Hodor', 'web' => 'http://example.com']]; // both batches yield the key 0 + yield from [['name' => 'Osha', 'web' => 'http://example.com']]; + })(); + + $result = null; + Assert::error( + function () use ($explorer, $rows, &$result) { + $result = $explorer->table('author')->insert($rows); + }, + E_USER_DEPRECATED, + ); + Assert::same(2, $result); +}); diff --git a/tests/Database/Explorer/Selection.insert().lazy.phpt b/tests/Database/Explorer/Selection.insert().lazy.phpt new file mode 100644 index 000000000..44869b048 --- /dev/null +++ b/tests/Database/Explorer/Selection.insert().lazy.phpt @@ -0,0 +1,92 @@ +getConnection(); + +Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); + + +test('reading only the primary key triggers no SELECT', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Eddard Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::type('int', $row->id); + Assert::same(0, $count); +}); + + +test('reading a non-primary column triggers exactly one SELECT', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Catelyn Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same('Catelyn Stark', $row->name); + Assert::same(1, $count); + + Assert::same('http://example.com', $row->web); // already loaded + Assert::same(1, $count); +}); + + +test('toArray() materializes the whole row', function () use ($explorer, $connection) { + $row = $explorer->table('author')->insert([ + 'name' => 'Robb Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + $arr = $row->toArray(); + Assert::same(1, $count); + Assert::same('Robb Stark', $arr['name']); +}); + + +test('column computed by the database is read correctly', function () use ($explorer) { + $row = $explorer->table('author')->insert([ + 'name' => $explorer->literal('LOWER(?)', 'Eddard Stark'), + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + Assert::same('eddard stark', $row->name); +}); + + +test('relationship is accessible right after insert', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Jon Snow', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $book = $explorer->table('book')->insert([ + 'title' => 'Winterfell', + 'author_id' => $author->id, + ]); + + Assert::same('Jon Snow', $book->author->name); +}); diff --git a/tests/Database/Explorer/Selection.insert().multi.phpt b/tests/Database/Explorer/Selection.insert().multi.phpt deleted file mode 100644 index acf47c48d..000000000 --- a/tests/Database/Explorer/Selection.insert().multi.phpt +++ /dev/null @@ -1,81 +0,0 @@ -getConnection(); - -Nette\Database\Helpers::loadFromFile($connection, __DIR__ . "/../files/{$driverName}-nette_test1.sql"); - - -test('', function () use ($explorer) { - Assert::same(3, $explorer->table('author')->count()); - $result = $explorer->table('author')->insert([ - [ - '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'), - ], - ]); // 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()); - $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().phpt b/tests/Database/Explorer/Selection.insert().phpt index 910be3d2c..dae0ccb6f 100644 --- a/tests/Database/Explorer/Selection.insert().phpt +++ b/tests/Database/Explorer/Selection.insert().phpt @@ -63,7 +63,7 @@ if ($driverName !== 'sqlsrv') { default => Assert::fail("Unsupported driver $driverName"), }; - $explorer->table('book')->insert($selection); + $explorer->table('book')->insertMany($selection); Assert::same(4, $explorer->table('book')->where('title LIKE', 'Biography%')->count('*')); } diff --git a/tests/types/database-types.php b/tests/types/database-types.php index c19fb12b1..ba221d7c9 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -149,3 +149,15 @@ function testSelectionInsertFromSelection(Selection $selection, Selection $sourc $result = $selection->insert($source); assertType('int', $result); } + + +/** + * @param Selection $selection + * @param Selection $source + */ +function testSelectionInsertMany(Selection $selection, Selection $source): void +{ + // insertMany() always returns the number of affected rows + assertType('int', $selection->insertMany([['name' => 'Alice'], ['name' => 'Bob']])); + assertType('int', $selection->insertMany($source)); +} From a72bf2fd05f698f0e31637045af19d789ec1dfdb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 8 Jun 2026 12:53:35 +0200 Subject: [PATCH 69/75] Selection::insert() returns a lazy row --- src/Database/Explorer.php | 4 +- src/Database/Table/RowBehavior.php | 25 +++++++ src/Database/Table/Selection.php | 51 +++++++------ .../Explorer/Selection.insert().lazy.phpt | 72 +++++++++++++++++++ .../Selection.insert().primaryKeys.phpt | 49 +++++++++++++ tests/Database/files/mysql-nette_test4.sql | 6 ++ tests/Database/files/pgsql-nette_test4.sql | 6 ++ tests/Database/files/sqlite-nette_test4.sql | 7 ++ tests/Database/files/sqlsrv-nette_test4.sql | 7 ++ 9 files changed, 203 insertions(+), 24 deletions(-) diff --git a/src/Database/Explorer.php b/src/Database/Explorer.php index 3593baaa8..bc79a812a 100644 --- a/src/Database/Explorer.php +++ b/src/Database/Explorer.php @@ -139,11 +139,11 @@ public function getEntityMapping(): ?EntityMapping * @param array $data * @param Table\Selection $selection */ - public function createActiveRow(array $data, Table\Selection $selection): Table\ActiveRow + public function createActiveRow(array $data, Table\Selection $selection, bool $deferredFetch = false): Table\ActiveRow { $class = $this->entityMapping?->getClassName($selection->getName()); $class = $class && class_exists($class) ? $class : Table\ActiveRow::class; - return new $class($data, $selection); + return new $class($data, $selection, $deferredFetch); } diff --git a/src/Database/Table/RowBehavior.php b/src/Database/Table/RowBehavior.php index f84f377fc..8736ed12c 100644 --- a/src/Database/Table/RowBehavior.php +++ b/src/Database/Table/RowBehavior.php @@ -33,6 +33,8 @@ public function __construct( private array $data, /** @var Selection */ private Selection $table, + // when true, the row holds only its primary key and fetches the rest on first access + private bool $deferredFetch = false, ) { $this->entityMapping = $table->getExplorer()->getEntityMapping(); foreach (self::declaredProperties(static::class) as $name) { @@ -390,6 +392,10 @@ public function __unset(string $key): void /** @internal */ public function accessColumn(?string $key, bool $selectColumn = true): bool { + if ($this->deferredFetch && ($key === null || !array_key_exists($key, $this->data))) { + $this->completeData(); + } + if ($this->table->accessColumn($key, $selectColumn) && !$this->dataRefreshed) { if (!isset($this->table[$this->getSignature()])) { throw new Nette\InvalidStateException("Database refetch failed; row with signature '{$this->getSignature()}' does not exist!"); @@ -408,4 +414,23 @@ protected function removeAccessColumn(string $key): void { $this->table->removeAccessColumn($key); } + + + /** + * Loads the full row by primary key. Used by rows returned from insert(), which initially + * hold only the primary key, to fetch the remaining columns on first access. + */ + private function completeData(): void + { + $this->deferredFetch = false; + $full = $this->table->fetch() + ?? throw new Nette\ShouldNotHappenException("Database refetch failed; inserted row with signature '{$this->getSignature()}' no longer exists!"); + $this->data = $full->data; + + // become the canonical row of the selection, so later table-level operations (e.g. referenced-table + // resolution after update()) see this instance and its data instead of the just-fetched duplicate + if (($signature = $this->getSignature(false)) !== '') { + $this->table[$signature] = $this; + } + } } diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 4fa2602fc..0550b9b10 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -10,7 +10,7 @@ use Nette; use Nette\Database\Conventions; use Nette\Database\Explorer; -use function array_filter, array_intersect_key, array_keys, array_map, array_merge, array_values, ceil, count, current, explode, func_num_args, hash, implode, is_array, is_int, iterator_to_array, key, next, reset, serialize, str_contains, substr_count; +use function array_filter, array_intersect_key, array_keys, array_map, array_merge, array_values, ceil, count, current, explode, func_num_args, hash, implode, is_array, is_int, is_string, iterator_to_array, key, next, reset, serialize, str_contains, substr_count; /** @@ -867,41 +867,47 @@ public function insert(iterable $data): ActiveRow|array|int } } - // First check sequence - if (!empty($primarySequenceName) && $primaryAutoincrementKey) { - $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($this->explorer->getConnection()->getDriver()->delimite($primarySequenceName)); + if ($primaryAutoincrementKey) { + $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($primarySequenceName + ? $this->explorer->getConnection()->getDriver()->delimite($primarySequenceName) + : $primarySequenceName); - // Autoincrement primary without sequence - } elseif ($primaryAutoincrementKey) { - $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($primarySequenceName); - - // Multi column primary without autoincrement - } elseif (is_array($this->primary)) { + } elseif (is_array($this->primary)) { // Multi column primary without autoincrement foreach ($this->primary as $key) { if (!isset($data[$key])) { $this->clearReferencingCache(); return $data; } } - - // Primary without autoincrement, try get primary from inserting data - } elseif ($this->primary && isset($data[$this->primary])) { - $primaryKey = $data[$this->primary]; - - // If primaryKey cannot be prepared, return inserted rows count - } else { + } elseif (!isset($data[$this->primary])) { // Single-column primary without autoincrement not present in the inserted data $this->clearReferencingCache(); return $return->getRowCount() ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); } + // otherwise $primaryKey already holds the whole primary key as a column => value map - /** @phpstan-var T $row */ - $row = $this->createSelectionInstance() + $selection = $this->createSelectionInstance($this->name) ->select('*') - ->wherePrimary($primaryKey) - ->fetch() - ?? throw new Nette\ShouldNotHappenException; + ->wherePrimary($primaryKey); + + if (count($primaryKey) === count((array) $this->primary)) { // is primary key complete? + // normalize numeric strings to int for integer columns, to match what a fetch would return + $columns = null; + foreach ($primaryKey as $key => $value) { + if (is_string($value) && (string) (int) $value === $value) { + $columns ??= array_column($this->explorer->getStructure()->getColumns($this->name), 'nativetype', 'name'); + if (Nette\Database\Helpers::detectType($columns[$key] ?? '') === Nette\Database\IStructure::FIELD_INTEGER) { + $primaryKey[$key] = (int) $value; + } + } + } + $row = $this->explorer->createActiveRow($primaryKey, $selection, deferredFetch: true); + } else { + $row = $selection->fetch() ?? throw new Nette\ShouldNotHappenException; + } + + /** @phpstan-var T $row */ if ($this->rows !== null) { if ($signature = $row->getSignature(false)) { $this->rows[$signature] = $row; @@ -1062,6 +1068,7 @@ public function getReferencingTable( /** @var ?GroupedSelection $prototype */ $prototype = &$this->refCache['referencingPrototype'][$this->getSpecificCacheKey()]["$table.$column"]; if (!$prototype) { + $this->execute(); // the selection may not be executed yet, e.g. when the row comes lazily from insert() $prototype = $this->createGroupedSelectionInstance($table, $column); $prototype->where("$table.$column", array_keys((array) $this->rows)); $prototype->getSpecificCacheKey(); diff --git a/tests/Database/Explorer/Selection.insert().lazy.phpt b/tests/Database/Explorer/Selection.insert().lazy.phpt index 44869b048..5ce5802b4 100644 --- a/tests/Database/Explorer/Selection.insert().lazy.phpt +++ b/tests/Database/Explorer/Selection.insert().lazy.phpt @@ -90,3 +90,75 @@ test('relationship is accessible right after insert', function () use ($explorer Assert::same('Jon Snow', $book->author->name); }); + + +test('related() right after insert sees the referencing rows', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Arya Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $explorer->table('book')->insert([ + 'title' => 'Needle', + 'author_id' => $author->id, + ]); + + $books = $author->related('book.author_id'); + Assert::same(1, $books->count('*')); + Assert::same('Needle', $books->fetch()->title); +}); + + +test('row inserted via related() gets the group column and is fully usable', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Sansa Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + $book = $author->related('book.author_id')->insert(['title' => 'Alayne']); + + Assert::type(Nette\Database\Table\ActiveRow::class, $book); + Assert::same($author->id, $book->author_id); + Assert::same('Alayne', $book->title); + Assert::same('Sansa Stark', $book->author->name); // ref() on a lazy row from GroupedSelection + + $tag = $explorer->table('tag')->insert(['name' => 'saga']); + $explorer->table('book_tag')->insert(['book_id' => $book->id, 'tag_id' => $tag->id]); + Assert::same(1, $book->related('book_tag')->count('*')); // related() on a lazy row from GroupedSelection + + $book->update(['title' => 'Alayne Stone']); + Assert::same('Alayne Stone', $book->title); +}); + + +test('lazy row completes its data even after related() executed the selection', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Rickon Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + + Assert::same(0, $author->related('book.author_id')->count('*')); // executes the backing selection + Assert::same('Rickon Stark', $author->name); // deferred fetch must still find the row +}); + + +test('lazy row stays consistent across related(), completion and update()', function () use ($explorer) { + $author = $explorer->table('author')->insert([ + 'name' => 'Benjen Stark', + 'web' => 'http://example.com', + 'born' => new DateTime('2011-11-11'), + ]); + $explorer->table('book')->insert([ + 'title' => 'The Wall', + 'author_id' => $author->id, + ]); + + Assert::same(1, $author->related('book.author_id')->count('*')); + Assert::same('Benjen Stark', $author->name); // completes data, row becomes canonical in the selection + $author->update(['name' => 'First Ranger']); + Assert::same('First Ranger', $author->name); + Assert::same(1, $author->related('book.author_id')->count('*')); // cached prototype keys still match +}); diff --git a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt index ef2a2519a..307e56c07 100644 --- a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt +++ b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt @@ -123,3 +123,52 @@ test('insert into table without primary key', function () use ($explorer) { ]); Assert::same(1, $noPkResult1); }); + +test('composite primary key insert returns a lazy row', function () use ($explorer, $connection) { + $row = $explorer->table('multi_pk_no_autoincrement')->insert([ + 'identifier1' => 7, + 'identifier2' => 14, + 'note' => 'lazy composite', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same(7, $row->identifier1); + Assert::same(14, $row->identifier2); + Assert::same(0, $count); // both primary key columns are available without a query + + Assert::same('lazy composite', $row->note); + Assert::same(1, $count); // the first non-primary access fetches the rest +}); + +test('numeric-string primary key values are normalized to int for integer columns', function () use ($explorer, $connection) { + $row = $explorer->table('multi_pk_no_autoincrement')->insert([ + 'identifier1' => '8', + 'identifier2' => '16', + 'note' => 'string ids', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same(8, $row->identifier1); + Assert::same(16, $row->identifier2); + Assert::same(0, $count); // normalized without fetching the row +}); + +test('numeric-string value in a string primary key column stays a string', function () use ($explorer, $connection) { + $row = $explorer->table('string_pk')->insert([ + 'identifier1' => '9', + 'note' => 'string pk', + ]); + + $count = 0; + $connection->onQuery[] = function () use (&$count) { $count++; }; + + Assert::same('9', $row->identifier1); + Assert::same(0, $count); + + Assert::same('string pk', $row->note); + Assert::same('9', $row->identifier1); // matches what a fetch returns +}); diff --git a/tests/Database/files/mysql-nette_test4.sql b/tests/Database/files/mysql-nette_test4.sql index 754ab2f1c..c12da300e 100644 --- a/tests/Database/files/mysql-nette_test4.sql +++ b/tests/Database/files/mysql-nette_test4.sql @@ -31,3 +31,9 @@ CREATE TABLE multi_pk_autoincrement( CREATE TABLE no_pk ( note varchar(100) ) ENGINE=InnoDB; + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +) ENGINE=InnoDB; diff --git a/tests/Database/files/pgsql-nette_test4.sql b/tests/Database/files/pgsql-nette_test4.sql index b45fa6850..d215ca4a1 100644 --- a/tests/Database/files/pgsql-nette_test4.sql +++ b/tests/Database/files/pgsql-nette_test4.sql @@ -30,3 +30,9 @@ CREATE TABLE multi_pk_autoincrement( CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); diff --git a/tests/Database/files/sqlite-nette_test4.sql b/tests/Database/files/sqlite-nette_test4.sql index e4ffe7889..232a55204 100644 --- a/tests/Database/files/sqlite-nette_test4.sql +++ b/tests/Database/files/sqlite-nette_test4.sql @@ -3,6 +3,7 @@ DROP TABLE IF EXISTS simple_pk_no_autoincrement; DROP TABLE IF EXISTS multi_pk_no_autoincrement; DROP TABLE IF EXISTS multi_pk_autoincrement; DROP TABLE IF EXISTS no_pk; +DROP TABLE IF EXISTS string_pk; CREATE TABLE simple_pk_autoincrement ( identifier1 integer PRIMARY KEY AUTOINCREMENT, @@ -25,3 +26,9 @@ CREATE TABLE multi_pk_no_autoincrement ( CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); diff --git a/tests/Database/files/sqlsrv-nette_test4.sql b/tests/Database/files/sqlsrv-nette_test4.sql index b9b5a86c7..2c38104e7 100644 --- a/tests/Database/files/sqlsrv-nette_test4.sql +++ b/tests/Database/files/sqlsrv-nette_test4.sql @@ -3,6 +3,7 @@ IF OBJECT_ID('simple_pk_no_autoincrement', 'U') IS NOT NULL DROP TABLE simple_pk IF OBJECT_ID('multi_pk_no_autoincrement', 'U') IS NOT NULL DROP TABLE multi_pk_no_autoincrement; IF OBJECT_ID('multi_pk_autoincrement', 'U') IS NOT NULL DROP TABLE multi_pk_autoincrement; IF OBJECT_ID('no_pk', 'U') IS NOT NULL DROP TABLE no_pk; +IF OBJECT_ID('string_pk', 'U') IS NOT NULL DROP TABLE string_pk; CREATE TABLE simple_pk_autoincrement ( @@ -34,3 +35,9 @@ ALTER TABLE multi_pk_autoincrement ADD CONSTRAINT PK_multi_pk_autoincrement PRIM CREATE TABLE no_pk ( note varchar(100) ); + +CREATE TABLE string_pk ( + identifier1 varchar(20) NOT NULL, + note varchar(100), + PRIMARY KEY (identifier1) +); From 878406c211b0720bfbbb0d24b70e11087fc690f4 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 8 Jun 2026 13:14:30 +0200 Subject: [PATCH 70/75] Selection::insert() returns null instead of the input array for an unidentifiable row When a single insert cannot determine the primary key of the inserted row (composite primary key with a missing column), insert() used to return the input data array, which is meaningless to the caller. It now returns null. The return type narrows from ActiveRow|array|int to ActiveRow|int|null. --- src/Database/Table/GroupedSelection.php | 4 ++-- src/Database/Table/Selection.php | 9 +++++---- tests/types/database-types.php | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index 616316ebb..5c4ede48d 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -259,9 +259,9 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data - * @return ($data is non-empty-list|Selection ? int : T|array|int) + * @return ($data is non-empty-list|Selection ? int : T|int|null) */ - public function insert(iterable $data): ActiveRow|array|int + public function insert(iterable $data): ActiveRow|int|null { if (!$data instanceof Selection) { $data = Nette\Database\Helpers::materializeRows($data); diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 0550b9b10..7f5a506c3 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -823,12 +823,13 @@ public function getDataRefreshed(): bool /** * Inserts one or more rows into the table. - * A single associative array inserts one row and returns the inserted ActiveRow; + * A single associative array inserts one row and returns the inserted ActiveRow, + * or null/int when the inserted row cannot be identified by its primary key; * Passing a list of rows or a Selection is deprecated, use insertMany() instead. * @param iterable|list>|Selection $data - * @return ($data is non-empty-list|Selection ? int : T|array|int) + * @return ($data is non-empty-list|Selection ? int : T|int|null) */ - public function insert(iterable $data): ActiveRow|array|int + public function insert(iterable $data): ActiveRow|int|null { if ($data instanceof self) { trigger_error(__METHOD__ . '() with a Selection is deprecated, use insertMany() instead.', E_USER_DEPRECATED); @@ -876,7 +877,7 @@ public function insert(iterable $data): ActiveRow|array|int foreach ($this->primary as $key) { if (!isset($data[$key])) { $this->clearReferencingCache(); - return $data; + return null; // the inserted row cannot be identified by its primary key } } } elseif (!isset($data[$this->primary])) { // Single-column primary without autoincrement not present in the inserted data diff --git a/tests/types/database-types.php b/tests/types/database-types.php index ba221d7c9..88f7684a1 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -121,9 +121,9 @@ function testResultSetFetchPairs(ResultSet $resultSet): void /** @param Selection $selection */ function testSelectionInsertSingleRow(Selection $selection): void { - // Single associative array -> inserted ActiveRow (or affected count / data for keyless tables) + // Single associative array -> inserted ActiveRow (or affected count / null when the row can't be identified) $result = $selection->insert(['name' => 'Alice']); - assertType('array|int|Nette\Database\Table\ActiveRow', $result); + assertType('int|Nette\Database\Table\ActiveRow|null', $result); } From 2578dc499d7afa7a05f80d04eb528497d70f69eb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 8 Jun 2026 13:36:59 +0200 Subject: [PATCH 71/75] Selection::insert() returns null consistently when no row can be identified A single insert into a table without a primary key (or when the primary key cannot be determined) used to return the affected-row count, which is always 1 and thus meaningless. It now returns null, like the composite-incomplete case, so the single-row return type is consistently ActiveRow|null. --- src/Database/Table/GroupedSelection.php | 2 +- src/Database/Table/Selection.php | 15 ++++++--------- tests/Database/Explorer/Selection.insert().phpt | 4 ++-- .../Explorer/Selection.insert().primaryKeys.phpt | 2 +- tests/types/database-types.php | 4 ++-- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index 5c4ede48d..df02b4d13 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -259,7 +259,7 @@ protected function emptyResultSet(bool $clearCache = true, bool $deleteReference /** * @param iterable|Selection $data - * @return ($data is non-empty-list|Selection ? int : T|int|null) + * @return ($data is non-empty-list|Selection ? int : T|null) */ public function insert(iterable $data): ActiveRow|int|null { diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 7f5a506c3..066c72496 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -822,12 +822,11 @@ public function getDataRefreshed(): bool /** - * Inserts one or more rows into the table. - * A single associative array inserts one row and returns the inserted ActiveRow, - * or null/int when the inserted row cannot be identified by its primary key; + * Inserts a single row into the table and returns the inserted ActiveRow, + * or null when the inserted row cannot be identified by its primary key. * Passing a list of rows or a Selection is deprecated, use insertMany() instead. * @param iterable|list>|Selection $data - * @return ($data is non-empty-list|Selection ? int : T|int|null) + * @return ($data is non-empty-list|Selection ? int : T|null) */ public function insert(iterable $data): ActiveRow|int|null { @@ -851,14 +850,13 @@ public function insert(iterable $data): ActiveRow|int|null $data = Nette\Database\Helpers::translateColumns($data, $mapping); } - $return = $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); + $this->explorer->query($this->sqlBuilder->buildInsertQuery() . ' ?values', $data); $this->loadRefCache(); if ($this->primary === null) { $this->clearReferencingCache(); - return $return->getRowCount() - ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + return null; // a table without a primary key has no identifiable row } $primaryKey = []; @@ -882,8 +880,7 @@ public function insert(iterable $data): ActiveRow|int|null } } elseif (!isset($data[$this->primary])) { // Single-column primary without autoincrement not present in the inserted data $this->clearReferencingCache(); - return $return->getRowCount() - ?? throw new Nette\InvalidStateException('Cannot determine the number of affected rows.'); + return null; // the inserted row cannot be identified by its primary key } // otherwise $primaryKey already holds the whole primary key as a column => value map diff --git a/tests/Database/Explorer/Selection.insert().phpt b/tests/Database/Explorer/Selection.insert().phpt index dae0ccb6f..a038a18ad 100644 --- a/tests/Database/Explorer/Selection.insert().phpt +++ b/tests/Database/Explorer/Selection.insert().phpt @@ -68,9 +68,9 @@ if ($driverName !== 'sqlsrv') { } -// Insert into table without primary key +// Insert into table without primary key returns null (no identifiable row) $inserted = $explorer->table('note')->insert([ 'book_id' => 1, 'note' => 'Good one!', ]); -Assert::same(1, $inserted); +Assert::null($inserted); diff --git a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt index 307e56c07..2b757a540 100644 --- a/tests/Database/Explorer/Selection.insert().primaryKeys.phpt +++ b/tests/Database/Explorer/Selection.insert().primaryKeys.phpt @@ -121,7 +121,7 @@ test('insert into table without primary key', function () use ($explorer) { $noPkResult1 = $explorer->table('no_pk')->insert([ 'note' => 'Some note here', ]); - Assert::same(1, $noPkResult1); + Assert::null($noPkResult1); }); test('composite primary key insert returns a lazy row', function () use ($explorer, $connection) { diff --git a/tests/types/database-types.php b/tests/types/database-types.php index 88f7684a1..b01e84eeb 100644 --- a/tests/types/database-types.php +++ b/tests/types/database-types.php @@ -121,9 +121,9 @@ function testResultSetFetchPairs(ResultSet $resultSet): void /** @param Selection $selection */ function testSelectionInsertSingleRow(Selection $selection): void { - // Single associative array -> inserted ActiveRow (or affected count / null when the row can't be identified) + // Single associative array -> inserted ActiveRow (or null when the row can't be identified) $result = $selection->insert(['name' => 'Alice']); - assertType('int|Nette\Database\Table\ActiveRow|null', $result); + assertType('Nette\Database\Table\ActiveRow|null', $result); } From 8b9084d512dba1548357b0067a6d53ecfe07331e Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 9 Jun 2026 13:56:54 +0200 Subject: [PATCH 72/75] Selection::insert(): simplified primary-key resolution to always return a lazy row or null The per-shape completeness checks are replaced by a single count comparison against the primary-key columns. insert() no longer falls back to an eager fetch: it returns a lazy row whenever the whole primary key is known (single or composite) and null otherwise. As a result a composite key whose database-generated part cannot be determined now returns null instead of being fetched eagerly. --- docs/internals/explorer.md | 21 +++++++------- src/Database/Table/Selection.php | 47 ++++++++++++++------------------ 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/docs/internals/explorer.md b/docs/internals/explorer.md index 69d793043..0e773f902 100644 --- a/docs/internals/explorer.md +++ b/docs/internals/explorer.md @@ -145,13 +145,16 @@ by-ref array), so a mutation from one clone is seen by all clones of the same re ## insert() & insertMany() -`Selection::insert(iterable)`: 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. A **list or a Selection** is routed to -`insertMany()`, and doing that through `insert()` is **deprecated**. +`Selection::insert(iterable)`: a **single associative row** returns a **lazy +`ActiveRow`** knowing only its PK (or `null` when the full PK can't be determined); a +**list or a Selection** is routed to `insertMany()`, and doing that through `insert()` +is **deprecated**. + +Lazy is the **only** mode — used whenever the **full PK is known** (single or +composite; an autoincrement part is filled from `getInsertId`). There is no +eager-fetch fallback: a PK-less table or an incomplete PK returns `null` +(+ `clearReferencingCache()`). A returned row is also registered into `rows`/`data` +if the Selection was already executed. `insertMany(iterable)` owns the bulk logic and always returns an int. Three details are not obvious from the signature: @@ -167,10 +170,6 @@ are not obvious from the signature: - a single associative row is **rejected up front** rather than inserted, so the mistake surfaces before the write. -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. - `GroupedSelection` overrides both: `insert()` assigns the grouping column to a single row, `insertMany()` to every row of the list — on a **clone** of each `Row`, never on the caller's object. diff --git a/src/Database/Table/Selection.php b/src/Database/Table/Selection.php index 066c72496..94677fbf9 100644 --- a/src/Database/Table/Selection.php +++ b/src/Database/Table/Selection.php @@ -822,8 +822,9 @@ public function getDataRefreshed(): bool /** - * Inserts a single row into the table and returns the inserted ActiveRow, - * or null when the inserted row cannot be identified by its primary key. + * Inserts a single row into the table and returns the inserted ActiveRow, or null when the + * inserted row cannot be identified by its primary key. The returned row is loaded lazily: + * it initially holds only the primary key and fetches the remaining columns on first access. * Passing a list of rows or a Selection is deprecated, use insertMany() instead. * @param iterable|list>|Selection $data * @return ($data is non-empty-list|Selection ? int : T|null) @@ -859,10 +860,12 @@ public function insert(iterable $data): ActiveRow|int|null return null; // a table without a primary key has no identifiable row } + // collect the primary key of the inserted row to load it back as an ActiveRow + $primaryColumns = (array) $this->primary; $primaryKey = []; - foreach ((array) $this->primary as $key) { - if (isset($data[$key])) { - $primaryKey[$key] = $data[$key]; + foreach ($primaryColumns as $column) { + if (isset($data[$column])) { + $primaryKey[$column] = $data[$column]; } } @@ -870,42 +873,32 @@ public function insert(iterable $data): ActiveRow|int|null $primaryKey[$primaryAutoincrementKey] = $this->explorer->getInsertId($primarySequenceName ? $this->explorer->getConnection()->getDriver()->delimite($primarySequenceName) : $primarySequenceName); + } - } elseif (is_array($this->primary)) { // Multi column primary without autoincrement - foreach ($this->primary as $key) { - if (!isset($data[$key])) { - $this->clearReferencingCache(); - return null; // the inserted row cannot be identified by its primary key - } - } - } elseif (!isset($data[$this->primary])) { // Single-column primary without autoincrement not present in the inserted data + if (count($primaryKey) !== count($primaryColumns)) { $this->clearReferencingCache(); return null; // the inserted row cannot be identified by its primary key } - // otherwise $primaryKey already holds the whole primary key as a column => value map + // return a lazy row holding only the primary key and fetching the remaining columns on first access $selection = $this->createSelectionInstance($this->name) ->select('*') ->wherePrimary($primaryKey); - if (count($primaryKey) === count((array) $this->primary)) { // is primary key complete? - // normalize numeric strings to int for integer columns, to match what a fetch would return - $columns = null; - foreach ($primaryKey as $key => $value) { - if (is_string($value) && (string) (int) $value === $value) { - $columns ??= array_column($this->explorer->getStructure()->getColumns($this->name), 'nativetype', 'name'); - if (Nette\Database\Helpers::detectType($columns[$key] ?? '') === Nette\Database\IStructure::FIELD_INTEGER) { - $primaryKey[$key] = (int) $value; - } + // normalize numeric strings to int for integer columns, to match what a fetch would return + $columns = null; + foreach ($primaryKey as $key => $value) { + if (is_string($value) && (string) (int) $value === $value) { + $columns ??= array_column($this->explorer->getStructure()->getColumns($this->name), 'nativetype', 'name'); + if (Nette\Database\Helpers::detectType($columns[$key] ?? '') === Nette\Database\IStructure::FIELD_INTEGER) { + $primaryKey[$key] = (int) $value; } } - $row = $this->explorer->createActiveRow($primaryKey, $selection, deferredFetch: true); - - } else { - $row = $selection->fetch() ?? throw new Nette\ShouldNotHappenException; } /** @phpstan-var T $row */ + $row = $this->explorer->createActiveRow($primaryKey, $selection, deferredFetch: true); + if ($this->rows !== null) { if ($signature = $row->getSignature(false)) { $this->rows[$signature] = $row; From 7aa17fcbbac8daa93a2163aaba4fff4187c45e3a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 23:50:43 +0200 Subject: [PATCH 73/75] SqlPreprocessor: a multi-insert row with a missing or unexpected column warns Columns are taken from the first row, so a later row that misses one used to be silently filled with NULL (with an "Undefined array key" warning), and a column the first row does not have was dropped without a trace. Both now warn. --- src/Database/SqlPreprocessor.php | 15 +++++++++++--- tests/Database/SqlPreprocessor.phpt | 31 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/Database/SqlPreprocessor.php b/src/Database/SqlPreprocessor.php index 5830a96a6..12d0e3d64 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_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; +use function array_diff_key, array_flip, array_key_exists, array_key_first, 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; /** @@ -259,10 +259,19 @@ private function formatMultiInsert(array $groups): string $cols = array_keys(iterator_to_array($groups[0])); $vals = []; - foreach ($groups as $group) { + foreach ($groups as $i => $group) { + $group = is_array($group) ? $group : iterator_to_array($group); $rowVals = []; foreach ($cols as $k) { - $rowVals[] = $this->formatValue($group[$k]); + if (!array_key_exists($k, $group)) { // the column would be silently filled with NULL + trigger_error("Missing value for column '$k' in multi-insert row #$i.", E_USER_WARNING); + } + + $rowVals[] = $this->formatValue($group[$k] ?? null); + } + + if ($extra = array_diff_key($group, array_flip($cols))) { // the column is taken from the first row only + trigger_error("Unexpected column '" . array_key_first($extra) . "' in multi-insert row #$i.", E_USER_WARNING); } $vals[] = implode(', ', $rowVals); diff --git a/tests/Database/SqlPreprocessor.phpt b/tests/Database/SqlPreprocessor.phpt index c62635a32..738f5daf8 100644 --- a/tests/Database/SqlPreprocessor.phpt +++ b/tests/Database/SqlPreprocessor.phpt @@ -513,6 +513,37 @@ test('Detects incorrect multi-insert usage', function () use ($preprocessor) { }); +test('multi-insert row with a missing or unexpected column warns', function () use ($preprocessor) { + Assert::error( + fn() => $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark', 'born' => null], + ['name' => 'Sansa Stark'], // the column would be silently filled with NULL + ]]), + E_USER_WARNING, + "Missing value for column 'born' in multi-insert row #1.", + ); + + Assert::error( + fn() => $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark'], + ['name' => 'Sansa Stark', 'born' => null], // the column would be silently dropped + ]]), + E_USER_WARNING, + "Unexpected column 'born' in multi-insert row #1.", + ); + + [$sql, $params] = $preprocessor->process(['INSERT INTO author', [ + ['name' => 'Catelyn Stark', 'born' => null], + ['born' => null, 'name' => 'Sansa Stark'], // different key order is fine + ]]); + Assert::same(reformat([ + 'sqlite' => 'INSERT INTO author ([name], [born]) SELECT ?, NULL UNION ALL SELECT ?, NULL', + 'INSERT INTO author ([name], [born]) VALUES (?, NULL), (?, NULL)', + ]), $sql); + Assert::same(['Catelyn Stark', 'Sansa Stark'], $params); +}); + + test('multi-row INSERT query', function () use ($preprocessor) { [$sql, $params] = $preprocessor->process(['INSERT INTO author', [ ['name' => 'Catelyn Stark', 'born' => new DateTime('2011-11-11')], From ff42a974cd2770fa71543b089aa844b5842ed53a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 16 Jul 2026 23:50:51 +0200 Subject: [PATCH 74/75] SqlBuilder: tryDelimite() warns about unsupported string literals Values belong in parameters. A literal written straight into a SQL fragment is not recognized, so its content gets delimited as if it were an identifier (name = 'abc' becomes [name] = '[abc]'), which silently returns wrong rows instead of failing. Whether it happens at all depends on the content: 'x' breaks, '!' does not, so the trap is impossible to guess. --- src/Database/Table/SqlBuilder.php | 6 ++++++ tests/Database/Explorer/SqlBuilder.tryDelimite().phpt | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/src/Database/Table/SqlBuilder.php b/src/Database/Table/SqlBuilder.php index 4a4cbe904..d55a251bc 100644 --- a/src/Database/Table/SqlBuilder.php +++ b/src/Database/Table/SqlBuilder.php @@ -922,9 +922,15 @@ protected function buildQueryEnd(): string /** * Delimits lowercase identifiers in a SQL fragment while leaving uppercase keywords untouched. + * String literals are not supported - values belong in parameters - and warn when present. */ protected function tryDelimite(string $s): string { + if (str_contains($s, "'")) { + // the literal is not recognized and its content gets delimited as if it were an identifier + trigger_error("SQL string literals are not supported here, pass the value as a parameter instead: $s", E_USER_WARNING); + } + if (!$this->entityMapping) { return preg_replace_callback( '#(?<=[^\w`"\[?:]|^)[a-z_][a-z0-9_]*(?=[^\w`"(\]]|$)#Di', diff --git a/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt b/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt index 66dae9397..adc6eebad 100644 --- a/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt +++ b/tests/Database/Explorer/SqlBuilder.tryDelimite().phpt @@ -24,3 +24,10 @@ Assert::same(reformat('HELLO([world])'), $tryDelimite->invoke($sqlBuilder, 'HELL Assert::same(reformat('hello([world])'), $tryDelimite->invoke($sqlBuilder, 'hello(world)')); Assert::same('[hello]', $tryDelimite->invoke($sqlBuilder, '[hello]')); Assert::same(reformat('::int'), $tryDelimite->invoke($sqlBuilder, '::int')); + +// string literals are not supported, the content would be delimited as an identifier +Assert::error( + fn() => $tryDelimite->invoke($sqlBuilder, "name = 'abc'"), + E_USER_WARNING, + "SQL string literals are not supported here, pass the value as a parameter instead: name = 'abc'", +); From 9d32efba75db22781c11c6eba715342775db01b4 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 17 Jul 2026 01:45:04 +0200 Subject: [PATCH 75/75] GroupedSelection: an aggregate over a group without rows returns null max()/min()/sum() answered 0 for a group with no rows, which is a value the data never contained and which the plain Selection does not report either - it returns null there, like the SQL aggregate functions do for an empty set. count() keeps returning 0, it casts the result. --- src/Database/Table/GroupedSelection.php | 2 +- tests/Database/Explorer/Explorer.aggregation.phpt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Database/Table/GroupedSelection.php b/src/Database/Table/GroupedSelection.php index df02b4d13..fad10236a 100644 --- a/src/Database/Table/GroupedSelection.php +++ b/src/Database/Table/GroupedSelection.php @@ -136,7 +136,7 @@ public function aggregation(string $function, ?string $groupFunction = null): mi } } - return 0; + return null; // the group has no rows, which is what the aggregate function would return for them } diff --git a/tests/Database/Explorer/Explorer.aggregation.phpt b/tests/Database/Explorer/Explorer.aggregation.phpt index dee750846..fe987db11 100644 --- a/tests/Database/Explorer/Explorer.aggregation.phpt +++ b/tests/Database/Explorer/Explorer.aggregation.phpt @@ -80,6 +80,19 @@ test('filtering groups by related count', function () use ($explorer) { ], $bookTags); }); +test('aggregation of a group without rows', function () use ($explorer) { + $aggregates = []; + foreach ($explorer->table('author') as $author) { + $books = $author->related('book'); + $aggregates[$author->name] = [$books->count('*'), $books->max('id'), $books->min('id'), $books->sum('id')]; + } + + // Geek has no books, so the aggregate functions have nothing to return but null; only count() is a number + Assert::same([0, null, null, null], $aggregates['Geek']); + Assert::same([2, 4, 3, 7], $aggregates['David Grudl']); +}); + + test('nested group by and having', function () use ($explorer) { $bookTags = []; foreach ($explorer->table('author') as $author) {