From dc7166d6f81136f00f68bad41eb0566b77c78458 Mon Sep 17 00:00:00 2001 From: Christos Lytras Date: Thu, 8 Oct 2026 16:04:18 +0300 Subject: [PATCH 1/2] Word warnings and errors by what failed, not by the library or Java class CATALOG_INDEX_UNUSABLE and UNSUPPORTED_COLUMN_TYPE describe the database instead of naming Jackcess; the codes are unchanged. Errors read error: : instead of starting with a Java class name. --- CHANGELOG.md | 8 ++++ .../accessconverter/cli/JdbcOptions.java | 4 +- .../io/lytrax/accessconverter/cli/Main.java | 33 +++++++++++-- .../accessconverter/cli/VerifyCommand.java | 2 +- .../accessconverter/extract/TableReader.java | 2 +- .../accessconverter/source/AccessSource.java | 2 +- .../target/OutputException.java | 8 +++- .../target/mysql/MySqlVerifier.java | 11 ++--- .../target/sqlite/SqliteVerifier.java | 2 +- .../target/sqlite/SqliteWriter.java | 2 +- .../lytrax/accessconverter/cli/MainTest.java | 46 ++++++++++++++++++- .../accessconverter/it/VerifyJdbcIT.java | 1 + .../regression/CatalogIndexTest.java | 4 +- .../target/AtomicOutputTest.java | 7 +++ .../target/mysql/MySqlFixture.java | 3 ++ .../V2007/unsupportedFieldsV2007.accdb.txt | 6 +-- 16 files changed, 116 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64e632..998470b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ fixes the patch version. - A date/time whose stored value isn't on a whole millisecond keeps the fraction of a second it holds, where it was rounded to milliseconds: Jackcess now reads the value that converts back to the same stored number. Dates and times entered in Access are whole seconds and unchanged. +- Two warnings describe the database instead of naming the library that reads it, with the same codes: + `CATALOG_INDEX_UNUSABLE` says "this database's catalog index is damaged (…); the catalog was read by scanning it + instead", and `UNSUPPORTED_COLUMN_TYPE` says "this column type (…) can't be decoded; its raw bytes are kept". +- Errors name what failed instead of a Java class, as `error: : `: `verify --jdbc-url` that can't + connect says `error: : can't connect: …` (was `error: IOException: can't connect to : …`), and an input + that isn't there `error: : no such file` (was `error: NoSuchFileException: …`). So do a missing or wrong + `--jdbc-driver`, a database or SQLite output `verify` can't read back, and a SQLite output that fails while it is + written, which is named as the output. ### Fixed diff --git a/src/main/java/io/lytrax/accessconverter/cli/JdbcOptions.java b/src/main/java/io/lytrax/accessconverter/cli/JdbcOptions.java index 730b4e0..fc25efd 100644 --- a/src/main/java/io/lytrax/accessconverter/cli/JdbcOptions.java +++ b/src/main/java/io/lytrax/accessconverter/cli/JdbcOptions.java @@ -85,9 +85,9 @@ Connection connect() throws IOException { } } } - throw new IOException(driver + " has no JDBC driver for " + url); + throw new IOException(driver + ": no JDBC driver in it accepts " + url); } catch (SQLException e) { - throw new IOException("can't connect to " + url + ": " + e.getMessage() + hint(e), e); + throw new IOException(url + ": can't connect: " + e.getMessage() + hint(e), e); } } diff --git a/src/main/java/io/lytrax/accessconverter/cli/Main.java b/src/main/java/io/lytrax/accessconverter/cli/Main.java index 28b3308..faf8529 100644 --- a/src/main/java/io/lytrax/accessconverter/cli/Main.java +++ b/src/main/java/io/lytrax/accessconverter/cli/Main.java @@ -12,8 +12,13 @@ import java.io.Writer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystemException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; +import java.nio.file.NotDirectoryException; import java.nio.file.Path; import java.util.concurrent.Callable; import java.util.logging.Level; @@ -246,9 +251,10 @@ static void requireFile(Path file) throws NoSuchFileException { } /** - * One line for the user. A {@link SourceException} (a database that can't be read) and an {@link OutputException} - * (an output that can't be written) already say which file and why; other I/O errors name their type; anything - * else is a bug. + * One line for the user, {@code : }. A {@link SourceException} (a database that can't be read), an + * {@link OutputException} (an output that can't be written), the other I/O errors this tool words itself and a + * file-system error with a reason already say which and why; a common file-system error without a reason gets + * one in words. Other I/O errors name their type; anything else is a bug. */ static String message(Throwable e) { for (Throwable t = e; t != null; t = t.getCause()) { @@ -256,10 +262,31 @@ static String message(Throwable e) { return t.getMessage(); } } + if (e.getMessage() != null + && (e.getClass() == IOException.class + || e instanceof FileSystemException fs && fs.getReason() != null)) { + return e.getMessage(); + } + String reason = e instanceof FileSystemException fs && fs.getFile() != null ? reason(fs) : null; + if (reason != null) { + return e.getMessage() + ": " + reason; + } String text = e.getClass().getSimpleName() + (e.getMessage() == null ? "" : ": " + e.getMessage()); return e instanceof IOException ? text : text + " (unexpected; run with --verbose for details)"; } + /** What a file-system error that gives no reason means, in words; null for one that isn't common. */ + private static String reason(FileSystemException e) { + return switch (e) { + case NoSuchFileException x -> "no such file"; + case AccessDeniedException x -> "no permission"; + case NotDirectoryException x -> "not a folder"; + case FileAlreadyExistsException x -> "it already exists"; + case DirectoryNotEmptyException x -> "the folder isn't empty"; + default -> null; + }; + } + static final class Version implements IVersionProvider { @Override public String[] getVersion() { diff --git a/src/main/java/io/lytrax/accessconverter/cli/VerifyCommand.java b/src/main/java/io/lytrax/accessconverter/cli/VerifyCommand.java index 945577f..eeaa127 100644 --- a/src/main/java/io/lytrax/accessconverter/cli/VerifyCommand.java +++ b/src/main/java/io/lytrax/accessconverter/cli/VerifyCommand.java @@ -197,7 +197,7 @@ private Integer compareDatabase() throws IOException { result = MySqlVerifier.verify( access, planned, db, jdbc.dumpDirectory == null ? Path.of("") : jdbc.dumpDirectory); } catch (SQLException e) { - throw new IOException("reading the database failed: " + e.getMessage(), e); + throw new IOException(jdbc.url + ": reading the database failed: " + e.getMessage(), e); } return print(model, jdbc.url + " (" + server + ")", result, issues); } diff --git a/src/main/java/io/lytrax/accessconverter/extract/TableReader.java b/src/main/java/io/lytrax/accessconverter/extract/TableReader.java index 23f3367..32f162f 100644 --- a/src/main/java/io/lytrax/accessconverter/extract/TableReader.java +++ b/src/main/java/io/lytrax/accessconverter/extract/TableReader.java @@ -94,7 +94,7 @@ private static ColumnModel column(String table, Column column, Issues issues) { IssueCode.UNSUPPORTED_COLUMN_TYPE, table, name, - "Jackcess can't decode this column type (" + column.getType() + "); its raw bytes are kept"); + "this column type (" + column.getType() + ") can't be decoded; its raw bytes are kept"); } Properties props = Properties.of(table, name, column::getProperties, issues); diff --git a/src/main/java/io/lytrax/accessconverter/source/AccessSource.java b/src/main/java/io/lytrax/accessconverter/source/AccessSource.java index aa0bc0b..cd26c82 100644 --- a/src/main/java/io/lytrax/accessconverter/source/AccessSource.java +++ b/src/main/java/io/lytrax/accessconverter/source/AccessSource.java @@ -184,7 +184,7 @@ private static Database withReadableCatalog(Path file, Database db, String passw IssueCode.CATALOG_INDEX_UNUSABLE, null, null, - "Jackcess can't use this database's catalog index (" + indexed.missingFrom(byScan) + "this database's catalog index is damaged (" + indexed.missingFrom(byScan) + "); the catalog was read by scanning it instead"); db.close(); return scanned; diff --git a/src/main/java/io/lytrax/accessconverter/target/OutputException.java b/src/main/java/io/lytrax/accessconverter/target/OutputException.java index 1d79a0a..bc46b69 100644 --- a/src/main/java/io/lytrax/accessconverter/target/OutputException.java +++ b/src/main/java/io/lytrax/accessconverter/target/OutputException.java @@ -25,8 +25,9 @@ public OutputException(Path output, String reason, Throwable cause) { /** * What a failure writing {@code output} is to the user: a full disk, or a file-system error on the output or on * {@code written} (the path actually being written, such as its {@code .partial}), in words and under the - * output's own name. {@code file} is whether the output is a file, not a directory of files. Anything else, a - * failure reading the source included, is returned unchanged. + * output's own name. {@code file} is whether the output is a file, not a directory of files. A plain + * {@link IOException}, whose message says what went wrong writing it, is put under the output's name. Anything + * else, a failure reading the source included, is returned unchanged. */ public static IOException of(Path output, Path written, boolean file, IOException e) { if (e instanceof OutputException || causedBy(e, SourceException.class)) { @@ -44,6 +45,9 @@ public static IOException of(Path output, Path written, boolean file, IOExceptio } return new OutputException(output, reason, e); } + if (e.getClass() == IOException.class && e.getMessage() != null) { + return new OutputException(output, e.getMessage(), e); + } return e; } diff --git a/src/main/java/io/lytrax/accessconverter/target/mysql/MySqlVerifier.java b/src/main/java/io/lytrax/accessconverter/target/mysql/MySqlVerifier.java index 314cbc7..bd8dafa 100644 --- a/src/main/java/io/lytrax/accessconverter/target/mysql/MySqlVerifier.java +++ b/src/main/java/io/lytrax/accessconverter/target/mysql/MySqlVerifier.java @@ -62,7 +62,8 @@ private MySqlVerifier(AccessSource source, MySqlPlan plan, Path dumpDirectory) { } /** @param db a connection whose current database holds the imported dump */ - public static VerifyResult verify(AccessSource source, MySqlPlan plan, Connection db) throws IOException { + public static VerifyResult verify(AccessSource source, MySqlPlan plan, Connection db) + throws IOException, SQLException { return verify(source, plan, db, Path.of("")); } @@ -73,13 +74,9 @@ public static VerifyResult verify(AccessSource source, MySqlPlan plan, Connectio * are relative to */ public static VerifyResult verify(AccessSource source, MySqlPlan plan, Connection db, Path dumpDirectory) - throws IOException { + throws IOException, SQLException { MySqlVerifier verifier = new MySqlVerifier(source, plan, dumpDirectory); - try { - verifier.run(db); - } catch (SQLException e) { - throw new IOException("the database could not be read back: " + e.getMessage(), e); - } + verifier.run(db); return verifier.differences.result(); } diff --git a/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteVerifier.java b/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteVerifier.java index a65dba0..fba484b 100644 --- a/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteVerifier.java +++ b/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteVerifier.java @@ -49,7 +49,7 @@ public static VerifyResult verify(AccessSource source, SqlitePlan plan, Path out try (Connection db = DriverManager.getConnection("jdbc:sqlite:" + output.toAbsolutePath())) { verifier.run(db); } catch (SQLException e) { - throw new IOException("the output could not be read back: " + e.getMessage(), e); + throw new IOException(output + ": the output could not be read back: " + e.getMessage(), e); } return verifier.differences.result(); } diff --git a/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteWriter.java b/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteWriter.java index 8992442..7bb23f3 100644 --- a/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteWriter.java +++ b/src/main/java/io/lytrax/accessconverter/target/sqlite/SqliteWriter.java @@ -184,7 +184,7 @@ private void build(Connection db, boolean integrityCheck) throws IOException { pragma(db, "journal_mode = DELETE"); execute(db, sqlite.analyze() ? "ANALYZE" : "PRAGMA optimize"); } catch (SQLException e) { - throw new IOException("writing the SQLite output failed: " + e.getMessage(), e); + throw new IOException("SQLite could not write it: " + e.getMessage(), e); } } diff --git a/src/test/java/io/lytrax/accessconverter/cli/MainTest.java b/src/test/java/io/lytrax/accessconverter/cli/MainTest.java index bb7583c..8ddf968 100644 --- a/src/test/java/io/lytrax/accessconverter/cli/MainTest.java +++ b/src/test/java/io/lytrax/accessconverter/cli/MainTest.java @@ -5,11 +5,15 @@ import io.lytrax.accessconverter.fixtures.CorpusFile; import io.lytrax.accessconverter.fixtures.GeneratedFixture; import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -52,7 +56,47 @@ void usageErrorsGiveSixtyFour() { void aMissingInputFails(@TempDir Path dir) { Cli cli = Cli.run("inspect", dir.resolve("missing.accdb").toString()); assertThat(cli.exitCode()).isEqualTo(ExitCodes.FAILED); - assertThat(cli.err()).startsWith("error: NoSuchFileException").contains("missing.accdb"); + assertThat(cli.err().lines()).containsExactly("error: " + dir.resolve("missing.accdb") + ": no such file"); + } + + @Test + void aDatabaseThatCantBeConnectedToIsNamed() { + String url = "jdbc:nothing://localhost/db"; + Cli cli = Cli.run("verify", GeneratedFixture.HUNDRED_ROWS.path().toString(), "--jdbc-url", url); + assertThat(cli.exitCode()).isEqualTo(ExitCodes.FAILED); + assertThat(cli.err().lines()) + .singleElement() + .satisfies(line -> assertThat(line) + .startsWith("error: " + url + ": can't connect: ") + .endsWith("; pass --jdbc-driver with MariaDB Connector/J or MySQL Connector/J")); + } + + @Test + void aMissingJdbcDriverIsNamed(@TempDir Path dir) { + Path driver = dir.resolve("driver.jar"); + Cli cli = Cli.run( + "verify", + GeneratedFixture.HUNDRED_ROWS.path().toString(), + "--jdbc-url", + "jdbc:mariadb://localhost/db", + "--jdbc-driver", + driver.toString()); + assertThat(cli.exitCode()).isEqualTo(ExitCodes.FAILED); + assertThat(cli.err().lines()).containsExactly("error: " + driver + ": no such JDBC driver jar"); + } + + @Test + void errorsNameWhatFailedNotAJavaClass() { + assertThat(Main.message(new IOException("x.jar: no JDBC driver in it accepts jdbc:y"))) + .isEqualTo("x.jar: no JDBC driver in it accepts jdbc:y"); + assertThat(Main.message(new NoSuchFileException("a.accdb"))).isEqualTo("a.accdb: no such file"); + assertThat(Main.message(new AccessDeniedException("a.accdb"))).isEqualTo("a.accdb: no permission"); + assertThat(Main.message(new AccessDeniedException("a.accdb", null, "locked"))) + .isEqualTo("a.accdb: locked"); + // An I/O error the tool didn't word, and a bug, still say what they are + assertThat(Main.message(new EOFException("end of stream"))).isEqualTo("EOFException: end of stream"); + assertThat(Main.message(new IllegalStateException("oops"))) + .isEqualTo("IllegalStateException: oops (unexpected; run with --verbose for details)"); } @Test diff --git a/src/test/java/io/lytrax/accessconverter/it/VerifyJdbcIT.java b/src/test/java/io/lytrax/accessconverter/it/VerifyJdbcIT.java index 27d4853..3de01d3 100644 --- a/src/test/java/io/lytrax/accessconverter/it/VerifyJdbcIT.java +++ b/src/test/java/io/lytrax/accessconverter/it/VerifyJdbcIT.java @@ -99,6 +99,7 @@ void mariaDbConnectorOnMySqlGetsTheFixItNeeds(String image) throws Exception { Run refused = run("verify", input, "--jdbc-url", url, "--db-user", user, "--db-password", "pw"); assertThat(refused.code()).isEqualTo(ExitCodes.FAILED); assertThat(refused.err()) + .startsWith("error: " + url + ": can't connect: ") .contains("RSA public key is not available") .contains(url + "?allowPublicKeyRetrieval=true"); diff --git a/src/test/java/io/lytrax/accessconverter/regression/CatalogIndexTest.java b/src/test/java/io/lytrax/accessconverter/regression/CatalogIndexTest.java index 1b4e855..1b2dc92 100644 --- a/src/test/java/io/lytrax/accessconverter/regression/CatalogIndexTest.java +++ b/src/test/java/io/lytrax/accessconverter/regression/CatalogIndexTest.java @@ -92,8 +92,8 @@ void aTableTheDamagedCatalogIndexMissesIsReadByScanning(String id, String table, assertThat(extraction.issues(IssueCode.CATALOG_INDEX_UNUSABLE)) .singleElement() .satisfies(i -> assertThat(i.message()) - .contains("1 of " + tables + " tables can't be looked up") - .contains("scanning")); + .isEqualTo("this database's catalog index is damaged (1 of " + tables + + " tables can't be looked up); the catalog was read by scanning it instead")); assertThat(extraction.issues()) .extracting(Issue::code) .doesNotContain(IssueCode.TABLE_NOT_IN_CATALOG, IssueCode.RELATIONSHIPS_UNREADABLE); diff --git a/src/test/java/io/lytrax/accessconverter/target/AtomicOutputTest.java b/src/test/java/io/lytrax/accessconverter/target/AtomicOutputTest.java index 9904b46..7848e70 100644 --- a/src/test/java/io/lytrax/accessconverter/target/AtomicOutputTest.java +++ b/src/test/java/io/lytrax/accessconverter/target/AtomicOutputTest.java @@ -51,6 +51,13 @@ void noPermissionOnThePartialNamesTheOutput() { .hasMessage(output() + ": no permission to write it"); } + @Test + void aFailureInWordsIsPutUnderTheOutputsName() { + assertThatThrownBy(() -> failing(new IOException("PRAGMA integrity_check failed: x"))) + .isInstanceOf(OutputException.class) + .hasMessage(output() + ": PRAGMA integrity_check failed: x"); + } + @Test void aFileErrorElsewhereIsNotTheOutputs() { NoSuchFileException elsewhere = diff --git a/src/test/java/io/lytrax/accessconverter/target/mysql/MySqlFixture.java b/src/test/java/io/lytrax/accessconverter/target/mysql/MySqlFixture.java index 153c322..ccf4792 100644 --- a/src/test/java/io/lytrax/accessconverter/target/mysql/MySqlFixture.java +++ b/src/test/java/io/lytrax/accessconverter/target/mysql/MySqlFixture.java @@ -18,6 +18,7 @@ import java.io.UncheckedIOException; import java.nio.file.Path; import java.sql.Connection; +import java.sql.SQLException; import java.util.List; import java.util.Set; @@ -96,6 +97,8 @@ public VerifyResult verify(Connection db, Path dumpDirectory) { return MySqlVerifier.verify(access, plan, db, dumpDirectory); } catch (IOException e) { throw new UncheckedIOException("verifying " + source, e); + } catch (SQLException e) { + throw new IllegalStateException("verifying " + source, e); } } } diff --git a/src/test/resources/golden/inspect/jackcess/V2007/unsupportedFieldsV2007.accdb.txt b/src/test/resources/golden/inspect/jackcess/V2007/unsupportedFieldsV2007.accdb.txt index bdc4bd3..e5c9d77 100644 --- a/src/test/resources/golden/inspect/jackcess/V2007/unsupportedFieldsV2007.accdb.txt +++ b/src/test/resources/golden/inspect/jackcess/V2007/unsupportedFieldsV2007.accdb.txt @@ -18,6 +18,6 @@ Profile: UnknownComplex: nulls 0 Issues: 3 - warning UNSUPPORTED_COLUMN_TYPE Test / UnknownComplex: Jackcess can't decode this column type (COMPLEX_TYPE); its raw bytes are kept - warning UNSUPPORTED_COLUMN_TYPE Test / UnknownFix: Jackcess can't decode this column type (UNSUPPORTED_FIXEDLEN); its raw bytes are kept - warning UNSUPPORTED_COLUMN_TYPE Test / UnknownVar: Jackcess can't decode this column type (UNSUPPORTED_VARLEN); its raw bytes are kept + warning UNSUPPORTED_COLUMN_TYPE Test / UnknownComplex: this column type (COMPLEX_TYPE) can't be decoded; its raw bytes are kept + warning UNSUPPORTED_COLUMN_TYPE Test / UnknownFix: this column type (UNSUPPORTED_FIXEDLEN) can't be decoded; its raw bytes are kept + warning UNSUPPORTED_COLUMN_TYPE Test / UnknownVar: this column type (UNSUPPORTED_VARLEN) can't be decoded; its raw bytes are kept From 683137f0d48b94387a66aa1792fd8732754b7b09 Mon Sep 17 00:00:00 2001 From: Christos Lytras Date: Thu, 8 Oct 2026 17:03:30 +0300 Subject: [PATCH 2/2] Report a damaged index found by the relationship check as a read failure A key lookup through a damaged index failed with a plain IOException, so the conversion ended with error: table X failed: IOException: ... instead of naming the input and suggesting Compact and Repair. KeyLookup now wraps it with SourceException.readFailed, as RowStream does for rows. --- CHANGELOG.md | 3 +++ .../accessconverter/source/AccessSource.java | 14 +++++++--- .../accessconverter/source/KeyLookup.java | 24 +++++++++++++---- .../cli/ConvertCommandTest.java | 20 ++++++++++++++ .../accessconverter/fixtures/DamagedCopy.java | 23 +++++++++++++++- .../target/sqlite/SqliteTableErrorTest.java | 26 +++++++++++++++++++ 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 998470b..a7689aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ fixes the patch version. that isn't there `error: : no such file` (was `error: NoSuchFileException: …`). So do a missing or wrong `--jdbc-driver`, a database or SQLite output `verify` can't read back, and a SQLite output that fails while it is written, which is named as the output. +- A damaged index that fails while relationships are checked, before anything is written, reads like any damaged + table: `error: : reading table failed (…); the file may be damaged: try Compact and Repair in Access`, + where it was `error: table failed: IOException: Unexpected order in index entries …`. ### Fixed diff --git a/src/main/java/io/lytrax/accessconverter/source/AccessSource.java b/src/main/java/io/lytrax/accessconverter/source/AccessSource.java index cd26c82..cd54a39 100644 --- a/src/main/java/io/lytrax/accessconverter/source/AccessSource.java +++ b/src/main/java/io/lytrax/accessconverter/source/AccessSource.java @@ -434,15 +434,23 @@ private TableModel parentOf(TableModel child) { /** * Finds rows by the key of a PK or unique index, with Access's own index semantics. Creating the lookup, or a - * seek, throws an unchecked exception when Jackcess can't encode keys for the index's collation. + * seek, throws an unchecked exception when Jackcess can't encode keys for the index's collation; an index that + * can't be read, as a damaged one can't, fails with a {@link SourceException}, as reading the rows does. */ public KeyLookup keyLookup(TableModel table, IndexModel key) throws IOException { + Path file = fileOf(table); Table jt = localTable(table); - IndexCursor cursor = CursorBuilder.createCursor(jackcessIndex(jt, key)); + String what = "table " + jt.getName(); + IndexCursor cursor; + try { + cursor = CursorBuilder.createCursor(jackcessIndex(jt, key)); + } catch (IOException e) { + throw SourceException.readFailed(file, what, e); + } List columns = key.columnNames().stream() .map(name -> table.column(name).orElseThrow()) .toList(); - return new KeyLookup(cursor, columns); + return new KeyLookup(file, what, cursor, columns); } /** diff --git a/src/main/java/io/lytrax/accessconverter/source/KeyLookup.java b/src/main/java/io/lytrax/accessconverter/source/KeyLookup.java index 84c3392..64de146 100644 --- a/src/main/java/io/lytrax/accessconverter/source/KeyLookup.java +++ b/src/main/java/io/lytrax/accessconverter/source/KeyLookup.java @@ -5,6 +5,7 @@ import io.lytrax.accessconverter.model.ColumnModel; import io.lytrax.accessconverter.value.AccessValues; import java.io.IOException; +import java.nio.file.Path; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; @@ -15,11 +16,15 @@ * as the index sort order does. The stored key is returned, so callers can tell exact matches from inexact ones. */ public final class KeyLookup { + private final Path file; + private final String what; private final IndexCursor cursor; private final List columns; private final Set names = new LinkedHashSet<>(); - KeyLookup(IndexCursor cursor, List columns) { + KeyLookup(Path file, String what, IndexCursor cursor, List columns) { + this.file = file; + this.what = what; this.cursor = cursor; this.columns = List.copyOf(columns); this.columns.forEach(c -> names.add(c.name())); @@ -30,7 +35,11 @@ public List columns() { return columns; } - /** The stored key (canonical values) of the first row matching {@code key}, or empty. */ + /** + * The stored key (canonical values) of the first row matching {@code key}, or empty. + * + * @throws SourceException when the index or the row can't be read + */ public Optional find(Object... key) throws IOException { if (key.length != columns.size()) { throw new IllegalArgumentException("expected " + columns.size() + " key values, got " + key.length); @@ -39,10 +48,15 @@ public Optional find(Object... key) throws IOException { for (int i = 0; i < key.length; i++) { entry[i] = AccessValues.toJackcess(columns.get(i).type(), key[i]); } - if (!cursor.findFirstRowByEntry(entry)) { - return Optional.empty(); + Row row; + try { + if (!cursor.findFirstRowByEntry(entry)) { + return Optional.empty(); + } + row = cursor.getCurrentRow(names); + } catch (IOException e) { + throw SourceException.readFailed(file, what, e); } - Row row = cursor.getCurrentRow(names); Object[] stored = new Object[columns.size()]; for (int i = 0; i < stored.length; i++) { ColumnModel column = columns.get(i); diff --git a/src/test/java/io/lytrax/accessconverter/cli/ConvertCommandTest.java b/src/test/java/io/lytrax/accessconverter/cli/ConvertCommandTest.java index 8729eee..d4d2e7d 100644 --- a/src/test/java/io/lytrax/accessconverter/cli/ConvertCommandTest.java +++ b/src/test/java/io/lytrax/accessconverter/cli/ConvertCommandTest.java @@ -286,6 +286,26 @@ void aSourceThatCannotBeReadFailsWithOneLineAndWritesNothing() { assertThat(output).doesNotExist(); } + /** + * A damaged index fails while the relationship check looks parent keys up through it, not while rows are read: + * the error still names the input and the table, and says what to do, as a damaged data page does. + */ + @Test + void aDamagedIndexReadsLikeAnyDamagedTable() throws IOException { + Path damaged = DamagedCopy.withIndexDamaged( + GeneratedFixture.SCHEMA_FIDELITY.path(), "Customers", "PrimaryKey", dir.resolve("damaged.accdb")); + Path output = dir.resolve("damaged.sqlite3"); + + Cli cli = Cli.run("convert", "--to", "sqlite", "-o", output.toString(), damaged.toString()); + assertThat(cli.exitCode()).isEqualTo(ExitCodes.FAILED); + assertThat(cli.err().lines()) + .singleElement() + .satisfies(line -> assertThat(line) + .startsWith("error: damaged.accdb: reading table Customers failed (IOException: ") + .endsWith("); the file may be damaged: try Compact and Repair in Access")); + assertThat(output).doesNotExist(); + } + /** * A table damaged so that the profiling pass can't read it (04): {@code continue} writes the other tables and a * failed report naming it, {@code fail} leaves nothing, and {@code verify} reports it and compares the rest. diff --git a/src/test/java/io/lytrax/accessconverter/fixtures/DamagedCopy.java b/src/test/java/io/lytrax/accessconverter/fixtures/DamagedCopy.java index 83ef518..0a709ec 100644 --- a/src/test/java/io/lytrax/accessconverter/fixtures/DamagedCopy.java +++ b/src/test/java/io/lytrax/accessconverter/fixtures/DamagedCopy.java @@ -24,7 +24,7 @@ /** * Test helper: a copy of a database with one part damaged, as a disk or a crashed write damages it. The corpus ships * no damaged files, so the damage is made where the test needs it, and the rest of the file is left intact: a - * table's first data page, or one entry of the catalog's index. + * table's first data page, a table's index, or one entry of the catalog's index. */ public final class DamagedCopy { @@ -67,6 +67,27 @@ public static Path withDataPageDamaged(Path source, String table, Path copy) thr return copy; } + /** + * Copies {@code source} to {@code copy} and damages the root page of {@code table}'s index {@code index}: past its + * header, most of the page is overwritten with nonsense, so its entries are out of order. The table's rows, which + * a scan reads, are left intact; a lookup through the index fails. + */ + public static Path withIndexDamaged(Path source, String table, String index, Path copy) throws IOException { + Files.copy(source, copy, StandardCopyOption.REPLACE_EXISTING); + int pageSize; + int root; + try (Database db = Fixtures.openReadOnly(copy)) { + pageSize = ((DatabaseImpl) db).getPageChannel().getFormat().PAGE_SIZE; + root = rootPage((IndexImpl) db.getTable(table).getIndex(index)); + } + byte[] garbage = new byte[3000]; + new Random(42).nextBytes(garbage); + try (FileChannel file = FileChannel.open(copy, StandardOpenOption.WRITE)) { + file.write(ByteBuffer.wrap(garbage), (long) root * pageSize + 40); + } + return copy; + } + /** * Copies {@code source} to {@code copy} and damages the entry for {@code object} (a table, or a system table such * as {@code MSysRelationships}) in the index Jackcess finds catalog rows by ({@code ParentIdName} on {@code diff --git a/src/test/java/io/lytrax/accessconverter/target/sqlite/SqliteTableErrorTest.java b/src/test/java/io/lytrax/accessconverter/target/sqlite/SqliteTableErrorTest.java index fbc22b8..f733f77 100644 --- a/src/test/java/io/lytrax/accessconverter/target/sqlite/SqliteTableErrorTest.java +++ b/src/test/java/io/lytrax/accessconverter/target/sqlite/SqliteTableErrorTest.java @@ -21,6 +21,7 @@ import io.lytrax.accessconverter.target.BinaryMode; import io.lytrax.accessconverter.target.ConvertOptions; import io.lytrax.accessconverter.target.ConvertOptions.OnTableError; +import io.lytrax.accessconverter.target.OutputException; import io.lytrax.accessconverter.target.RowSource; import io.lytrax.accessconverter.target.WriteOutcome; import java.io.IOException; @@ -214,6 +215,31 @@ void aTableThatFailsLeavesNoFilesBehind() throws IOException { assertThat(issues.list()).noneMatch(i -> i.code() == IssueCode.BINARY_FILES_NOT_REMOVED); } + /** A table that fails to be written stops the conversion under {@code fail}, named after the output. */ + @Test + void aWriteFailureUnderFailNamesTheOutput() throws IOException { + Path output = dir.resolve("write.sqlite3"); + Issues issues = new Issues(); + ConvertOptions options = new ConvertOptions(true, false, OnTableError.FAIL, 1); + try (AccessSource db = AccessSource.open(GeneratedFixture.SCHEMA_FIDELITY.path())) { + SchemaModel model = SchemaExtractor.extract(db, ExtractOptions.ALL, issues); + SqlitePlan plan = + SqlitePlanner.plan(model, DataProfiler.profile(db, model), options, SqliteOptions.DEFAULT, issues); + assertThatThrownBy(() -> SqliteWriter.write( + db::rows, + required(plan), + output, + options, + SqliteOptions.DEFAULT, + "AccessConverter", + false, + issues)) + .isInstanceOf(OutputException.class) + .hasMessageStartingWith(output + ": table Files failed: "); + } + assertThat(output).doesNotExist(); + } + /** The plan with {@code Files.Raw} NOT NULL, which the second row contradicts. */ private static SqlitePlan required(SqlitePlan plan) { List tables = plan.tables().stream()