fix(csvim): bind the UPDATE's WHERE key at the table's column count - #6683
Merged
Conversation
…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() |
This was referenced Aug 12, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6676.
CsvProcessor.updateCsvWithHeaderbound theUPDATE'sWHERE <pk> = ?parameter at the CSV record's field count M instead of the table's column count N. The statement carries N−1SETplaceholders plus theWHEREat N, so the two agree only when the CSV is exactly as wide as its table.SETslot #M and the realWHEREplaceholder is never set. H2'scheckParametersthrows before execution, so it fails closed: no corruption, but no corrected seed value can ever reach an existing row.INSERTbinds by column name and is unaffected, which is why only re-imports broke. It stayed invisible because a plain nomenclature seed (id,nameover 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:
updateCsvWithoutHeaderboundSETslots for the record's fields only and the key at M, leaving the trailingSETslots and theWHEREunbound for a narrow record.insertCsvWithoutHeaderbound M placeholders whileinsert()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 anIndexOutOfBoundsException, 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 skippedSETcolumn and theWHEREcolumn separately.getPkNamereturns null for a headerless CSV (it only looks up the key column when header names are present), andgetName().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 asParameter "#4" is not seton a 3-column table. One resolved name now drives both the skip and theWHERE, 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 isnullrather than the record's id. Both fixtures are deliberately narrower than their table — anM == Nfixture 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
CsvProcessorTestintests/tests-integrations/src/main/javanever runs: failsafe is configured withtestClassesDirectory=${project.build.outputDirectory}and**/*IT.java, while surefire has no such override, so a*Testclass in that module'ssrc/main/javais compiled and then skipped by both. That is why the new coverage is anITrather than an addition to that class. Worth a separate look — there may be other dead*Testclasses in there.