/** * Copyright (C) 2012-2013 Selventa, Inc. * * This file is part of the OpenBEL Framework. * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * The OpenBEL Framework is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public * License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with the OpenBEL Framework. If not, see <http://www.gnu.org/licenses/>. * * Additional Terms under LGPL v3: * * This license does not authorize you and you are prohibited from using the * name, trademarks, service marks, logos or similar indicia of Selventa, Inc., * or, in the discretion of other licensors or authors of the program, the * name, trademarks, service marks, logos or similar indicia of such authors or * licensors, in any marketing or advertising materials relating to your * distribution of the program or any covered product. This restriction does * not waive or limit your obligation to keep intact all copyright notices set * forth in the program as delivered to you. * * If you distribute the program in whole or in part, or any modified version * of the program, and you assume contractual liability to the recipient with * respect to the program or modified version, then you will indemnify the * authors and licensors of the program for any liabilities that these * contractual assumptions directly impose on those licensors and authors. */ package org.openbel.framework.common.record; import static java.lang.String.format; import static java.lang.System.arraycopy; import static java.nio.ByteBuffer.allocate; import static java.util.Arrays.copyOf; import static java.util.Arrays.fill; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.util.ConcurrentModificationException; import java.util.Iterator; import org.openbel.framework.common.InvalidArgument; /** * Record files are a representation of files that contain fixed-length records. * As such, the size of these files are multiples of their associated record * size. The first record of every record file contains metadata indicating the * record size. * <p> * It is helpful to consider the contents of these objects in a tabular manner. * * <pre> * <i>row number</i>: column one, column two, [...], column n * </pre> * * An example of a record file's contents containing two columns, each with a * size of 8 bytes. * * <pre> * <i>0:</i> 0xffffffff 0xffffffff * <i>1:</i> 0xffffffff 0xffffffff * </pre> * * </p> * <p> * When a record file is created with a existing file, the size of the last * column will be set at runtime by reading the metadata. Record files are not * thread-safe. * </p> * * @author Nick Bargnesi */ public class RecordFile implements Iterable<byte[]> { private static final String RO_MSG; static { RO_MSG = "record file is read-only"; } final String path; final RandomAccessFile raf; final Column<?>[] columns; final int recordSize; final boolean readonly; long recordCt; long size; /** * Creates a record file associated with the supplied {@link File path} and * {@link Column columns}. * * @param path {@link File Path}; may not be null. If the path does not * exist it will be created. * @param mode {@link RecordMode}; {@link RecordMode#READ_ONLY read-only} or * {@link RecordMode#READ_WRITE read/write} * @param columns {@link Column Columns} composing each record; may not be * null or have zero length * @throws InvalidArgument Thrown if {@code path} or {@code columns} is null * or invalid */ public RecordFile(final File path, final RecordMode mode, final Column<?>... columns) { // TODO clean up this constructor if (path == null) { throw new InvalidArgument("path", path); } else if (mode == null) { throw new InvalidArgument("mode", mode); } else if (columns == null) { throw new InvalidArgument("columns", columns); } else if (columns.length == 0) { throw new InvalidArgument("invalid number of columns"); } else if (!path.exists()) { if (mode == RecordMode.READ_ONLY) { // Read-only mode? final String fmt = "cannot read path: %s"; final String msg = format(fmt, path); throw new InvalidArgument(msg); } // Read-write mode, create a new file try { boolean b = path.createNewFile(); if (!b) { throw new IOException(); } } catch (IOException e) { final String fmt = "error creating: %s"; final String msg = format(fmt, path); throw new InvalidArgument(msg, e); } } else if (!path.canRead()) { final String fmt = "cannot read path: %s"; final String msg = format(fmt, path); throw new InvalidArgument(msg); } try { if (mode == RecordMode.READ_ONLY) { // Open a random access file in read-only mode this.raf = new RandomAccessFile(path, "r"); readonly = true; } else { // Open a random access file in read/write mode this.raf = new RandomAccessFile(path, "rw"); readonly = false; } } catch (FileNotFoundException e) { throw new InvalidArgument(e); } this.columns = columns; this.path = path.getPath(); int size = 0, i = 0; for (; i < columns.length; i++) { Column<?> c = columns[i]; if (c.size == 0 && i != (columns.length - 1)) { // only the last column's size may be zero final String msg = "only the last column size may be unknown"; throw new InvalidArgument(msg); } size += c.size; } Column<?> last = columns[i - 1]; if (last.size == 0) { final byte[] intbytes = new byte[4]; int read; try { read = raf.read(intbytes); } catch (IOException e) { final String msg = "failed to read record size"; throw new InvalidArgument(msg, e); } if (read != 4) { final String fmt = "read %d bytes, but expected %d"; final String msg = format(fmt, read, 4); throw new InvalidArgument(msg); } final ByteBuffer bytebuf = allocate(4); bytebuf.put(intbytes); bytebuf.rewind(); recordSize = bytebuf.getInt(); last.size = recordSize - size; } else { recordSize = size; } if (recordSize <= 0) { final String fmt = "invalid record size: %d"; final String msg = format(fmt, recordSize); throw new InvalidArgument(msg); } this.size = path.length(); if (this.size == 0) { if (readonly) { throw new UnsupportedOperationException(RO_MSG); } // Empty file - write metadata final ByteBuffer buffer = allocate(4); buffer.putInt(recordSize); byte[] array = buffer.array(); byte[] metadata = new byte[recordSize]; fill(metadata, (byte) 0); arraycopy(array, 0, metadata, 0, 4); try { raf.seek(0); raf.write(metadata); } catch (IOException e) { final String msg = "failed to initialize metadata"; throw new InvalidArgument(msg, e); } this.size = path.length(); } else if ((this.size % recordSize) != 0) { final String fmt = "file size %d: not a multiple of %d"; final String msg = format(fmt, this.size, recordSize); throw new InvalidArgument(msg); } recordCt = this.size / recordSize - 1; } /** * Reads a record from this record file. * * @param record The record to read * @return {@code byte[]} * @throws IOException Throw if an I/O error occurs during I/O seeks or * reads */ public byte[] read(long record) throws IOException { // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } raf.seek(pos); byte[] ret = new byte[recordSize]; int read = raf.read(ret); if (read != recordSize) { final String fmt = "read %d bytes, but expected %d"; final String msg = format(fmt, read, recordSize); throw new InvalidArgument(msg); } return ret; } /** * Reads a record from this record file into the provided byte buffer. * * @param record The record to read * @param bytes Byte buffer to read into * @throws IOException Throw if an I/O error occurs during I/O seeks or * reads */ public void read(long record, byte[] bytes) throws IOException { if (bytes.length != recordSize) { final String fmt = "invalid buffer length of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, recordSize); throw new InvalidArgument(msg); } // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } raf.seek(pos); int read = raf.read(bytes); if (read != recordSize) { final String fmt = "read %d bytes, but expected %d"; final String msg = format(fmt, read, recordSize); throw new InvalidArgument(msg); } } /** * Reads a record for a specific columns from this record file. * * @param record The record to read * @param column The column to read from * @return {@code byte[]} * @throws IOException Thrown if an I/O error occurs during I/O seeks or * read or an invalid number of bytes were read */ public byte[] read(long record, int column) throws IOException { // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; int i = 0; for (; i < column; i++) { pos += columns[i].size; } if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } Column<?> c = columns[i - 1]; raf.seek(pos); byte[] ret = new byte[c.size]; int read = raf.read(ret); if (read != c.size) { final String fmt = "read %d bytes, but expected %d"; final String msg = format(fmt, read, c.size); throw new IOException(msg); } return ret; } /** * Reads a record for a specific columns from this record file into the * provided byte buffer. * * @param record The record to read * @param column The column to read from * @param bytes Byte buffer to read into * @throws IOException Thrown if an I/O error occurs during I/O seeks or * read or an invalid number of bytes were read */ public void read(long record, int column, byte[] bytes) throws IOException { // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; int i = 0; for (; i < column; i++) { pos += columns[i].size; } if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } Column<?> c = columns[i - 1]; raf.seek(pos); if (bytes.length != c.size) { final String fmt = "invalid buffer length of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, c.size); throw new InvalidArgument(msg); } int read = raf.read(bytes); if (read != c.size) { final String fmt = "read %d bytes, but expected %d"; final String msg = format(fmt, read, c.size); throw new IOException(msg); } } /** * Writes a record to this record file, overwriting the previous contents. * * @param record The record being written * @param bytes The bytes to write * @throws IOException Thrown if an I/O error occurs during I/O seeks or * writes * @throws UnsupportedOperationException Thrown if the mode is set to * read-only * @throws InvalidArgument Thrown if a write of anything other than the size * of the record is requested */ public void write(long record, byte[] bytes) throws IOException { if (readonly) { throw new UnsupportedOperationException(RO_MSG); } if (bytes.length != recordSize) { final String fmt = "invalid write of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, recordSize); throw new InvalidArgument(msg); } // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } raf.seek(pos); raf.write(bytes); } /** * Writes a record for a specific column to this record file, overwriting * the previous contents. * * @param record The record being written * @param column The column to write * @param bytes The bytes to write * @throws IOException Thrown if an I/O error occurs during I/O seeks or * writes * @throws InvalidArgument Thrown if a write of anything other than the size * of the column is requested * @throws UnsupportedOperationException Thrown if the mode is set to * read-only */ public void write(long record, int column, byte[] bytes) throws IOException { if (readonly) { throw new UnsupportedOperationException(RO_MSG); } // (+ recordSize) allows skipping metadata record long pos = record * recordSize + recordSize; int i = 0; for (; i < column; i++) { pos += columns[i].size; } if (pos >= size) { final String fmt = "bad seek to position: %d (size is %d)"; final String msg = format(fmt, pos, size); throw new InvalidArgument(msg); } Column<?> c = columns[i]; if (bytes.length != c.size) { final String fmt = "invalid write of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, c.size); throw new InvalidArgument(msg); } raf.seek(pos); raf.write(bytes); } /** * Appends a record to this record file, recalculating the * {@link #getSize() size} and {@link #getRecordCount() record count}. * * @param bytes {@code byte[]} with length {@link #getRecordSize()} * @throws IOException Thrown if an I/O error occurs during write> * @throws InvalidArgument Thrown if the length of {@code bytes} is not * equal to the {@link #getRecordSize()} * @throws UnsupportedOperationException Thrown if the mode is set to * read-only */ public void append(byte[] bytes) throws IOException { if (readonly) { throw new UnsupportedOperationException(RO_MSG); } if (bytes.length != recordSize) { final String fmt = "invalid write of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, recordSize); throw new InvalidArgument(msg); } raf.write(bytes); // write succeeded - adjust size accordingly size += bytes.length; recordCt = size / recordSize - 1; } /** * Reads the metadata from this record file. * * @return {@code byte[]} * @throws IOException Throw if an I/O error occurs during I/O seeks or * reads */ public byte[] readMetadata() throws IOException { byte[] ret = read(0); return ret; } /** * Reads the metadata from this record file into the provided byte buffer. * * @param bytes Byte buffer to read into * @throws IOException Throw if an I/O error occurs during I/O seeks or * reads */ public void readMetadata(byte[] bytes) throws IOException { read(0, bytes); } /** * Writes metadata to this record file overwriting existing metadata. * * @param bytes The metadata bytes to write * @throws IOException Thrown if an I/O error occurs during I/O seeks or * writes * @throws InvalidArgument Thrown if a write of anything other than the size * of the record is requested * @throws UnsupportedOperationException Thrown if the mode is set to * read-only * @deprecated Explicitly writing the metadata record can cause * inconsistent {@link RecordFile record files} if used incorrectly. * Metadata for the {@link RecordFile record file} should only be written * on construction. * @see RecordFile#RecordFile(File, RecordMode, Column...) */ @Deprecated public void writeMetadata(byte[] bytes) throws IOException { if (readonly) { throw new UnsupportedOperationException(RO_MSG); } if (bytes.length != recordSize) { final String fmt = "invalid write of %d bytes, expected %d"; final String msg = format(fmt, bytes.length, recordSize); throw new InvalidArgument(msg); } raf.seek(0); raf.write(bytes); } /** * {@inheritDoc} */ @Override public Iterator<byte[]> iterator() { return new Iter(); } /** * Returns this record file's path. * * @return {@link String} */ public final String getPath() { return path; } /** * Returns a copy of the record file's columns. * * @return Array of {@link Column columns} */ public final Column<?>[] getColumns() { Column<?>[] ret = copyOf(columns, columns.length); return ret; } /** * Returns the size of the records. * * @return {@code int} */ public final int getRecordSize() { return recordSize; } /** * Returns {@code true} if the record file is operating in read-only mode, * {@code false} otherwise. * * @return {@code boolean} */ public final boolean isReadOnly() { return readonly; } /** * Returns {@code true} if the record file is operating in read/write mode, * {@code false} otherwise. * * @return {@code boolean} */ public final boolean isReadWrite() { return !readonly; } /** * Returns the number of records in the file. * * @return {@code long} */ public final long getRecordCount() { return recordCt; } /** * Returns the size of the record file, in bytes. * * @return {@code long} */ public final long getSize() { return size; } class Iter implements Iterator<byte[]> { private final long expectedRecordCt; private long currentRecord; /** * New {@link RecordFile} iterator. */ public Iter() { this.expectedRecordCt = recordCt; } /** * {@inheritDoc} */ @Override public boolean hasNext() { if (expectedRecordCt != recordCt) { throw new ConcurrentModificationException(); } if (currentRecord < recordCt) { return true; } return false; } /** * {@inheritDoc} */ @Override public byte[] next() { byte[] ret; try { ret = read(currentRecord); } catch (IOException e) { return null; } currentRecord += 1; return ret; } /** * Throws {@link UnsupportedOperationException}. */ @Override public void remove() { throw new UnsupportedOperationException(); } } }