From 7a2a52071fbdbab5c6fba7e3ce74c5441b8dc7c6 Mon Sep 17 00:00:00 2001 From: Mark Rotteveel Date: Fri, 20 Oct 2023 11:29:56 +0200 Subject: [PATCH] Avoid inefficient reads, and allow utilization of blob buffer larger than segment size on read --- .../org/firebirdsql/gds/ng/jna/JnaBlob.java | 14 +- src/docs/asciidoc/release_notes.adoc | 17 +- .../gds/ng/jna/JnaBlobInputTest.java | 4 +- .../firebirdsql/gds/ng/AbstractFbBlob.java | 41 +++- src/main/org/firebirdsql/gds/ng/FbBlob.java | 43 ++++ .../gds/ng/wire/AbstractFbWireOutputBlob.java | 2 +- .../gds/ng/wire/version10/V10InputBlob.java | 18 +- .../firebirdsql/jdbc/FBBlobInputStream.java | 119 ++++++--- .../firebirdsql/gds/ng/BaseTestInputBlob.java | 226 ++++++++++++------ .../wire/version10/V10InputBlobMockTest.java | 2 + .../wire/version10/V10OutputBlobMockTest.java | 2 + .../jdbc/FBBlobInputStreamTest.java | 95 +++++--- 12 files changed, 442 insertions(+), 141 deletions(-) diff --git a/jaybird-native/src/main/java/org/firebirdsql/gds/ng/jna/JnaBlob.java b/jaybird-native/src/main/java/org/firebirdsql/gds/ng/jna/JnaBlob.java index 87cdd0fc1..5fa16f484 100644 --- a/jaybird-native/src/main/java/org/firebirdsql/gds/ng/jna/JnaBlob.java +++ b/jaybird-native/src/main/java/org/firebirdsql/gds/ng/jna/JnaBlob.java @@ -28,11 +28,13 @@ import org.firebirdsql.gds.ng.FbExceptionBuilder; import org.firebirdsql.gds.ng.LockCloseable; import org.firebirdsql.gds.ng.listeners.DatabaseListener; +import org.firebirdsql.jdbc.SQLStateConstants; import org.firebirdsql.jna.fbclient.FbClientLibrary; import org.firebirdsql.jna.fbclient.ISC_STATUS; import java.nio.ByteBuffer; import java.sql.SQLException; +import java.sql.SQLNonTransientException; import static org.firebirdsql.gds.JaybirdErrorCodes.jb_blobGetSegmentNegative; import static org.firebirdsql.gds.JaybirdErrorCodes.jb_blobPutSegmentEmpty; @@ -195,16 +197,22 @@ private ByteBuffer getSegment0(int sizeRequested, ShortByReference actualLength) } @Override - public int get(final byte[] b, final int off, final int len) throws SQLException { + protected int get(final byte[] b, final int off, final int len, final int minLen) throws SQLException { try (LockCloseable ignored = withLock()) { validateBufferLength(b, off, len); if (len == 0) return 0; + if (minLen <= 0 || minLen > len ) { + throw new SQLNonTransientException( + "Value out of range 0 < minLen <= %d, minLen was: %d".formatted(len, minLen), + SQLStateConstants.SQL_STATE_INVALID_STRING_LENGTH); + } checkDatabaseAttached(); checkTransactionActive(); checkBlobOpen(); - ShortByReference actualLength = new ShortByReference(); + + var actualLength = new ShortByReference(); int count = 0; - while (count < len && !isEof()) { + while (count < minLen && !isEof()) { // We honor the configured buffer size unless we somehow already allocated a bigger buffer earlier ByteBuffer segmentBuffer = getSegment0( Math.min(len - count, Math.max(getBlobBufferSize(), currentBufferCapacity())), diff --git a/src/docs/asciidoc/release_notes.adoc b/src/docs/asciidoc/release_notes.adoc index 01e6c01b6..b4d606d08 100644 --- a/src/docs/asciidoc/release_notes.adoc +++ b/src/docs/asciidoc/release_notes.adoc @@ -820,14 +820,29 @@ For connections to Firebird 2.1 and higherfootnote:[Formally, only Firebird 3.0 The maximum segment size is the maximum size for sending segments (_put_) to the server. Due to protocol limitations, retrieving segments from the server (_get_) is two bytes (or multiples of two bytes) shorterfootnote:[For _get_ the maximum segment size is actually the maximum buffer size to receive one or more segments which are prefixed with two bytes for the length]. +[#blob-buffer-size] +==== Effectiveness of `blobBufferSize` larger than maximum segment size + +Previously, when reading blobs, a `blobBufferSize` larger than the maximum segment size was effectively ignored. +Now, when reading through an input stream, a `blobBufferSize` larger than the maximum segment size can be used. + +Jaybird will use one or more roundtrips to fill the buffer. +To avoid inefficient fetches, a minimum of 90% of the buffer size will be filled up to the `blobBufferSize`. +This change is not likely to improve performance, but it may allow for optimizations when reading or transferring data in large chunks. + +In general, setting the `blobBufferSize` larger than 65535 bytes will likely not improve performance. + [#blob-put-segment-limit] ==== Internal API changes for `FbBlob` Two new methods were added to `FbBlob`: -`int get(byte[] b, int off, int len)`:: populates the array `b`, starting at `off`, for the requested `len` bytes from the blob, and returning the actual read bytes. +`int get(byte[] b, int off, int len)`:: populates the array `b`, starting at `off`, for the requested `len` bytes from the blob, and returns the actual number of bytes read. This method will read until `len` bytes have been read, and only return less than `len` when end-of-blob was reached. +`int get(byte[] b, int off, int len, float minFillFactor)`:: populates the array `b`, starting at `off`, for + at least `minFillFactor` * `len` bytes (up to `len` bytes) from the blob, and returns the actual number of bytes read. + `void put(byte[] b, int off, int len)`:: sends data from array `b` to the blob, starting at `off`, for the requested `len` bytes. The documentation of method `FbBlob.putSegment(byte[])` contradicted itself, by requiring implementations to batch larger arrays, but also requiring them to throw an exception for larger arrays, and the actual implementations provided by Jaybird threw an exception. diff --git a/src/jna-test/org/firebirdsql/gds/ng/jna/JnaBlobInputTest.java b/src/jna-test/org/firebirdsql/gds/ng/jna/JnaBlobInputTest.java index e4a9929da..9f6961231 100644 --- a/src/jna-test/org/firebirdsql/gds/ng/jna/JnaBlobInputTest.java +++ b/src/jna-test/org/firebirdsql/gds/ng/jna/JnaBlobInputTest.java @@ -20,7 +20,7 @@ import org.firebirdsql.common.FBTestProperties; import org.firebirdsql.common.extension.GdsTypeExtension; -import org.firebirdsql.gds.ng.BaseTestBlob; +import org.firebirdsql.gds.ng.BaseTestInputBlob; import org.firebirdsql.gds.ng.FbConnectionProperties; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.extension.RegisterExtension; @@ -32,7 +32,7 @@ * * @author Mark Rotteveel */ -class JnaBlobInputTest extends BaseTestBlob { +class JnaBlobInputTest extends BaseTestInputBlob { @RegisterExtension @Order(1) diff --git a/src/main/org/firebirdsql/gds/ng/AbstractFbBlob.java b/src/main/org/firebirdsql/gds/ng/AbstractFbBlob.java index 7d67c3986..d70b8dc72 100644 --- a/src/main/org/firebirdsql/gds/ng/AbstractFbBlob.java +++ b/src/main/org/firebirdsql/gds/ng/AbstractFbBlob.java @@ -183,6 +183,42 @@ public void putSegment(byte[] segment) throws SQLException { put(segment, 0, segment.length); } + @Override + public final int get(byte[] b, int off, int len) throws SQLException { + // requested length is minimum length + return get(b, off, len, len); + } + + @Override + public final int get(byte[] b, int off, int len, float minFillFactor) throws SQLException { + if (minFillFactor <= 0f || minFillFactor > 1f || Float.isNaN(minFillFactor)) { + var invalidFloatFactor = new SQLNonTransientException( + "minFillFactor out of range, must be 0 < minFillFactor <= 1, was: " + minFillFactor); + exceptionListenerDispatcher.errorOccurred(invalidFloatFactor); + throw invalidFloatFactor; + } + return get(b, off, len, len != 0 ? Math.max(1, (int) (minFillFactor * len)) : 0); + } + + /** + * Default implementation for {@link #get(byte[], int, int)} and {@link #get(byte[], int, int, float)}. + * + * @param b + * target byte array + * @param off + * offset to start + * @param len + * number of bytes + * @param minLen + * minimum number of bytes to fill (must be {@code 0 < minLen <= len} if {@code len != 0} + * @return actual number of bytes read; is {@code 0} if {@code len == 0}, will only be less than {@code minLen} if + * end-of-blob is reached + * @throws SQLException + * for database access errors, if {@code off < 0}, {@code len < 0}, or if {@code off + len > b.length}, + * or {@code len != 0 && (minLen <= 0 || minLen > len)} + */ + protected abstract int get(byte[] b, int off, int len, int minLen) throws SQLException; + /** * Release Java resources held. This should not communicate with the Firebird server. */ @@ -416,9 +452,8 @@ public int getMaximumSegmentSize() { private static int maximumSegmentSize(FbDatabase db) { // Max size in FB 2.1 and higher is 2^16 - 1, not 2^15 - 3 (IB 6 docs mention max is 32KiB) - if (db != null && (db.getOdsMajor() > 11 || db.getOdsMajor() == 11 && db.getOdsMinor() >= 1)) { - /* ODS 11.1 is Firebird 2.1 - NOTE: getSegment can retrieve at most 65533 bytes of blob data as the buffer to receive segments is + if (db != null && db.getServerVersion().isEqualOrAbove(2, 1)) { + /* NOTE: getSegment can retrieve at most 65533 bytes of blob data as the buffer to receive segments is max 65535 bytes, but the contents of the buffer are one or more segments prefixed with 2-byte lengths; putSegment can write max 65535 bytes, because the buffer *is* the segment */ return 65535; diff --git a/src/main/org/firebirdsql/gds/ng/FbBlob.java b/src/main/org/firebirdsql/gds/ng/FbBlob.java index 0dde15b55..fe458da50 100644 --- a/src/main/org/firebirdsql/gds/ng/FbBlob.java +++ b/src/main/org/firebirdsql/gds/ng/FbBlob.java @@ -142,6 +142,13 @@ public interface FbBlob extends ExceptionListenable, AutoCloseable { * Contrary to similar methods like {@link java.io.InputStream#read(byte[], int, int)}, this method returns * {@code 0} when no bytes were read if end-of-blob is reached without reading any bytes, not {@code -1}. *

+ *

+ * Given this method attempts to fulfill the entire request for {@code len} bytes, it may not always be efficient. + * For example, requests near multiples of the maximum segment size (or blob buffer size) may result in a final + * request for just a few bytes. This is not a problem if the entire blob is requested at once, but for intermediate + * buffering it might be better not to do that final request, and instead work with a smaller number of bytes than + * requested. For those cases, use {@link #get(byte[], int, int, float)}. + *

* * @param b * target byte array @@ -156,6 +163,42 @@ public interface FbBlob extends ExceptionListenable, AutoCloseable { */ int get(byte[] b, int off, int len) throws SQLException; + /** + * Variant of {@link #get(byte[], int, int)} to exert some control over the number of requests performed. + *

+ * This method will request segments until at least {@code (int) (minFillFactor * len)} bytes have been retrieved, + * or end-of-blob is reached. This method is intended as an alternative to {@link #get(byte[], int, int)} where + * avoiding the potential inefficiencies of that method are preferred over getting all the requested {@code len} + * bytes. + *

+ *

+ * If the implementation cannot perform reads without additional allocation, it should use at most + * {@link DatabaseConnectionProperties#getBlobBufferSize()} as an internal buffer. If the implementation can + * perform reads without additional allocation, it is recommended it performs reads using (at most) + * {@link #getMaximumSegmentSize()}. + *

+ *

+ * Contrary to similar methods like {@link java.io.InputStream#read(byte[], int, int)}, this method returns + * {@code 0} when no bytes were read if end-of-blob is reached without reading any bytes, not {@code -1}. + *

+ * + * @param b + * target byte array + * @param off + * offset to start + * @param len + * number of bytes + * @param minFillFactor + * minimum fill factor ({@code 0 < minFillFactor <= 1}) + * @return actual number of bytes read, this method returns at least {@code (int) (minFillFactor * len)} bytes, + * unless end-of-blob is reached + * @throws SQLException + * for database access errors, if {@code off < 0}, {@code len < 0}, or if {@code off + len > b.length}, + * {@code minFillFactor <= 0}, or {@code minFillFactor > 1} or {@code minFillFactor is NaN} + * @since 6 + */ + int get(byte[] b, int off, int len, float minFillFactor) throws SQLException; + /** * Writes a segment of blob data. *

diff --git a/src/main/org/firebirdsql/gds/ng/wire/AbstractFbWireOutputBlob.java b/src/main/org/firebirdsql/gds/ng/wire/AbstractFbWireOutputBlob.java index 5f1f12c37..d15841756 100644 --- a/src/main/org/firebirdsql/gds/ng/wire/AbstractFbWireOutputBlob.java +++ b/src/main/org/firebirdsql/gds/ng/wire/AbstractFbWireOutputBlob.java @@ -81,7 +81,7 @@ private void readNotSupported() throws SQLException { } @Override - public int get(byte[] b, int off, int len) throws SQLException { + protected final int get(byte[] b, int off, int len, int minLen) throws SQLException { readNotSupported(); return -1; } diff --git a/src/main/org/firebirdsql/gds/ng/wire/version10/V10InputBlob.java b/src/main/org/firebirdsql/gds/ng/wire/version10/V10InputBlob.java index 338ad956a..7b5b085a4 100644 --- a/src/main/org/firebirdsql/gds/ng/wire/version10/V10InputBlob.java +++ b/src/main/org/firebirdsql/gds/ng/wire/version10/V10InputBlob.java @@ -26,9 +26,11 @@ import org.firebirdsql.gds.ng.LockCloseable; import org.firebirdsql.gds.ng.listeners.DatabaseListener; import org.firebirdsql.gds.ng.wire.*; +import org.firebirdsql.jdbc.SQLStateConstants; import java.io.IOException; import java.sql.SQLException; +import java.sql.SQLNonTransientException; import java.sql.SQLWarning; import static org.firebirdsql.gds.JaybirdErrorCodes.jb_blobGetSegmentNegative; @@ -177,29 +179,35 @@ protected void sendGetSegment(int len) throws SQLException, IOException { } @Override - public int get(final byte[] b, final int off, final int len) throws SQLException { + protected int get(final byte[] b, final int off, final int len, final int minLen) throws SQLException { try (LockCloseable ignored = withLock()) { validateBufferLength(b, off, len); + if (len == 0) return 0; + if (minLen <= 0 || minLen > len ) { + throw new SQLNonTransientException("Value out of range 0 < minLen <= len, minLen was: " + minLen, + SQLStateConstants.SQL_STATE_INVALID_STRING_LENGTH); + } checkDatabaseAttached(); checkTransactionActive(); checkBlobOpen(); + final FbWireOperations wireOps = getDatabase().getWireOperations(); + final XdrOutputStream xdrOut = getXdrOut(); + final XdrInputStream xdrIn = getXdrIn(); int count = 0; - while (count < len && !isEof()) { + while (count < minLen && !isEof()) { try { sendGetSegment(segmentRequestSize(len - count)); - getXdrOut().flush(); + xdrOut.flush(); } catch (IOException e) { throw FbExceptionBuilder.ioWriteError(e); } try { - FbWireOperations wireOps = getDatabase().getWireOperations(); final int opCode = wireOps.readNextOperation(); if (opCode != op_response) { wireOps.readOperationResponse(opCode, null); throw new SQLException("Unexpected response to op_get_segment: " + opCode); } - XdrInputStream xdrIn = getXdrIn(); final int objHandle = xdrIn.readInt(); xdrIn.skipNBytes(8); // blob-id (unused) diff --git a/src/main/org/firebirdsql/jdbc/FBBlobInputStream.java b/src/main/org/firebirdsql/jdbc/FBBlobInputStream.java index b5224f523..e4434a23e 100644 --- a/src/main/org/firebirdsql/jdbc/FBBlobInputStream.java +++ b/src/main/org/firebirdsql/jdbc/FBBlobInputStream.java @@ -24,6 +24,7 @@ import java.io.EOFException; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.sql.SQLException; import java.util.Objects; @@ -36,23 +37,18 @@ public final class FBBlobInputStream extends InputStream implements FirebirdBlob private byte[] buffer = EMPTY_BUFFER; private FbBlob blobHandle; - private int pos = 0; - + private int pos; + private int lim; private boolean closed; private final FBBlob owner; FBBlobInputStream(FBBlob owner) throws SQLException { if (owner.isNew()) { - throw new SQLException("You can't read a new blob", SQLStateConstants.SQL_STATE_LOCATOR_EXCEPTION); + throw new SQLException("Cannot read a new blob", SQLStateConstants.SQL_STATE_LOCATOR_EXCEPTION); } - this.owner = owner; - closed = false; - - try (LockCloseable ignored = owner.withLock()) { - blobHandle = owner.openBlob(); - } + blobHandle = owner.openBlob(); } @Override @@ -91,34 +87,47 @@ public long length() throws IOException { @Override public int available() throws IOException { - return buffer.length - pos; + return lim - pos; } /** * Checks the available buffer size, retrieving a segment from the server if necessary. * - * @return The number of bytes available in the buffer, or {@code -1} if the end of the stream is reached. + * @return the number of bytes available in the buffer, or {@code -1} if the end of the stream is reached. * @throws IOException * if an I/O error occurs, or if the stream has been closed. */ private int checkBuffer() throws IOException { try (LockCloseable ignored = owner.withLock()) { checkClosed(); - if (pos < buffer.length) { - return buffer.length - pos; - } - if (blobHandle.isEof()) { - return -1; + if (pos < lim) { + return lim - pos; } + if (blobHandle.isEof()) return -1; - buffer = blobHandle.getSegment(owner.getBufferLength()); + byte[] buffer = requireBuffer(); + lim = blobHandle.get(buffer, 0, buffer.length, 0.9f); pos = 0; - return buffer.length != 0 ? buffer.length : -1; - } catch (SQLException ge) { - throw new IOException("Blob read problem: " + ge, ge); + return lim > 0 ? lim : -1; + } catch (SQLException e) { + if (e.getCause() instanceof IOException ioe) { + throw ioe; + } + throw new IOException("Blob read problem: " + e, e); } } + /** + * @return buffer with length equal to {@code owner.getBufferLength()}. + */ + private byte[] requireBuffer() { + byte[] buffer = this.buffer; + if (buffer.length > 0) { + return buffer; + } + return this.buffer = new byte[owner.getBufferLength()]; + } + @Override public int read() throws IOException { if (checkBuffer() == -1) { @@ -140,9 +149,8 @@ public int read(final byte[] b, int off, final int len) throws IOException { if (avail == -1) { return -1; } - int count = 0; - if (avail > 0) { - count = Math.min(avail, len); + int count = Math.min(avail, len); + if (count > 0) { System.arraycopy(buffer, this.pos, b, off, count); this.pos += count; if (len - count < smallBufferLimit) { @@ -152,15 +160,37 @@ public int read(final byte[] b, int off, final int len) throws IOException { } if (count < len) { - count += blobHandle.get(b, off + count, len - count); + count += blobHandle.get(b, off + count, len - count, 0.9f); } // When we haven't read anything, report end-of-blob return count == 0 ? -1 : count; - } catch (SQLException ge) { - if (ge.getCause() instanceof IOException ioe) { + } catch (SQLException e) { + if (e.getCause() instanceof IOException ioe) { throw ioe; } - throw new IOException("Blob read problem: " + ge, ge); + throw new IOException("Blob read problem: " + e, e); + } + } + + @Override + public int readNBytes(final byte[] b, final int off, final int len) throws IOException { + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) return 0; + + try (LockCloseable ignored = owner.withLock()) { + checkClosed(); + final int count = Math.min(available(), len); + if (count > 0) { + System.arraycopy(buffer, pos, b, off, count); + pos += count; + if (count == len) return len; + } + return count + blobHandle.get(b, off + count, len - count); + } catch (SQLException e) { + if (e.getCause() instanceof IOException ioe) { + throw ioe; + } + throw new IOException("Blob read problem: " + e, e); } } @@ -178,6 +208,36 @@ public void readFully(byte[] b) throws IOException { readFully(b, 0, b.length); } + @Override + public long transferTo(final OutputStream out) throws IOException { + Objects.requireNonNull(out, "out"); + + try (LockCloseable ignored = owner.withLock()) { + checkClosed(); + int read = checkBuffer(); + if (read == -1) return 0; + final byte[] buffer = requireBuffer(); + if (read != 0) { + out.write(buffer, pos, read); + pos = lim = 0; + } + try { + long transferred = read; + while (!blobHandle.isEof()) { + read = blobHandle.get(buffer, 0, buffer.length, 0.9f); + out.write(buffer, 0, read); + transferred += read; + } + return transferred; + } catch (SQLException e) { + if (e.getCause() instanceof IOException ioe) { + throw ioe; + } + throw new IOException("Blob read problem: " + e, e); + } + } + } + @Override public void close() throws IOException { try (LockCloseable ignored = owner.withLock()) { @@ -187,13 +247,14 @@ public void close() throws IOException { try { blobHandle.close(); owner.notifyClosed(this); - } catch (SQLException ge) { - throw new IOException("couldn't close blob: " + ge); + } catch (SQLException e) { + throw new IOException("Couldn't close blob: " + e, e); } finally { blobHandle = null; closed = true; buffer = EMPTY_BUFFER; pos = 0; + lim = 0; } } } diff --git a/src/test/org/firebirdsql/gds/ng/BaseTestInputBlob.java b/src/test/org/firebirdsql/gds/ng/BaseTestInputBlob.java index 2a834df2c..cf2b5cf7a 100644 --- a/src/test/org/firebirdsql/gds/ng/BaseTestInputBlob.java +++ b/src/test/org/firebirdsql/gds/ng/BaseTestInputBlob.java @@ -35,6 +35,9 @@ import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.startsWith; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -47,6 +50,11 @@ */ public abstract class BaseTestInputBlob extends BaseTestBlob { + /** + * Blob size sufficiently large that multiple segments are used. + */ + protected static final int MULTI_SEGMENT_SIZE = 4 * Short.MAX_VALUE; + /** * Tests retrieval of a blob (what goes in is what comes out). */ @@ -55,24 +63,23 @@ public abstract class BaseTestInputBlob extends BaseTestBlob { public void testBlobRetrieval(boolean useStreamBlobs) throws Exception { final int testId = 1; final byte[] baseContent = generateBaseContent(); - // Use sufficiently large value so that multiple segments are used - final int requiredSize = 4 * Short.MAX_VALUE; - populateBlob(testId, baseContent, requiredSize, useStreamBlobs); + populateBlob(testId, baseContent, MULTI_SEGMENT_SIZE, useStreamBlobs); try (FbDatabase db = createDatabaseConnection()) { try { long blobId = getBlobId(testId, db); - final FbBlob blob = db.createBlobForInput(transaction, blobId); - blob.open(); - var bos = new ByteArrayOutputStream(requiredSize); - while (!blob.isEof()) { - bos.write(blob.getSegment(blob.getMaximumSegmentSize())); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + var bos = new ByteArrayOutputStream(MULTI_SEGMENT_SIZE); + while (!blob.isEof()) { + bos.write(blob.getSegment(blob.getMaximumSegmentSize())); + } + byte[] result = bos.toByteArray(); + assertBlobContent(result, baseContent, MULTI_SEGMENT_SIZE); + } finally { + statement.close(); } - blob.close(); - statement.close(); - byte[] result = bos.toByteArray(); - assertBlobContent(result, baseContent, requiredSize); } finally { if (transaction != null) transaction.commit(); } @@ -87,21 +94,102 @@ public void testBlobRetrieval(boolean useStreamBlobs) throws Exception { public void testBlobGet(boolean useStreamBlobs) throws Exception { final int testId = 1; final byte[] baseContent = generateBaseContent(); - // Use sufficiently large value so that multiple roundtrips are used - final int requiredSize = 4 * Short.MAX_VALUE; - populateBlob(testId, baseContent, requiredSize, useStreamBlobs); + populateBlob(testId, baseContent, MULTI_SEGMENT_SIZE, useStreamBlobs); try (FbDatabase db = createDatabaseConnection()) { try { long blobId = getBlobId(testId, db); - FbBlob blob = db.createBlobForInput(transaction, blobId); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + byte[] result = new byte[MULTI_SEGMENT_SIZE]; + assertEquals(MULTI_SEGMENT_SIZE, blob.get(result, 0, MULTI_SEGMENT_SIZE)); + assertBlobContent(result, baseContent, MULTI_SEGMENT_SIZE); + } finally { + statement.close(); + } + } finally { + if (transaction != null) transaction.commit(); + } + } + } + + @Test + public void testBlobGet_minFillFactor_outOfRange() throws Exception { + try (FbDatabase db = createDatabaseConnection()) { + FbTransaction transaction = getTransaction(db); + try (FbBlob blob = db.createBlobForInput(transaction, 0)) { blob.open(); - byte[] result = new byte[requiredSize]; - blob.get(result, 0, requiredSize); - blob.close(); - statement.close(); - assertBlobContent(result, baseContent, requiredSize); + + byte[] segment = new byte[1]; + assertAll( + () -> assertThrows(SQLNonTransientException.class, () -> blob.get(segment, 0, 1, 0f)), + () -> assertThrows(SQLNonTransientException.class, + () -> blob.get(segment, 0, 1, 0f - Math.ulp(0f))), + () -> assertThrows(SQLNonTransientException.class, + () -> blob.get(segment, 0, 1, 1f + Math.ulp(1f)))); + } finally { + transaction.commit(); + } + } + } + + @Test + public void testBlobGet_minFillFactor_inRange() throws Exception { + final int testId = 1; + populateStreamBlob(testId, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, 10); + try (FbDatabase db = createDatabaseConnection()) { + long blobId = getBlobId(testId, db); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + byte[] segment = new byte[1]; + assertEquals(0, blob.get(segment, 0, 0, 0f + Math.ulp(0f)), + "fetch with length 0 should allow minFillFactor just greater than 0f"); + assertEquals(0, blob.get(segment, 0, 0, 1f), "fetch with length 0 should allow minFillFactor = 1f"); + assertEquals(1, blob.get(segment, 0, 1, 0f + Math.ulp(0f)), + "fetch with length 1 should allow minFillFactor just greater than 0f"); + assertEquals(1, segment[0]); + assertEquals(1, blob.get(segment, 0, 1, 1f), "fetch with length 1 should allow minFillFactor = 1f"); + assertEquals(2, segment[0]); + segment = new byte[8]; + assertEquals(8, blob.get(segment, 0, 8, 0.1f), + "fetch with length 8 and minFillFactor = 0.1f should fetch remainder"); + assertArrayEquals(new byte[] { 3, 4, 5, 6, 7, 8, 9, 10 }, segment); + } finally { + transaction.commit(); + } + } + } + + /** + * Tests retrieval of a blob (what goes in is what comes out). + */ + @ParameterizedTest + @ValueSource(booleans = { true, false }) + public void testBlobGet_withMinFillFactor(boolean useStreamBlobs) throws Exception { + final int testId = 1; + final byte[] baseContent = generateBaseContent(); + populateBlob(testId, baseContent, MULTI_SEGMENT_SIZE, useStreamBlobs); + + try (FbDatabase db = createDatabaseConnection()) { + try { + long blobId = getBlobId(testId, db); + + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + final int maximumSegmentSize = blob.getMaximumSegmentSize(); + byte[] result = new byte[(int) (maximumSegmentSize * 1.05f)]; + int readBytes = blob.get(result, 0, result.length, 0.9f); + assertThat(readBytes, allOf( + greaterThanOrEqualTo((int) (0.9f * result.length)), + /* NOTE: for pure Java, the max segment size is the determining factor, but for native it is + (multiples of) blobBufferSize. Fudging it a bit, so the result works both for pure Java and + native (with blobBufferSize=16384) */ + lessThanOrEqualTo(maximumSegmentSize + 1))); + assertBlobContent(Arrays.copyOf(result, readBytes), baseContent, readBytes); + } finally { + statement.close(); + } } finally { if (transaction != null) transaction.commit(); } @@ -115,24 +203,23 @@ public void testBlobGet(boolean useStreamBlobs) throws Exception { public void testBlobSeek_segmented() throws Exception { final int testId = 1; final byte[] baseContent = generateBaseContent(); - // Use sufficiently large value so that multiple segments are used - final int requiredSize = 4 * Short.MAX_VALUE; - populateSegmentedBlob(testId, baseContent, requiredSize); + populateSegmentedBlob(testId, baseContent, MULTI_SEGMENT_SIZE); try (FbDatabase db = createDatabaseConnection()) { try { long blobId = getBlobId(testId, db); // NOTE: What matters is if the blob on the server is stream or segment - final FbBlob blob = db.createBlobForInput(transaction, blobId); - blob.open(); - int offset = baseContent.length / 2; + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + int offset = baseContent.length / 2; - SQLException exception = assertThrows(SQLException.class, - () -> blob.seek(offset, FbBlob.SeekMode.ABSOLUTE)); - assertThat(exception, allOf( - errorCodeEquals(ISCConstants.isc_bad_segstr_type), - message(startsWith(getFbMessage(ISCConstants.isc_bad_segstr_type))))); + SQLException exception = assertThrows(SQLException.class, + () -> blob.seek(offset, FbBlob.SeekMode.ABSOLUTE)); + assertThat(exception, allOf( + errorCodeEquals(ISCConstants.isc_bad_segstr_type), + message(startsWith(getFbMessage(ISCConstants.isc_bad_segstr_type))))); + } } finally { if (transaction != null) transaction.commit(); } @@ -146,7 +233,6 @@ public void testBlobSeek_segmented() throws Exception { public void testBlobSeek_streamed() throws Exception { final int testId = 1; final byte[] baseContent = generateBaseContent(); - // Use sufficiently large value so that multiple segments are used final int requiredSize = 200; populateStreamBlob(testId, baseContent, requiredSize); @@ -155,18 +241,18 @@ public void testBlobSeek_streamed() throws Exception { long blobId = getBlobId(testId, db); // NOTE: What matters is if the blob on the server is stream or segment - final FbBlob blob = db.createBlobForInput(transaction, blobId); - blob.open(); - final int offset = requiredSize / 2; - - blob.seek(offset, FbBlob.SeekMode.ABSOLUTE); - byte[] segment = blob.getSegment(100); - byte[] expected = Arrays.copyOfRange(baseContent, offset, offset + 100); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + final int offset = requiredSize / 2; + blob.seek(offset, FbBlob.SeekMode.ABSOLUTE); + byte[] segment = blob.getSegment(100); + assertEquals(100, segment.length, "Unexpected length read from blob"); + assertArrayEquals(Arrays.copyOfRange(baseContent, offset, offset + 100), segment, + "Unexpected segment content"); + } finally { + statement.close(); + } - blob.close(); - statement.close(); - assertEquals(100, segment.length, "Unexpected length read from blob"); - assertArrayEquals(expected, segment, "Unexpected segment content"); } finally { if (transaction != null) transaction.commit(); } @@ -188,24 +274,24 @@ public void testReopen() throws Exception { try { long blobId = getBlobId(testId, db); - final FbBlob blob = db.createBlobForInput(transaction, blobId); - blob.open(); - ByteArrayOutputStream bos = new ByteArrayOutputStream(requiredSize); - while (!blob.isEof()) { - bos.write(blob.getSegment(blob.getMaximumSegmentSize())); - } - blob.close(); - // Reopen - blob.open(); - bos = new ByteArrayOutputStream(requiredSize); - while (!blob.isEof()) { - bos.write(blob.getSegment(blob.getMaximumSegmentSize())); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + ByteArrayOutputStream bos = new ByteArrayOutputStream(requiredSize); + while (!blob.isEof()) { + bos.write(blob.getSegment(blob.getMaximumSegmentSize())); + } + blob.close(); + // Reopen + blob.open(); + bos = new ByteArrayOutputStream(requiredSize); + while (!blob.isEof()) { + bos.write(blob.getSegment(blob.getMaximumSegmentSize())); + } + byte[] result = bos.toByteArray(); + assertBlobContent(result, baseContent, requiredSize); + } finally { + statement.close(); } - blob.close(); - - statement.close(); - byte[] result = bos.toByteArray(); - assertBlobContent(result, baseContent, requiredSize); } finally { if (transaction != null) transaction.commit(); } @@ -226,13 +312,14 @@ public void testDoubleOpen() throws Exception { try { long blobId = getBlobId(testId, db); - final FbBlob blob = db.createBlobForInput(transaction, blobId); - blob.open(); - // Double open - SQLException exception = assertThrows(SQLNonTransientException.class, blob::open); - assertThat(exception, allOf( - errorCodeEquals(ISCConstants.isc_no_segstr_close), - fbMessageStartsWith(ISCConstants.isc_no_segstr_close))); + try (FbBlob blob = db.createBlobForInput(transaction, blobId)) { + blob.open(); + // Double open + SQLException exception = assertThrows(SQLNonTransientException.class, blob::open); + assertThat(exception, allOf( + errorCodeEquals(ISCConstants.isc_no_segstr_close), + fbMessageStartsWith(ISCConstants.isc_no_segstr_close))); + } } finally { if (transaction != null) transaction.commit(); } @@ -243,8 +330,7 @@ public void testDoubleOpen() throws Exception { public void readBlobIdZero() throws Exception { try (FbDatabase db = createDatabaseConnection()) { FbTransaction transaction = getTransaction(db); - try { - FbBlob blob = db.createBlobForInput(transaction, 0); + try (FbBlob blob = db.createBlobForInput(transaction, 0)) { blob.open(); assertEquals(0, blob.length()); byte[] segment = blob.getSegment(500); diff --git a/src/test/org/firebirdsql/gds/ng/wire/version10/V10InputBlobMockTest.java b/src/test/org/firebirdsql/gds/ng/wire/version10/V10InputBlobMockTest.java index 69d0b14dd..4f2cb5c64 100644 --- a/src/test/org/firebirdsql/gds/ng/wire/version10/V10InputBlobMockTest.java +++ b/src/test/org/firebirdsql/gds/ng/wire/version10/V10InputBlobMockTest.java @@ -19,6 +19,7 @@ package org.firebirdsql.gds.ng.wire.version10; import org.firebirdsql.gds.ISCConstants; +import org.firebirdsql.gds.impl.GDSServerVersion; import org.firebirdsql.gds.ng.LockCloseable; import org.firebirdsql.gds.ng.TransactionState; import org.firebirdsql.gds.ng.wire.FbWireDatabase; @@ -58,6 +59,7 @@ class V10InputBlobMockTest { @BeforeEach void setUp() { lenient().when(db.withLock()).thenReturn(LockCloseable.NO_OP); + lenient().when(db.getServerVersion()).thenReturn(GDSServerVersion.INVALID_VERSION); } /** diff --git a/src/test/org/firebirdsql/gds/ng/wire/version10/V10OutputBlobMockTest.java b/src/test/org/firebirdsql/gds/ng/wire/version10/V10OutputBlobMockTest.java index 86cbdfb30..907994b3e 100644 --- a/src/test/org/firebirdsql/gds/ng/wire/version10/V10OutputBlobMockTest.java +++ b/src/test/org/firebirdsql/gds/ng/wire/version10/V10OutputBlobMockTest.java @@ -19,6 +19,7 @@ package org.firebirdsql.gds.ng.wire.version10; import org.firebirdsql.gds.ISCConstants; +import org.firebirdsql.gds.impl.GDSServerVersion; import org.firebirdsql.gds.ng.FbBlob; import org.firebirdsql.gds.ng.LockCloseable; import org.firebirdsql.gds.ng.wire.FbWireDatabase; @@ -57,6 +58,7 @@ class V10OutputBlobMockTest { @BeforeEach void setUp() { lenient().when(db.withLock()).thenReturn(LockCloseable.NO_OP); + lenient().when(db.getServerVersion()).thenReturn(GDSServerVersion.INVALID_VERSION); } /** diff --git a/src/test/org/firebirdsql/jdbc/FBBlobInputStreamTest.java b/src/test/org/firebirdsql/jdbc/FBBlobInputStreamTest.java index ae201c3df..9aabd5aeb 100644 --- a/src/test/org/firebirdsql/jdbc/FBBlobInputStreamTest.java +++ b/src/test/org/firebirdsql/jdbc/FBBlobInputStreamTest.java @@ -26,9 +26,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import java.io.ByteArrayOutputStream; import java.io.EOFException; +import java.io.InputStream; import java.sql.*; import java.util.Arrays; import java.util.Properties; @@ -51,11 +54,13 @@ */ class FBBlobInputStreamTest { - private static final String CREATE_TABLE = - "CREATE TABLE test_blob(" + - " id INTEGER, " + - " bin_data BLOB " + - ")"; + private static final int MULTI_SEGMENT_LENGTH = 128 * 1024; + + private static final String CREATE_TABLE = """ + CREATE TABLE test_blob( + id INTEGER, + bin_data BLOB + )"""; @RegisterExtension static final UsesDatabaseExtension.UsesDatabaseForAll useDatabase = UsesDatabaseExtension.usesDatabaseForAll( @@ -103,7 +108,7 @@ void testNewBlob_throwSQLE() throws Exception { Blob blob = connection.createBlob(); SQLException exception = assertThrows(SQLException.class, blob::getBinaryStream); - assertThat(exception, message(equalTo("You can't read a new blob"))); + assertThat(exception, message(equalTo("Cannot read a new blob"))); } @Test @@ -115,7 +120,7 @@ void testAvailable_noReads_returns0() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); assertEquals(0, is.available(), "Available() without initial read should return 0"); } @@ -132,7 +137,7 @@ void testAvailable_singleRead_returnsRemaining() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); assertEquals(1, is.read(), "Expected first blob value of 1"); @@ -174,7 +179,7 @@ void testAvailable_singleReadClosed_returns0() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); assertEquals(1, is.read(), "Expected first blob value of 1"); is.close(); @@ -191,7 +196,7 @@ void testRead_byteArr_moreThanAvailable_returnsAvailable(boolean useStreamBlobs) connection.close(); connection = getConnection(false); } - byte[] bytes = DataGenerator.createRandomBytes(128 * 1024); + byte[] bytes = DataGenerator.createRandomBytes(MULTI_SEGMENT_LENGTH); populateBlob(1, bytes); try (PreparedStatement pstmt = connection.prepareStatement(SELECT_BLOB)) { @@ -199,7 +204,7 @@ void testRead_byteArr_moreThanAvailable_returnsAvailable(boolean useStreamBlobs) try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); assertEquals(bytes[0] & 0xFF, is.read(), "Unexpected first byte"); int available = is.available(); @@ -243,8 +248,7 @@ void testRead_byteArr_moreThanAvailable_returnsAll(boolean useStreamBlobs) throw connection.close(); connection = getConnection(false); } - final int testBlobSize = 128 * 1024; - final byte[] bytes = DataGenerator.createRandomBytes(testBlobSize); + final byte[] bytes = DataGenerator.createRandomBytes(MULTI_SEGMENT_LENGTH); populateBlob(1, bytes); try (PreparedStatement pstmt = connection.prepareStatement(SELECT_BLOB)) { @@ -252,7 +256,7 @@ void testRead_byteArr_moreThanAvailable_returnsAll(boolean useStreamBlobs) throw try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); int blobBufferSize = getConnectionBlobBufferSize(); assertEquals(bytes[0] & 0xFF, is.read(), "Unexpected first byte"); @@ -260,10 +264,10 @@ void testRead_byteArr_moreThanAvailable_returnsAll(boolean useStreamBlobs) throw assertThat("Value of available() should be greater than 0 but less than blobBufferSize", available, allOf(greaterThan(0), lessThan(blobBufferSize))); - byte[] buffer = new byte[testBlobSize]; + byte[] buffer = new byte[MULTI_SEGMENT_LENGTH]; buffer[0] = bytes[0]; - assertEquals(testBlobSize - 1, is.read(buffer, 1, testBlobSize - 1), + assertEquals(MULTI_SEGMENT_LENGTH - 1, is.read(buffer, 1, MULTI_SEGMENT_LENGTH - 1), "Expected remaining bytes to be read"); assertArrayEquals(bytes, buffer, "Expected identical bytes to be returned"); } @@ -280,7 +284,7 @@ void testRead_byteArr_length0_returns0() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); byte[] buffer = new byte[5]; int bytesRead = is.read(buffer, 0, 0); @@ -301,7 +305,7 @@ void testRead_byteArrNull_throwsNPE() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); //noinspection DataFlowIssue assertThrows(NullPointerException.class, () -> is.read(null, 0, 1)); @@ -319,7 +323,7 @@ void testRead_negativeOffset_throwsIOBE() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); byte[] buffer = new byte[5]; assertThrows(IndexOutOfBoundsException.class, () -> is.read(buffer, -1, 1)); @@ -337,7 +341,7 @@ void testRead_negativeLength_throwsIOBE() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); byte[] buffer = new byte[5]; assertThrows(IndexOutOfBoundsException.class, () -> is.read(buffer, 0, -1)); @@ -355,7 +359,7 @@ void testRead_offsetBeyondLength_throwsIOBE() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); byte[] buffer = new byte[5]; assertThrows(IndexOutOfBoundsException.class, () -> is.read(buffer, 5, 1)); @@ -373,7 +377,7 @@ void testRead_offsetAndLengthBeyondLength_throwsIOBE() throws Exception { try (ResultSet rs = pstmt.executeQuery()) { assertTrue(rs.next(), "Expected a row"); Blob blob = rs.getBlob(1); - FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); + InputStream is = blob.getBinaryStream(); byte[] buffer = new byte[5]; assertThrows(IndexOutOfBoundsException.class, () -> is.read(buffer, 0, 6)); @@ -383,8 +387,7 @@ void testRead_offsetAndLengthBeyondLength_throwsIOBE() throws Exception { @Test void testReadFully_byteArr_moreThanAvailable_returnsAllRead() throws Exception { - final int testBlobSize = 128 * 1024; - final byte[] bytes = DataGenerator.createRandomBytes(testBlobSize); + final byte[] bytes = DataGenerator.createRandomBytes(MULTI_SEGMENT_LENGTH); populateBlob(1, bytes); try (PreparedStatement pstmt = connection.prepareStatement(SELECT_BLOB)) { @@ -394,16 +397,16 @@ void testReadFully_byteArr_moreThanAvailable_returnsAllRead() throws Exception { Blob blob = rs.getBlob(1); FBBlobInputStream is = (FBBlobInputStream) blob.getBinaryStream(); - byte[] buffer = new byte[testBlobSize]; + byte[] buffer = new byte[MULTI_SEGMENT_LENGTH]; int firstValue = is.read(); assertEquals(bytes[0] & 0xFF, firstValue, "Unexpected first byte"); buffer[0] = (byte) firstValue; final int available = is.available(); assertThat("Value of available() should be smaller than 128 * 1024 - 1", - available, lessThan(testBlobSize - 1)); + available, lessThan(MULTI_SEGMENT_LENGTH - 1)); - is.readFully(buffer, 1, testBlobSize - 1); + is.readFully(buffer, 1, MULTI_SEGMENT_LENGTH - 1); assertArrayEquals(bytes, buffer, "Full blob should have been read"); } @@ -536,6 +539,44 @@ void testReadFully_bufferLongerThanBlob_throwsEOFException() throws Exception { } } + @ParameterizedTest + @CsvSource(useHeadersInDisplayName = true, textBlock = """ + useStreamBlobs, startWithSingleRead + true, false + true, true + false, false + false, true + """) + void testTransferTo(boolean useStreamBlobs, boolean startWithSingleRead) throws Exception { + if (!useStreamBlobs) { + connection.close(); + connection = getConnection(false); + } + final byte[] bytes = DataGenerator.createRandomBytes(MULTI_SEGMENT_LENGTH); + populateBlob(1, bytes); + + try (var pstmt = connection.prepareStatement(SELECT_BLOB)) { + pstmt.setInt(1, 1); + try (var rs = pstmt.executeQuery()) { + assertTrue(rs.next(), "Expected a row"); + Blob blob = rs.getBlob(1); + InputStream is = blob.getBinaryStream(); + var baos = new ByteArrayOutputStream(bytes.length); + if (startWithSingleRead) { + // Using a single read will populate the buffer of the FBBlobInputStream. This verifies that we + // take the current buffer content into consideration when transferring + int read = is.read(); + assertEquals(bytes[0] & 0xFF, read, "Unexpected first byte"); + baos.write(read); + } + assertEquals(startWithSingleRead ? MULTI_SEGMENT_LENGTH - 1 : MULTI_SEGMENT_LENGTH, is.transferTo(baos), + "Unexpected number of bytes transferred"); + + assertArrayEquals(bytes, baos.toByteArray(), "Expected identical bytes to be returned"); + } + } + } + @SuppressWarnings("SameParameterValue") private void populateBlob(int id, byte[] bytes) throws SQLException { try (PreparedStatement insert = connection.prepareStatement(INSERT_BLOB)) {