Skip to content

fix(csvim): bind the UPDATE's WHERE key at the table's column count - #6683

Merged
delchev merged 1 commit into
masterfrom
fix/csvim-update-pk-bind
Aug 12, 2026
Merged

fix(csvim): bind the UPDATE's WHERE key at the table's column count#6683
delchev merged 1 commit into
masterfrom
fix/csvim-update-pk-bind

Conversation

@delchev

@delchev delchev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #6676.

CsvProcessor.updateCsvWithHeader bound the UPDATE's WHERE <pk> = ? parameter at the CSV record's field count M instead of the table's column count N. The statement carries N−1 SET placeholders plus the WHERE at N, so the two agree only when the CSV is exactly as wide as its table.

  • M < N — the id lands in SET slot #M and the real WHERE placeholder is never set. H2's checkParameters throws before execution, so it fails closed: no corruption, but no corrected seed value can ever reach an existing row.
  • M > N — parameter index out of range.

INSERT binds by column name and is unaffected, which is why only re-imports broke. It stayed invisible because a plain nomenclature seed (id,name over a two-column table) is exactly as wide as its table by construction; any table with columns the CSV does not carry — most commonly the four audit columns — misses by that many.

The two positional siblings from the issue

Both had the same assumption, and both are fixed here:

  • updateCsvWithoutHeader bound SET slots for the record's fields only and the key at M, leaving the trailing SET slots and the WHERE unbound for a narrow record.
  • insertCsvWithoutHeader bound M placeholders while insert() builds N.

They now iterate the table's columns and bind a column the record does not reach as null — the outcome the header path already produces for a column the CSV omits, so the two paths agree rather than diverging on the same file. The semantic call the issue asked for is therefore bind null, not reject, on the narrow side. A record with more fields than the table has columns has nowhere to put them: that now fails with a message naming the counts and the table, instead of an IndexOutOfBoundsException, because silently dropping trailing fields would import a row that does not say what the file says.

One more cause found while testing

update() resolved the skipped SET column and the WHERE column separately. getPkName returns null for a headerless CSV (it only looks up the key column when header names are present), and getName().equals(null) is false — so nothing was skipped, the statement grew to N+1 placeholders, and no binder could satisfy it. The headerless IT caught this as Parameter "#4" is not set on a 3-column table. One resolved name now drives both the skip and the WHERE, making the statement shape invariant.

Tests

CsvimReimportIT (new, untagged so it runs in the PR smoke gate) asserts at the outermost layer per the issue's request: import, change a value, re-import, then assert the row holds the new value and that a column the CSV does not carry is null rather than the record's id. Both fixtures are deliberately narrower than their table — an M == N fixture passes with the arithmetic still wrong.

Verified red before, green after: with the fix reverted, Tests run: 2, Failures: 2; with it, Tests run: 2, Failures: 0. Neighbouring suites re-run green: CsvimIdentityRestartIT, CsvimIT. Formatter and release-profile javadoc clean on both changed modules.

Note for maintainers, not addressed here

