Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ 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: <what>: <reason>`: `verify --jdbc-url` that can't
connect says `error: <url>: can't connect: …` (was `error: IOException: can't connect to <url>: …`), and an input
that isn't there `error: <file>: 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: <input>: reading table <name> failed (…); the file may be damaged: try Compact and Repair in Access`,
where it was `error: table <name> failed: IOException: Unexpected order in index entries …`.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions src/main/java/io/lytrax/accessconverter/cli/JdbcOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
33 changes: 30 additions & 3 deletions src/main/java/io/lytrax/accessconverter/cli/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,20 +251,42 @@ 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 <what>: <reason>}. 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()) {
if (t instanceof SourceException || t instanceof OutputException) {
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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 12 additions & 4 deletions src/main/java/io/lytrax/accessconverter/source/AccessSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ColumnModel> columns = key.columnNames().stream()
.map(name -> table.column(name).orElseThrow())
.toList();
return new KeyLookup(cursor, columns);
return new KeyLookup(file, what, cursor, columns);
}

/**
Expand Down
24 changes: 19 additions & 5 deletions src/main/java/io/lytrax/accessconverter/source/KeyLookup.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ColumnModel> columns;
private final Set<String> names = new LinkedHashSet<>();

KeyLookup(IndexCursor cursor, List<ColumnModel> columns) {
KeyLookup(Path file, String what, IndexCursor cursor, List<ColumnModel> columns) {
this.file = file;
this.what = what;
this.cursor = cursor;
this.columns = List.copyOf(columns);
this.columns.forEach(c -> names.add(c.name()));
Expand All @@ -30,7 +35,11 @@ public List<ColumnModel> 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<Object[]> find(Object... key) throws IOException {
if (key.length != columns.size()) {
throw new IllegalArgumentException("expected " + columns.size() + " key values, got " + key.length);
Expand All @@ -39,10 +48,15 @@ public Optional<Object[]> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(""));
}

Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 45 additions & 1 deletion src/test/java/io/lytrax/accessconverter/cli/MainTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading