Skip to content

Fetching rows as native arrays for better static analysis - #336

Closed
wonka007 wants to merge 76 commits into
nette:masterfrom
wonka007:v3.3
Closed

wonka007 wants to merge 76 commits into
nette:masterfrom
wonka007:v3.3

Conversation

@wonka007

Copy link
Copy Markdown
  • bug fix / new feature? Allows rows to be fetched directly as arrays as fetchAssoc does.
  • BC break? NO
  • doc PR: nette/docs#??? (Will provide after I know that there is interest for this feature.)

Apply the possibility to use fetchAssoc to get native array type of next row to fetchAll without the need to convert from \Nette\Database\Row back to array.

\Nette\Database\Row cannot be typed properly (at least I wasn't able to use something like

/** @var \Nette\Database\Row{id: int} */

But this syntax is possible to use with fetchAssoc because that returns array:

/** @var array{id: int, name: string}
$this->db->fetchAssoc("SELECT id, name FROM user WHERE id = ?", $user_id);

This new proposed function fetchAllArray copies what fetchAll does but without the need to convert array that are retrieved from the driver into \Nette\Database\Row (Arrays::toObject($data, new Row)) while not breaking the iterator over rows with original type.

The result is list which can be typed similarly as example above.

/** @var list<array{id: int, name: string}>
$this->db->fetchAssoc("SELECT id, name FROM user");

This appoach unclocks new possibilities for static analysis. We have to keep in mind that these hints rely on developer knowledge of the query string used and will likely not be useful for higly dynamic queries where unions should have to be used and checked.

It also saves some performance because convertion to \Nette\Database\Row is not useful most of the time.

dg and others added 30 commits June 7, 2026 14:58
…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.
… multi-insert (nette#330)

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 <m@kminet.eu>
…method

The three identical unset() of refCache['referencing'] in insert()/insertMany()
are replaced by a single clearReferencingCache() method.
…tifiable 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.
…alse for binary (non-UTF-8) parameters and made condition dedup collide
… a WHERE identical to an existing JOIN condition is no longer dropped
…tly mutate the builder with the implicit ORDER BY
…arent rows and no longer lost from the builder after execution
…key, a re-query used to crash with LogicException
… the final ROLLBACK fails (e.g. after a deadlock)
…s, SQLite CHECK, PostgreSQL 57P0x and MySQL 4031 as connection lost)
…ng a phantom empty table on drivers that do not fail (SQLite)
…lumn order, functional index parts, view comment, per-connection column-type cache)
dg and others added 27 commits July 17, 2026 00:41
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.
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.
… RetryableException

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.
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.
…y 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.
…precated

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.
…identifiable 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.
…tified

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.
…rn 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.
…mn 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.
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.
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.
@wonka007
wonka007 changed the base branch from v3.3 to master September 24, 2026 12:21
@wonka007
wonka007 marked this pull request as draft September 24, 2026 12:22
@wonka007 wonka007 closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants