diff options
6 files changed, 460 insertions, 12 deletions
diff --git a/src/changes/changes.xml b/src/changes/changes.xml index ba2583d..d46a983 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -18,6 +18,11 @@ issue="3"> Allow inserting negative auto number fields, thanks to Gord Thompson. </action> + <action dev="jahlborn" type="update" system="SourceForge2Features" + issue="36"> + Add CustomLinkResolver which facilitates loading linked tables from + files which are not access databases. + </action> </release> <release version="2.1.6" date="2016-11-29"> <action dev="jahlborn" type="update" system="SourceForge2Features" diff --git a/src/main/java/com/healthmarketscience/jackcess/impl/DatabaseImpl.java b/src/main/java/com/healthmarketscience/jackcess/impl/DatabaseImpl.java index 17741a5..31d2635 100644 --- a/src/main/java/com/healthmarketscience/jackcess/impl/DatabaseImpl.java +++ b/src/main/java/com/healthmarketscience/jackcess/impl/DatabaseImpl.java @@ -454,10 +454,10 @@ public class DatabaseImpl implements Database boolean success = false; try { channel.truncate(0); - transferFrom(channel, getResourceAsStream(details.getEmptyFilePath())); + transferDbFrom(channel, getResourceAsStream(details.getEmptyFilePath())); channel.force(true); DatabaseImpl db = new DatabaseImpl(mdbFile, channel, closeChannel, autoSync, - fileFormat, charset, timeZone, null); + fileFormat, charset, timeZone, null); success = true; return db; } finally { @@ -508,8 +508,8 @@ public class DatabaseImpl implements Database * @param timeZone TimeZone to use, if {@code null}, uses default */ protected DatabaseImpl(File file, FileChannel channel, boolean closeChannel, - boolean autoSync, FileFormat fileFormat, Charset charset, - TimeZone timeZone, CodecProvider provider) + boolean autoSync, FileFormat fileFormat, Charset charset, + TimeZone timeZone, CodecProvider provider) throws IOException { _file = file; @@ -971,7 +971,7 @@ public class DatabaseImpl implements Database * @param includeSystemTables whether to consider returning a system table * @return The table, or null if it doesn't exist */ - private TableImpl getTable(String name, boolean includeSystemTables) + protected TableImpl getTable(String name, boolean includeSystemTables) throws IOException { TableInfo tableInfo = getTableInfo(name, includeSystemTables); @@ -1948,10 +1948,10 @@ public class DatabaseImpl implements Database } /** - * Copies the given InputStream to the given channel using the most + * Copies the given db InputStream to the given channel using the most * efficient means possible. */ - static void transferFrom(FileChannel channel, InputStream in) + protected static void transferDbFrom(FileChannel channel, InputStream in) throws IOException { ReadableByteChannel readChannel = Channels.newChannel(in); @@ -1991,7 +1991,7 @@ public class DatabaseImpl implements Database return pwdMask; } - static InputStream getResourceAsStream(String resourceName) + protected static InputStream getResourceAsStream(String resourceName) throws IOException { InputStream stream = DatabaseImpl.class.getClassLoader() diff --git a/src/main/java/com/healthmarketscience/jackcess/util/CustomLinkResolver.java b/src/main/java/com/healthmarketscience/jackcess/util/CustomLinkResolver.java new file mode 100644 index 0000000..9f4cace --- /dev/null +++ b/src/main/java/com/healthmarketscience/jackcess/util/CustomLinkResolver.java @@ -0,0 +1,283 @@ +/* +Copyright (c) 2017 James Ahlborn + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.healthmarketscience.jackcess.util; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.util.Random; + +import com.healthmarketscience.jackcess.Database; +import com.healthmarketscience.jackcess.Database.FileFormat; +import com.healthmarketscience.jackcess.Table; +import com.healthmarketscience.jackcess.impl.ByteUtil; +import com.healthmarketscience.jackcess.impl.DatabaseImpl; +import com.healthmarketscience.jackcess.impl.TableImpl; + +/** + * Utility base implementaton of LinkResolver which facilitates loading linked + * tables from files which are not access databases. The LinkResolver API + * ultimately presents linked table information to the primary database using + * the jackcess {@link Database} and {@link Table} classes. In order to + * consume linked tables in non-mdb files, they need to somehow be coerced + * into the appropriate form. The approach taken by this utility is to make + * it easy to copy the external tables into a temporary mdb file for + * consumption by the primary database. + * <p> + * The primary features of this utility: + * <ul> + * <li>Supports custom behavior for non-mdb files and default behavior for mdb + * files, see {@link #loadCustomFile}</li> + * <li>Temp db can be an actual file or entirely in memory</li> + * <li>Linked tables are loaded on-demand, see {@link #loadCustomTable}</li> + * <li>Temp db files will be automatically deleted on close</li> + * </ul> + * + * @author James Ahlborn + * @usage _intermediate_class_ + */ +public abstract class CustomLinkResolver implements LinkResolver +{ + private static final Random DB_ID = new Random(); + + private static final String MEM_DB_PREFIX = "memdb_"; + private static final String FILE_DB_PREFIX = "linkeddb_"; + + /** the default file format used for temp dbs */ + public static final FileFormat DEFAULT_FORMAT = FileFormat.V2000; + /** temp dbs default to the filesystem, not in memory */ + public static final boolean DEFAULT_IN_MEMORY = false; + /** temp dbs end up in the system temp dir by default */ + public static final File DEFAULT_TEMP_DIR = null; + + private final FileFormat _defaultFormat; + private final boolean _defaultInMemory; + private final File _defaultTempDir; + + /** + * Creates a CustomLinkResolver using the default behavior for creating temp + * dbs, see {@link #DEFAULT_FORMAT}, {@link #DEFAULT_IN_MEMORY} and + * {@link #DEFAULT_TEMP_DIR}. + */ + protected CustomLinkResolver() { + this(DEFAULT_FORMAT, DEFAULT_IN_MEMORY, DEFAULT_TEMP_DIR); + } + + /** + * Creates a CustomLinkResolver with the given default behavior for creating + * temp dbs. + * + * @param defaultFormat the default format for the temp db + * @param defaultInMemory whether or not the temp db should be entirely in + * memory by default (while this will be faster, it + * should only be used if table data is expected to + * fit entirely in memory) + * @param defaultTempDir the default temp dir for a file based temp db + * ({@code null} for the system defaqult temp + * directory) + */ + protected CustomLinkResolver(FileFormat defaultFormat, boolean defaultInMemory, + File defaultTempDir) + { + _defaultFormat = defaultFormat; + _defaultInMemory = defaultInMemory; + _defaultTempDir = defaultTempDir; + } + + /** + * Custom implementation is: + * <pre> + * // attempt to load the linkeeFileName as a custom file + * Object customFile = loadCustomFile(linkerDb, linkeeFileName); + * + * if(customFile != null) { + * // this is a custom file, create and return relevant temp db + * return createTempDb(customFile, _defaultFormat, _defaultInMemory, + * _defaultTempDir); + * } + * + * // not a custmom file, load using the default behavior + * return LinkResolver.DEFAULT.resolveLinkedDatabase(linkerDb, linkeeFileName); + * </pre> + * + * @see #loadCustomFile + * @see #createTempDb + * @see LinkResolver#DEFAULT + */ + public Database resolveLinkedDatabase(Database linkerDb, String linkeeFileName) + throws IOException + { + Object customFile = loadCustomFile(linkerDb, linkeeFileName); + if(customFile != null) { + return createTempDb(customFile, _defaultFormat, _defaultInMemory, + _defaultTempDir); + } + return LinkResolver.DEFAULT.resolveLinkedDatabase(linkerDb, linkeeFileName); + } + + /** + * Creates a temporary database for holding the table data from + * linkeeFileName. + * + * @param customFile custom file state returned from {@link #loadCustomFile} + * @param format the access format for the temp db + * @param inMemory whether or not the temp db should be entirely in memory + * (while this will be faster, it should only be used if + * table data is expected to fit entirely in memory) + * @param tempDir the temp dir for a file based temp db ({@code null} for + * the system default temp directory) + * + * @return the temp db for holding the linked table info + */ + protected Database createTempDb(Object customFile, FileFormat format, + boolean inMemory, File tempDir) + throws IOException + { + File dbFile = null; + FileChannel channel = null; + boolean success = false; + try { + + if(inMemory) { + dbFile = new File(MEM_DB_PREFIX + DB_ID.nextLong() + + format.getFileExtension()); + channel = MemFileChannel.newChannel(); + } else { + dbFile = File.createTempFile(FILE_DB_PREFIX, format.getFileExtension(), + tempDir); + channel = new RandomAccessFile(dbFile, DatabaseImpl.RW_CHANNEL_MODE) + .getChannel(); + } + + TempDatabaseImpl.initDbChannel(channel, format); + TempDatabaseImpl db = new TempDatabaseImpl(this, customFile, dbFile, + channel, format); + success = true; + return db; + + } finally { + if(!success) { + ByteUtil.closeQuietly(channel); + deleteDbFile(dbFile); + closeCustomFile(customFile); + } + } + } + + private static void deleteDbFile(File dbFile) { + if((dbFile != null) && (dbFile.getName().startsWith(FILE_DB_PREFIX))) { + dbFile.delete(); + } + } + + private static void closeCustomFile(Object customFile) { + if(customFile instanceof Closeable) { + ByteUtil.closeQuietly((Closeable)customFile); + } + } + + /** + * Called by {@link #resolveLinkedDatabase} to determine whether the + * linkeeFileName should be treated as a custom file (thus utiliziing a temp + * db) or a normal access db (loaded via the default behavior). Loads any + * state necessary for subsequently loading data from linkeeFileName. + * <p> + * The returned custom file state object will be maintained with the temp db + * and passed to {@link #loadCustomTable} whenever a new table needs to be + * loaded. Also, if this object is {@link Closeable}, it will be closed + * with the temp db. + * + * @param linkerDb the primary database in which the link is defined + * @param linkeeFileName the name of the linked file + * + * @return non-{@code null} if linkeeFileName should be treated as a custom + * file (using a temp db) or {@code null} if it should be treated as + * a normal access db. + */ + protected abstract Object loadCustomFile( + Database linkerDb, String linkeeFileName) throws IOException; + + /** + * Called by an instance of a temp db when a missing table is first requested. + * + * @param tempDb the temp db instance which should be populated with the + * relevant table info for the given tableName + * @param customFile custom file state returned from {@link #loadCustomFile} + * @param tableName the name of the table which is requested from the linked + * file + * + * @return {@code true} if the table was available in the linked file, + * {@code false} otherwise + */ + protected abstract boolean loadCustomTable( + Database tempDb, Object customFile, String tableName) + throws IOException; + + + /** + * Subclass of DatabaseImpl which allows us to load tables "on demand" as + * well as delete the temporary db on close. + */ + private static class TempDatabaseImpl extends DatabaseImpl + { + private final CustomLinkResolver _resolver; + private final Object _customFile; + + protected TempDatabaseImpl(CustomLinkResolver resolver, Object customFile, + File file, FileChannel channel, + FileFormat fileFormat) + throws IOException + { + super(file, channel, true, false, fileFormat, null, null, null); + _resolver = resolver; + _customFile = customFile; + } + + @Override + protected TableImpl getTable(String name, boolean includeSystemTables) + throws IOException + { + TableImpl table = super.getTable(name, includeSystemTables); + if((table == null) && + _resolver.loadCustomTable(this, _customFile, name)) { + table = super.getTable(name, includeSystemTables); + } + return table; + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + deleteDbFile(getFile()); + closeCustomFile(_customFile); + } + } + + static FileChannel initDbChannel(FileChannel channel, FileFormat format) + throws IOException + { + FileFormatDetails details = getFileFormatDetails(format); + transferDbFrom(channel, getResourceAsStream(details.getEmptyFilePath())); + return channel; + } + } + +} diff --git a/src/test/java/com/healthmarketscience/jackcess/TestUtil.java b/src/test/java/com/healthmarketscience/jackcess/TestUtil.java index ab3a8d4..3317c7f 100644 --- a/src/test/java/com/healthmarketscience/jackcess/TestUtil.java +++ b/src/test/java/com/healthmarketscience/jackcess/TestUtil.java @@ -85,7 +85,8 @@ public class TestUtil public static Database open(FileFormat fileFormat, File file, boolean inMem) throws Exception { - FileChannel channel = (inMem ? MemFileChannel.newChannel(file, "rw") + FileChannel channel = (inMem ? MemFileChannel.newChannel( + file, DatabaseImpl.RW_CHANNEL_MODE) : null); final Database db = new DatabaseBuilder(file).setReadOnly(true) .setAutoSync(getTestAutoSync()).setChannel(channel).open(); @@ -134,7 +135,7 @@ public class TestUtil .getResourceAsStream("emptyJet4.mdb"); File f = createTempFile(keep); if (channel != null) { - JetFormatTest.transferFrom(channel, inStream); + JetFormatTest.transferDbFrom(channel, inStream); } else { ByteUtil.copy(inStream, outStream = new FileOutputStream(f)); outStream.close(); diff --git a/src/test/java/com/healthmarketscience/jackcess/impl/JetFormatTest.java b/src/test/java/com/healthmarketscience/jackcess/impl/JetFormatTest.java index d2ebd41..b302985 100644 --- a/src/test/java/com/healthmarketscience/jackcess/impl/JetFormatTest.java +++ b/src/test/java/com/healthmarketscience/jackcess/impl/JetFormatTest.java @@ -265,9 +265,9 @@ public class JetFormatTest extends TestCase { } } - public static void transferFrom(FileChannel channel, InputStream in) + public static void transferDbFrom(FileChannel channel, InputStream in) throws IOException { - DatabaseImpl.transferFrom(channel, in); + DatabaseImpl.transferDbFrom(channel, in); } } diff --git a/src/test/java/com/healthmarketscience/jackcess/util/CustomLinkResolverTest.java b/src/test/java/com/healthmarketscience/jackcess/util/CustomLinkResolverTest.java new file mode 100644 index 0000000..31a8853 --- /dev/null +++ b/src/test/java/com/healthmarketscience/jackcess/util/CustomLinkResolverTest.java @@ -0,0 +1,159 @@ +/* +Copyright (c) 2017 James Ahlborn + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package com.healthmarketscience.jackcess.util; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +import com.healthmarketscience.jackcess.ColumnBuilder; +import com.healthmarketscience.jackcess.DataType; +import com.healthmarketscience.jackcess.Database; +import com.healthmarketscience.jackcess.Database.FileFormat; +import com.healthmarketscience.jackcess.Table; +import com.healthmarketscience.jackcess.TableBuilder; +import junit.framework.TestCase; +import static com.healthmarketscience.jackcess.TestUtil.*; +import static com.healthmarketscience.jackcess.impl.JetFormatTest.*; + +/** + * + * @author James Ahlborn + */ +public class CustomLinkResolverTest extends TestCase +{ + + public CustomLinkResolverTest(String name) { + super(name); + } + + public void testCustomLinkResolver() throws Exception { + for(final FileFormat fileFormat : SUPPORTED_FILEFORMATS) { + Database db = create(fileFormat); + + db.setLinkResolver(new TestLinkResolver()); + + db.createLinkedTable("Table1", "testFile1.txt", "Table1"); + db.createLinkedTable("Table2", "testFile2.txt", "OtherTable2"); + db.createLinkedTable("Table3", "missingFile3.txt", "MissingTable3"); + db.createLinkedTable("Table4", "testFile2.txt", "MissingTable4"); + + Table t1 = db.getTable("Table1"); + assertNotNull(t1); + assertNotSame(db, t1.getDatabase()); + + assertTable(createExpectedTable(createExpectedRow("id", 0, + "data1", "row0"), + createExpectedRow("id", 1, + "data1", "row1"), + createExpectedRow("id", 2, + "data1", "row2")), + t1); + + Table t2 = db.getTable("Table2"); + assertNotNull(t2); + assertNotSame(db, t2.getDatabase()); + + assertTable(createExpectedTable(createExpectedRow("id", 3, + "data2", "row3"), + createExpectedRow("id", 4, + "data2", "row4"), + createExpectedRow("id", 5, + "data2", "row5")), + t2); + + assertNull(db.getTable("Table4")); + + try { + db.getTable("Table3"); + fail("FileNotFoundException should have been thrown"); + } catch(FileNotFoundException e) { + // success + } + + db.close(); + } + } + + private static class TestLinkResolver extends CustomLinkResolver + { + private TestLinkResolver() + { + super(DEFAULT_FORMAT, true, DEFAULT_TEMP_DIR); + } + + @Override + protected Object loadCustomFile( + Database linkerDb, String linkeeFileName) throws IOException + { + return (("testFile1.txt".equals(linkeeFileName) || + "testFile2.txt".equals(linkeeFileName)) ? + linkeeFileName : null); + } + + @Override + protected boolean loadCustomTable( + Database tempDb, Object customFile, String tableName) + throws IOException + { + if("Table1".equals(tableName)) { + + assertEquals("testFile1.txt", customFile); + Table t = new TableBuilder(tableName) + .addColumn(new ColumnBuilder("id", DataType.LONG)) + .addColumn(new ColumnBuilder("data1", DataType.TEXT)) + .toTable(tempDb); + + for(int i = 0; i < 3; ++i) { + t.addRow(i, "row" + i); + } + + return true; + + } else if("OtherTable2".equals(tableName)) { + + assertEquals("testFile2.txt", customFile); + Table t = new TableBuilder(tableName) + .addColumn(new ColumnBuilder("id", DataType.LONG)) + .addColumn(new ColumnBuilder("data2", DataType.TEXT)) + .toTable(tempDb); + + for(int i = 3; i < 6; ++i) { + t.addRow(i, "row" + i); + } + + return true; + + } else if("Table4".equals(tableName)) { + + assertEquals("testFile2.txt", customFile); + return false; + } + + return false; + } + + @Override + protected Database createTempDb(Object customFile, FileFormat format, + boolean inMemory, File tempDir) + throws IOException + { + inMemory = "testFile1.txt".equals(customFile); + return super.createTempDb(customFile, format, inMemory, tempDir); + } + } +} |