Skip to content

Report changed rows for updates - #458

Draft
JanJakes wants to merge 1 commit into
trunkfrom
agent/report-changed-update-rows
Draft

Report changed rows for updates#458
JanJakes wants to merge 1 commit into
trunkfrom
agent/report-changed-update-rows

Conversation

@JanJakes

@JanJakes JanJakes commented Jul 27, 2026

Copy link
Copy Markdown
Member

What changed

This PR fixes affected-row reporting for translated UPDATE statements. The driver now returns the number of rows whose values actually changed, matching MySQL semantics instead of SQLite's matched-row behavior. In particular, a no-op update now returns 0 rather than 1.

  • Accurate changed-row counts: Compare the assigned columns' OLD and NEW values with binary semantics.
  • Single assignment evaluation: Keep evaluation in the original UPDATE, including non-deterministic expressions and values selected from joined tables.
  • Broad update support: Handle ordinary, CTE, joined, IGNORE, and temporary-table updates, with cleanup after both successful and failed statements.

How it works

SQLite's affected-row result counts rows matched by an UPDATE, even when every assignment leaves the stored values unchanged. The driver therefore creates a connection-private temporary trigger for the duration of each update. The trigger invokes an internal SQLite function only when an assigned value changes, incrementing the driver's affected-row counter directly. It is always removed in a finally block.

Approaches considered

The available approaches range from simple SQLite counters to native integration:

  1. SQLite counters and statement output. changes(), total_changes(), PRAGMA count_changes, PDO's native row count, and plain RETURNING all report rows processed by SQLite, including no-op assignments. RETURNING exposes post-update values but does not support OLD.column, so it cannot identify actual changes by itself. Metadata and statistics facilities such as data_version, dbstat, update hooks, and page or WAL counters do not retain the required row-level OLD and NEW values.

  2. Filter the original UPDATE to changed rows. Adding a null-safe difference predicate makes SQLite's native count correct in one statement and avoids a second scan. However, it suppresses update triggers for matched no-op rows, evaluates assignment expressions again, and can change LIMIT, IGNORE, or joined-update behavior.

  3. Run a preflight SELECT COUNT(*). Counting rows whose current values differ before executing the original update is straightforward for deterministic, filtered, single-table updates. It can diverge when expressions are volatile, constraints cause rows to be ignored, joins provide multiple source candidates, or the original query contains more complex selection semantics.

  4. Materialize candidates and use UPDATE … RETURNING. A materialized CTE can preserve old values and computed assignments, while RETURNING exposes successfully updated rows. This keeps the count tied to the update, but requires materializing every candidate, merging with existing CTEs, preserving exact row identity, and substantially rewriting joined updates.

  5. Wrap assignments in stateful SQLite functions. Functions could capture old values while returning computed assignments unchanged, with RETURNING used to compare final values. Passing values through PHP risks type coercion, while row identity, ignored rows, legacy SQLite support, and per-statement state make the design complex.

  6. Compare OLD and NEW in a temporary trigger. This observes the values from the actual update execution, naturally handling volatile expressions, joins, ignored rows, affinity, and no-op assignments. A single-row temporary counter table would keep all state in SQLite but require nested table DML for every changed row. Calling a connection-local function instead avoids that table work and increments the driver's counter directly, which is the approach used here.

  7. Use SQLite's native pre-update hook. sqlite3_preupdate_hook() is the cleanest engine-level mechanism because it exposes old and new column values during the update. It is compile-time optional and unavailable through PDO, so adopting it would require native integration beyond this driver.

Count actual OLD and NEW values with a connection-private temporary trigger. The trigger invokes an internal SQLite function that updates the driver's affected-row count directly, allowing the translated UPDATE to select joined values and evaluate assignments only once.
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.

1 participant