The existing CsvProcessorTest in tests/tests-integrations/src/main/java never runs: failsafe is configured with testClassesDirectory=${project.build.outputDirectory} and **/*IT.java, while surefire has no such override, so a *Test class in that module's src/main/java is compiled and then skipped by both. That is why the new coverage is an IT rather than an addition to that class. Worth a separate look — there may be other dead *Test classes in there.

…6676)

updateCsvWithHeader bound the WHERE-clause primary key at the CSV record's
field count M instead of the table's column count N. The UPDATE carries N-1
SET placeholders plus the WHERE at N, so the two agree only when the CSV is
exactly as wide as its table. For M < N the id went into SET slot #M and the
WHERE placeholder was never set - H2 throws at checkParameters before
execution, so it failed closed: no corruption, but no corrected seed value
could ever reach an existing row. The INSERT path binds by column name and is
unaffected, which is why only re-imports broke.

It stayed invisible because a plain nomenclature seed (id,name over a
two-column table) is exactly as wide as its table by construction. Any table
with columns the CSV does not carry - most commonly the four audit columns -
misses by that many.

The two positional siblings flagged on the issue had the same assumption:

- updateCsvWithoutHeader bound SET slots for the record's fields only and the
  key at M, leaving both the trailing SET slots and the WHERE unbound when the
  record was narrower than the table.
- insertCsvWithoutHeader bound M placeholders while insert() builds N.

Both now iterate the table's columns and bind a column the record does not
reach as null - the outcome the header path already produces for a column the
CSV omits, so the two paths agree. A record with MORE fields than the table has
columns has nowhere to put them; that now fails with a message naming the
counts and the table instead of an IndexOutOfBoundsException, since silently
dropping trailing fields would import a row that does not say what the file
says.

update() also resolved the skipped SET column and the WHERE column separately,
so a null pkName - which is what getPkName returns for a headerless CSV - emitted
a SET for the key column too and produced N+1 placeholders that no binder could
satisfy. One resolved name now drives both, making the statement shape invariant.

CsvimReimportIT covers the header and headerless paths at the outermost layer:
import, change a value, re-import, assert the row holds the new value and that a
column the CSV does not carry is null rather than the record's id. Both fixtures
are deliberately narrower than their table - an M == N fixture passes with the
arithmetic still wrong. Verified red before the fix and green after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
void reimportAppliesChangedValueWhenCsvIsNarrowerThanTable() throws Exception {
try (Connection connection = dataSourceManager.getDefaultDataSource()
.getConnection()) {
connection.createStatement()
Comment on lines +71 to +72
ResultSet rs = connection.createStatement()
.executeQuery("SELECT R2, R3 FROM CSV_REIMPORT WHERE R1 = 1");
// the same rows with one changed value, in a CSV one column narrower than the table
csvimProcessor.process(csvFile, "R1,R2\n1,r2_1_changed\n2,r2_2".getBytes(), defaultDataSourceName);

ResultSet rs = connection.createStatement()
Comment on lines +77 to +78
rs = connection.createStatement()
.executeQuery("SELECT COUNT(*) FROM CSV_REIMPORT");
assertEquals("r2_1_changed", rs.getString("R2"), "The changed value did not reach the row");
assertNull(rs.getString("R3"), "A column the CSV does not carry must be set to null, not to the record's id");

rs = connection.createStatement()
Comment on lines +107 to +108
ResultSet rs = connection.createStatement()
.executeQuery("SELECT N2, N3 FROM CSV_REIMPORT_NH WHERE N1 = 1");

csvimProcessor.process(csvFile, "1,n2_1_changed\n2,n2_2".getBytes(), defaultDataSourceName);

ResultSet rs = connection.createStatement()
Comment on lines +113 to +114
rs = connection.createStatement()
.executeQuery("SELECT COUNT(*) FROM CSV_REIMPORT_NH");
assertEquals("n2_1_changed", rs.getString("N2"), "The changed value did not reach the row");
assertNull(rs.getString("N3"), "A column the record does not reach must be bound as null");

rs = connection.createStatement()
assertTrue(rs.next());
assertEquals(2, rs.getInt(1), "The re-import must update the existing rows, not add new ones");
} finally {
connection.createStatement()
@delchev
delchev merged commit 52c7c58 into master Aug 12, 2026
8 of 10 checks passed
@delchev
delchev deleted the fix/csvim-update-pk-bind branch August 12, 2026 20:23
delchev added a commit that referenced this pull request Aug 13, 2026
…6697)

A .csvim artefact re-imported only when its own content changed - the
referenced CSVs were read during the import but took no part in change
detection, so editing only a CSV re-imported nothing, silently. The
.csvim is a stable pointer that stays byte-identical when a seed value
changes, so the ordinary editing path never reached the (now correct,
after #6683) UPDATE re-import.

The Synchronizer interface gains a checksumContent(location, content)
hook - by default the definition file's own bytes - which
SynchronizationProcessor.checkAndCollect uses for the definition's
checksum. CsvimSynchronizer overrides it to append each referenced
CSV's content, so an edited CSV yields MODIFIED and the existing
UPDATE path runs unchanged. A CSV that does not exist yet contributes
nothing, so its later appearance also triggers the import.

The new IT drives the full synchronizer path over registry fixtures,
edits ONLY the .csv, and asserts the row's value - verified red
without the fix (expected Varna, was Sofia) and green with it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

csvim: UPDATE binds the WHERE-clause PK at the CSV's column count, so a changed seed value can never re-import

2 participants