Repository: pgjdbc/pgjdbc
Pull request #3369: fix: EOFException on PreparedStatement#toString with unset bytea parameter since 42.7.4
Author: login: MrEasy
Labels: name: bug
Created: 2024-08-29T13:46:06Z | Merged: 2025-05-21T15:05:58Z

Test case reproducing issue of #3365.
Fix by @kimjand  


Diff:
diff --git a/pgjdbc/src/main/java/org/postgresql/core/NativeQuery.java b/pgjdbc/src/main/java/org/postgresql/core/NativeQuery.java
index d26bd32d09..5383dd0484 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/NativeQuery.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/NativeQuery.java
@@ -6,6 +6,8 @@
 
 package org.postgresql.core;
 
+import org.postgresql.core.v3.SqlSerializationContext;
+
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /**
@@ -40,7 +42,7 @@ public NativeQuery(String nativeSql, int @Nullable [] bindPositions, boolean mul
   }
 
   /**
-   * Stringize this query to a human-readable form, substituting particular parameter values for
+   * Returns string representation of the query, substituting particular parameter values for
    * parameter placeholders.
    *
    * @param parameters a ParameterList returned by this Query's {@link Query#createParameterList}
@@ -48,6 +50,19 @@ public NativeQuery(String nativeSql, int @Nullable [] bindPositions, boolean mul
    * @return a human-readable representation of this query
    */
   public String toString(@Nullable ParameterList parameters) {
+    return toString(parameters, SqlSerializationContext.of(true, true));
+  }
+
+  /**
+   * Returns string representation of the query, substituting particular parameter values for
+   * parameter placeholders.
+   *
+   * @param parameters a ParameterList returned by this Query's {@link Query#createParameterList}
+   *        method, or {@code null} to leave the parameter placeholders unsubstituted.
+   * @param context specifies configuration for converting the parameters to string
+   * @return a human-readable representation of this query
+   */
+  public String toString(@Nullable ParameterList parameters, SqlSerializationContext context) {
     if (bindPositions.length == 0) {
       return nativeSql;
     }
@@ -55,7 +70,7 @@ public String toString(@Nullable ParameterList parameters) {
     int queryLength = nativeSql.length();
     String[] params = new String[bindPositions.length];
     for (int i = 1; i <= bindPositions.length; i++) {
-      String param = parameters == null ? "?" : parameters.toString(i, true);
+      String param = parameters == null ? "?" : parameters.toString(i, context);
       params[i - 1] = param;
       queryLength += param.length() - bindName(i).length();
     }
diff --git a/pgjdbc/src/main/java/org/postgresql/core/ParameterList.java b/pgjdbc/src/main/java/org/postgresql/core/ParameterList.java
index 8eb68d24c3..1881849709 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/ParameterList.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/ParameterList.java
@@ -6,6 +6,7 @@
 
 package org.postgresql.core;
 
+import org.postgresql.core.v3.SqlSerializationContext;
 import org.postgresql.util.ByteStreamWriter;
 
 import org.checkerframework.checker.index.qual.NonNegative;
@@ -189,7 +190,8 @@ void setBytea(@Positive int index, byte[] data,
 
   /**
    * Return a human-readable representation of a particular parameter in this ParameterList. If the
-   * parameter is not bound, returns "?".
+   * parameter is not bound or is of type bytea sourced from an InputStream, returns "?".
+   * This method will NOT consume InputStreams, instead "?" will be returned.
    *
    * @param index the 1-based parameter index to bind.
    * @param standardConformingStrings true if \ is not an escape character in strings literals
@@ -197,6 +199,17 @@ void setBytea(@Positive int index, byte[] data,
    */
   String toString(@Positive int index, boolean standardConformingStrings);
 
+  /**
+   * Return the string literal representation of a particular parameter in this ParameterList. If the
+   * parameter is not bound, returns "?".
+   * This method will consume all InputStreams to produce the result.
+   *
+   * @param index the 1-based parameter index to bind.
+   * @param context specifies configuration for converting the parameters to string
+   * @return a string representation of the parameter.
+   */
+  String toString(@Positive int index, SqlSerializationContext context);
+
   /**
    * Use this operation to append more parameters to the current list.
    * @param list of parameters to append with.
diff --git a/pgjdbc/src/main/java/org/postgresql/core/Query.java b/pgjdbc/src/main/java/org/postgresql/core/Query.java
index 5e583ca9fd..44a0157c36 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/Query.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/Query.java
@@ -6,6 +6,8 @@
 
 package org.postgresql.core;
 
+import org.postgresql.core.v3.SqlSerializationContext;
+
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 import java.util.Map;
@@ -32,15 +34,30 @@ public interface Query {
   ParameterList createParameterList();
 
   /**
-   * Stringize this query to a human-readable form, substituting particular parameter values for
+   * Returns string representation of the query, substituting particular parameter values for
    * parameter placeholders.
    *
+   * <p>Note: the method replaces the values on a best-effort basis as it might omit the replacements
+   * for parameters that can't be processed several times. For instance, {@link java.io.InputStream}
+   * can be processed only once.
+   *
    * @param parameters a ParameterList returned by this Query's {@link #createParameterList} method,
-   *        or <code>null</code> to leave the parameter placeholders unsubstituted.
-   * @return a human-readable representation of this query
+   *        or {@code null} to leave the parameter placeholders unsubstituted.
+   * @return string representation of this query
    */
   String toString(@Nullable ParameterList parameters);
 
+  /**
+   * Returns string representation of the query, substituting particular parameter values for
+   * parameter placeholders.
+   *
+   * @param parameters a ParameterList returned by this Query's {@link #createParameterList} method,
+   *        or {@code null} to leave the parameter placeholders unsubstituted.
+   * @param context specifies configuration for converting the parameters to string
+   * @return string representation of this query
+   */
+  String toString(@Nullable ParameterList parameters, SqlSerializationContext context);
+
   /**
    * Returns SQL in native for database format.
    * @return SQL in native for database format
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/BatchedQuery.java b/pgjdbc/src/main/java/org/postgresql/core/v3/BatchedQuery.java
index 4369ebdce3..cbdafdda8e 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/BatchedQuery.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/BatchedQuery.java
@@ -82,11 +82,11 @@ public String getNativeSql() {
     if (sql != null) {
       return sql;
     }
-    sql = buildNativeSql(null);
+    sql = buildNativeSql(null, DefaultSqlSerializationContext.STDSTR_IDEMPOTENT);
     return sql;
   }
 
-  private String buildNativeSql(@Nullable ParameterList params) {
+  private String buildNativeSql(@Nullable ParameterList params, SqlSerializationContext context) {
     String sql = null;
     // dynamically build sql with parameters for batches
     String nativeSql = super.getNativeSql();
@@ -158,7 +158,7 @@ private String buildNativeSql(@Nullable ParameterList params) {
         if (params == null) {
           NativeQuery.appendBindName(s, pos++);
         } else {
-          s.append(params.toString(pos++, true));
+          s.append(params.toString(pos++, context));
         }
         s.append(nativeSql, chunkStart[j], chunkEnd[j]);
       }
@@ -174,11 +174,11 @@ private String buildNativeSql(@Nullable ParameterList params) {
   }
 
   @Override
-  public String toString(@Nullable ParameterList params) {
+  public String toString(@Nullable ParameterList params, SqlSerializationContext context) {
     if (getBatchSize() < 2) {
-      return super.toString(params);
+      return super.toString(params, context);
     }
-    return buildNativeSql(params);
+    return buildNativeSql(params, context);
   }
 
 }
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeParameterList.java b/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeParameterList.java
index 6ae134a798..4bb46cad18 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeParameterList.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeParameterList.java
@@ -144,9 +144,14 @@ public void setNull(@Positive int index, int oid) throws SQLException {
 
   @Override
   public String toString(@Positive int index, boolean standardConformingStrings) {
+    return toString(index, SqlSerializationContext.of(standardConformingStrings, true));
+  }
+
+  @Override
+  public String toString(@Positive int index, SqlSerializationContext context) {
     try {
       int sub = findSubParam(index);
-      return subparams[sub].toString(index - offsets[sub], standardConformingStrings);
+      return subparams[sub].toString(index - offsets[sub], context);
     } catch (SQLException e) {
       throw new IllegalStateException(e.getMessage());
     }
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeQuery.java b/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeQuery.java
index 9a8a0f2daa..c2e7b84ce0 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeQuery.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/CompositeQuery.java
@@ -38,10 +38,18 @@ public ParameterList createParameterList() {
 
   @Override
   public String toString(@Nullable ParameterList parameters) {
-    StringBuilder sbuf = new StringBuilder(subqueries[0].toString());
+    return toString(parameters, DefaultSqlSerializationContext.STDSTR_IDEMPOTENT);
+  }
+
+  @Override
+  public String toString(@Nullable ParameterList parameters, SqlSerializationContext context) {
+    SimpleParameterList[] subparams =
+        parameters == null ? null : ((V3ParameterList) parameters).getSubparams();
+    StringBuilder sbuf = new StringBuilder(
+        subqueries[0].toString(subparams == null ? null : subparams[0], context));
     for (int i = 1; i < subqueries.length; i++) {
       sbuf.append(';');
-      sbuf.append(subqueries[i]);
+      sbuf.append(subqueries[i].toString(subparams == null ? null : subparams[i], context));
     }
     return sbuf.toString();
   }
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/DefaultSqlSerializationContext.java b/pgjdbc/src/main/java/org/postgresql/core/v3/DefaultSqlSerializationContext.java
new file mode 100644
index 0000000000..772b137ddb
--- /dev/null
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/DefaultSqlSerializationContext.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Provides a default implementation for {@link SqlSerializationContext}.
+ */
+enum DefaultSqlSerializationContext implements SqlSerializationContext {
+  /**
+   * Render SQL in a repeatable way (avoid consuming {@link java.io.InputStream} sources),
+   * use standard_conforming_strings=yes string literals.
+   * This option is useful for {@code toString()} implementations as it does induce side effects.
+   */
+  STDSTR_IDEMPOTENT(true, true),
+
+  /**
+   * Render SQL with replacing all the parameters, including {@link java.io.InputStream} sources.
+   * Use standard_conforming_strings=yes for string literals.
+   * This option is useful for rendering an executable SQL.
+   */
+  STDSTR_NONIDEMPOTENT(true, false),
+
+  // Auxiliary options as standard_conforming_strings=on since PostgreSQL 9.1
+
+  /**
+   * Render SQL in a repeatable way (avoid consuming {@link java.io.InputStream} sources),
+   * use standard_conforming_strings=no string literals.
+   * The entry is for completeness only as standard_conforming_strings=no should probably be avoided.
+   */
+  NONSTDSTR_IDEMPOTENT(false, true),
+
+  /**
+   * Render SQL with replacing all the parameters, including {@link java.io.InputStream} sources.
+   * Use standard_conforming_strings=no for string literals.
+   * The entry is for completeness only as standard_conforming_strings=no should probably be avoided.
+   */
+  NONSTDSTR_NONIDEMPOTENT(false, false),
+  ;
+
+  private final boolean standardConformingStrings;
+  private final boolean idempotent;
+
+  DefaultSqlSerializationContext(boolean standardConformingStrings, boolean idempotent) {
+    this.standardConformingStrings = standardConformingStrings;
+    this.idempotent = idempotent;
+  }
+
+  @Override
+  public boolean getStandardConformingStrings() {
+    return standardConformingStrings;
+  }
+
+  @Override
+  public boolean getIdempotent() {
+    return idempotent;
+  }
+}
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/QueryExecutorImpl.java b/pgjdbc/src/main/java/org/postgresql/core/v3/QueryExecutorImpl.java
index 974a2363a5..b123d847dd 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/QueryExecutorImpl.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/QueryExecutorImpl.java
@@ -1672,7 +1672,7 @@ private void sendBind(SimpleQuery query, SimpleParameterList params, @Nullable P
       StringBuilder sbuf = new StringBuilder(" FE=> Bind(stmt=" + statementName + ",portal=" + portal);
       for (int i = 1; i <= params.getParameterCount(); i++) {
         sbuf.append(",$").append(i).append("=<")
-            .append(params.toString(i, true))
+            .append(params.toString(i, getStandardConformingStrings()))
             .append(">,type=").append(Oid.toString(params.getTypeOID(i)));
       }
       sbuf.append(")");
@@ -2050,7 +2050,9 @@ private void sendOneQuery(SimpleQuery query, SimpleParameterList params, int max
   }
 
   private void sendSimpleQuery(SimpleQuery query, SimpleParameterList params) throws IOException {
-    String nativeSql = query.toString(params);
+    String nativeSql = query.toString(
+        params,
+        SqlSerializationContext.of(getStandardConformingStrings(), false));
 
     LOGGER.log(Level.FINEST, " FE=> SimpleQuery(query=\"{0}\")", nativeSql);
     Encoding encoding = pgStream.getEncoding();
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleParameterList.java b/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleParameterList.java
index 30528ee7f6..10a145b3c4 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleParameterList.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleParameterList.java
@@ -238,8 +238,12 @@ private static <E extends Throwable> RuntimeException sneakyThrow(Throwable e) t
   }
 
   @Override
-  @SuppressWarnings("type.arguments.not.inferred")
   public String toString(@Positive int index, boolean standardConformingStrings) {
+    return toString(index, SqlSerializationContext.of(standardConformingStrings, true));
+  }
+
+  @Override
+  public String toString(@Positive int index, SqlSerializationContext context) {
     --index;
     Object paramValue = paramValues[index];
     if (paramValue == null) {
@@ -251,7 +255,7 @@ public String toString(@Positive int index, boolean standardConformingStrings) {
     String type;
     if (paramTypes[index] == Oid.BYTEA) {
       try {
-        return PGbytea.toPGLiteral(paramValue);
+        return PGbytea.toPGLiteral(paramValue, context);
       } catch (Throwable e) {
         Throwable cause = e;
         if (!(cause instanceof IOException)) {
@@ -393,7 +397,7 @@ public String toString(@Positive int index, boolean standardConformingStrings) {
           type = null;
       }
     }
-    return quoteAndCast(textValue, type, standardConformingStrings);
+    return quoteAndCast(textValue, type, context.getStandardConformingStrings());
   }
 
   @Override
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleQuery.java b/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleQuery.java
index cd1832df86..c4f8daff7e 100644
--- a/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleQuery.java
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/SimpleQuery.java
@@ -55,7 +55,12 @@ public ParameterList createParameterList() {
 
   @Override
   public String toString(@Nullable ParameterList parameters) {
-    return nativeQuery.toString(parameters);
+    return toString(parameters, DefaultSqlSerializationContext.STDSTR_IDEMPOTENT);
+  }
+
+  @Override
+  public String toString(@Nullable ParameterList parameters, SqlSerializationContext context) {
+    return nativeQuery.toString(parameters, context);
   }
 
   @Override
diff --git a/pgjdbc/src/main/java/org/postgresql/core/v3/SqlSerializationContext.java b/pgjdbc/src/main/java/org/postgresql/core/v3/SqlSerializationContext.java
new file mode 100644
index 0000000000..96f9bf476a
--- /dev/null
+++ b/pgjdbc/src/main/java/org/postgresql/core/v3/SqlSerializationContext.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Specifies the properties required to convert SQL to String.
+ */
+public interface SqlSerializationContext {
+  /**
+   * Returns SqlSerializationContext instance with the given parameters
+   * @param standardConformingStrings true when string literals should be standard conforming
+   * @param idempotent true when idempotent conversion is needed
+   * @return Returns SqlSerializationContext instance with the given parameters
+   */
+  static SqlSerializationContext of(boolean standardConformingStrings, boolean idempotent) {
+    if (standardConformingStrings) {
+      return idempotent
+          ? DefaultSqlSerializationContext.STDSTR_IDEMPOTENT
+          : DefaultSqlSerializationContext.STDSTR_NONIDEMPOTENT;
+    }
+    return idempotent
+        ? DefaultSqlSerializationContext.NONSTDSTR_IDEMPOTENT
+        : DefaultSqlSerializationContext.NONSTDSTR_NONIDEMPOTENT;
+  }
+
+  /**
+   * Returns true if strings literals should use {@code standard_conforming_strings=on} encoding.
+   * @return true if strings literals should use {@code standard_conforming_strings=on} encoding.
+   */
+  boolean getStandardConformingStrings();
+
+  /**
+   * Returns true if the SQL to String conversion should be idempotent.
+   * For instance, if a query parameter comes from an {@link java.io.InputStream},
+   * then the stream could be skipped when writing SQL with idempotent mode.
+   * @return true if the SQL to String conversion should be idempotent
+   */
+  boolean getIdempotent();
+}
diff --git a/pgjdbc/src/main/java/org/postgresql/jdbc/PgPreparedStatement.java b/pgjdbc/src/main/java/org/postgresql/jdbc/PgPreparedStatement.java
index 980c2e4eaf..420ee922b2 100644
--- a/pgjdbc/src/main/java/org/postgresql/jdbc/PgPreparedStatement.java
+++ b/pgjdbc/src/main/java/org/postgresql/jdbc/PgPreparedStatement.java
@@ -75,7 +75,9 @@
 import java.time.OffsetTime;
 import java.util.ArrayList;
 import java.util.Calendar;
+import java.util.List;
 import java.util.Map;
+import java.util.StringJoiner;
 import java.util.TimeZone;
 import java.util.UUID;
 
@@ -1100,7 +1102,25 @@ public String toString() {
       return super.toString();
     }
 
-    return preparedQuery.query.toString(preparedParameters);
+    List<Query> batchStatements = this.batchStatements;
+    if (batchStatements == null || batchStatements.isEmpty()) {
+      return preparedQuery.query.toString(preparedParameters);
+    }
+    List<@Nullable ParameterList> batchParameters = this.batchParameters;
+    StringJoiner sj = new StringJoiner(";\n");
+    if (batchStatements.size() == 1 && batchParameters != null) {
+      // For rewritebatchinserts=true case, we have a single batch statement and multiple parameter rows
+      Query query = batchStatements.get(0);
+      for (ParameterList batchParameter : batchParameters) {
+        sj.add(query.toString(batchParameter));
+      }
+    } else {
+      for (int i = 0; i < batchStatements.size(); i++) {
+        Query statement = batchStatements.get(i);
+        sj.add(statement.toString(batchParameters == null ? null : batchParameters.get(i)));
+      }
+    }
+    return sj.toString();
   }
 
   /**
diff --git a/pgjdbc/src/main/java/org/postgresql/util/PGbytea.java b/pgjdbc/src/main/java/org/postgresql/util/PGbytea.java
index 1aef66f1c0..00243c6e31 100644
--- a/pgjdbc/src/main/java/org/postgresql/util/PGbytea.java
+++ b/pgjdbc/src/main/java/org/postgresql/util/PGbytea.java
@@ -6,6 +6,7 @@
 package org.postgresql.util;
 
 import org.postgresql.core.FixedLengthOutputStream;
+import org.postgresql.core.v3.SqlSerializationContext;
 
 import org.checkerframework.checker.nullness.qual.PolyNull;
 
@@ -163,8 +164,24 @@ public static void appendHexString(StringBuilder sb, byte[] buf, int offset, int
    * @param value input value to format
    * @return formatted value
    * @throws IOException in case there's underflow in the input value
+   * @deprecated prefer {@link #toPGLiteral(Object, SqlSerializationContext)} to clarify the behaviour
+   *     regarding {@link InputStream} objects
    */
+  @Deprecated
   public static String toPGLiteral(Object value) throws IOException {
+    return toPGLiteral(value, SqlSerializationContext.of(true, true));
+  }
+
+  /**
+   * Formats input object as {@code bytea} literal like {@code '\xcafebabe'::bytea}.
+   * The following inputs are supported: {@code byte[]}, {@link StreamWrapper}, and
+   * {@link ByteStreamWriter}.
+   * @param value input value to format
+   * @param context specifies configuration for converting the parameters to string
+   * @return formatted value
+   * @throws IOException in case there's underflow in the input value
+   */
+  public static String toPGLiteral(Object value, SqlSerializationContext context) throws IOException {
     if (value instanceof byte[]) {
       byte[] bytes = (byte[]) value;
       StringBuilder sb = new StringBuilder(bytes.length * 2 + 11);
@@ -176,10 +193,16 @@ public static String toPGLiteral(Object value) throws IOException {
 
     if (value instanceof StreamWrapper) {
       StreamWrapper sw = (StreamWrapper) value;
+      byte[] bytes = sw.getBytes();
+      if (context.getIdempotent() && bytes == null) {
+        // Note: we skip reading the stream wrapper only in case it wraps a stream
+        // If StreamWrapper wraps a byte[] instance, then it is fine to serialize it
+        return "?";
+      }
+
       int length = sw.getLength();
       StringBuilder sb = new StringBuilder(length * 2 + 11);
       sb.append("'\\x");
-      byte[] bytes = sw.getBytes();
       if (bytes != null) {
         appendHexString(sb, bytes, sw.getOffset(), length);
       } else if (length > 0) {
diff --git a/pgjdbc/src/test/java/org/postgresql/core/v3/adaptivefetch/AdaptiveFetchCacheTest.java b/pgjdbc/src/test/java/org/postgresql/core/v3/adaptivefetch/AdaptiveFetchCacheTest.java
index 42e2c2c0d5..315570e5e3 100644
--- a/pgjdbc/src/test/java/org/postgresql/core/v3/adaptivefetch/AdaptiveFetchCacheTest.java
+++ b/pgjdbc/src/test/java/org/postgresql/core/v3/adaptivefetch/AdaptiveFetchCacheTest.java
@@ -14,6 +14,7 @@
 import org.postgresql.core.ParameterList;
 import org.postgresql.core.Query;
 import org.postgresql.core.SqlCommand;
+import org.postgresql.core.v3.SqlSerializationContext;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -1028,6 +1029,11 @@ public ParameterList createParameterList() {
       throw new WrongMethodCallException("Method shouldn't be called.");
     }
 
+    @Override
+    public String toString(ParameterList parameters, SqlSerializationContext context) {
+      throw new WrongMethodCallException("Method shouldn't be called.");
+    }
+
     @Override
     public String toString(ParameterList parameters) {
       throw new WrongMethodCallException("Method shouldn't be called.");
diff --git a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/PreparedStatementTest.java b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/PreparedStatementTest.java
index b3bafc57a4..89435a273f 100644
--- a/pgjdbc/src/test/java/org/postgresql/test/jdbc2/PreparedStatementTest.java
+++ b/pgjdbc/src/test/java/org/postgresql/test/jdbc2/PreparedStatementTest.java
@@ -11,6 +11,7 @@
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 
 import org.postgresql.PGStatement;
 import org.postgresql.core.ServerVersion;
@@ -238,6 +239,173 @@ private void doSetAsciiStream(InputStream is, int length) throws SQLException {
     pstmt.close();
   }
 
+  @Test
+  public void ByteArrayInputStream_setBinaryStream_toString() throws SQLException {
+    try (PreparedStatement pstmt =
+             con.prepareStatement("INSERT INTO streamtable VALUES (?,?)")) {
+
+      byte[] buf = new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+      ByteArrayInputStream byteStream = new ByteArrayInputStream(buf);
+
+      pstmt.setBinaryStream(1, byteStream, buf.length);
+      assertEquals(
+          "InputStream parameter should come as ?, and the second parameter is unset, so it should be ? as well",
+          "INSERT INTO streamtable VALUES (?,?)",
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed with half-set parameters"));
+
+      pstmt.setString(2, "test");
+
+      String expected = "INSERT INTO streamtable VALUES (?,('test'))";
+      assertEquals(
+          "InputStream parameter should come as ? when calling PreparedStatement#toString as we can't process input stream twice yet",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed after setting parameters"));
+
+      assertEquals(
+          "InputStream parameter should come as ? when calling PreparedStatement#toString as we can't process input stream twice yet",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "Second PreparedStatement#toString call should succeed as well"));
+
+      pstmt.execute();
+
+      assertEquals(
+          "PreparedStatement#toString after .execute()",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed even after execute()"));
+    }
+  }
+
+  @Test
+  public void ByteArrayInputStream_setBinaryStream_addBatch_toString() throws SQLException {
+    try (PreparedStatement pstmt =
+             con.prepareStatement("INSERT INTO streamtable VALUES (?,?)")) {
+
+      pstmt.setBinaryStream(1, new ByteArrayInputStream(new byte[]{0, 1}), 2);
+      pstmt.setString(2, "line1");
+      pstmt.addBatch();
+      pstmt.setBinaryStream(1, new ByteArrayInputStream(new byte[]{0, 1, 2}), 3);
+      pstmt.setString(2, "line2");
+      pstmt.addBatch();
+
+      String expected = "INSERT INTO streamtable VALUES (?,('line1'));\n"
+          + "INSERT INTO streamtable VALUES (?,('line2'))";
+      assertEquals(
+          "InputStream parameter should come as ? when calling PreparedStatement#toString as we can't process input stream twice yet",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed after addBatch"));
+
+      assertEquals(
+          "InputStream parameter should come as ? when calling PreparedStatement#toString as we can't process input stream twice yet",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "Second PreparedStatement#toString call should succeed as well"));
+
+      pstmt.executeBatch();
+
+      assertEquals(
+          "PreparedStatement#toString after executeBatch() seem to equal to the latest parameter row",
+          "INSERT INTO streamtable VALUES (?,('line2'))",
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed even after executeBatch()"));
+    }
+  }
+
+  @Test
+  public void ByteArray_setBytes_toString() throws SQLException {
+    try (PreparedStatement pstmt =
+             con.prepareStatement("INSERT INTO streamtable VALUES (?,?)")) {
+
+      byte[] buf = new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
+
+      pstmt.setBytes(1, buf);
+
+      assertEquals(
+          "byte[] parameter could be rendered, and the second parameter is unset, so it should be ? as well",
+          "INSERT INTO streamtable VALUES ('\\x00010203040506070809'::bytea,?)",
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed with half-set parameters"));
+
+      pstmt.setString(2, "test");
+
+      String expected = "INSERT INTO streamtable VALUES ('\\x00010203040506070809'::bytea,('test'))";
+      assertEquals(
+          "byte[] should be rendered when calling PreparedStatement#toString",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed after setting parameters"));
+
+      assertEquals(
+          "byte[] should be rendered when calling PreparedStatement#toString",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "Second PreparedStatement#toString call should succeed as well"));
+
+      pstmt.execute();
+
+      assertEquals(
+          "PreparedStatement#toString after .execute()",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed even after execute()"));
+    }
+  }
+
+  @Test
+  public void ByteArray_setBytes_addBatch_toString() throws SQLException {
+    try (PreparedStatement pstmt =
+             con.prepareStatement("INSERT INTO streamtable VALUES (?,?)")) {
+
+      pstmt.setBytes(1, new byte[]{0, 1});
+      pstmt.setString(2, "line1");
+      pstmt.addBatch();
+
+      pstmt.setBytes(1, new byte[]{0, 1, 2});
+      pstmt.setString(2, "line2");
+      pstmt.addBatch();
+
+      String expected = "INSERT INTO streamtable VALUES ('\\x0001'::bytea,('line1'));\n"
+          + "INSERT INTO streamtable VALUES ('\\x000102'::bytea,('line2'))";
+      assertEquals(
+          "byte[] should be rendered when calling PreparedStatement#toString",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed after addBatch"));
+
+      assertEquals(
+          "byte[] should be rendered when calling PreparedStatement#toString",
+          expected,
+          assertDoesNotThrow(
+              pstmt::toString,
+              "Second PreparedStatement#toString call should succeed as well"));
+
+      pstmt.executeBatch();
+
+      assertEquals(
+          "PreparedStatement#toString after executeBatch() seem to equal to the latest parameter row",
+          "INSERT INTO streamtable VALUES ('\\x000102'::bytea,('line2'))",
+          assertDoesNotThrow(
+              pstmt::toString,
+              "PreparedStatement#toString call should succeed even after executeBatch()"));
+    }
+  }
+
   @Test
   public void testTrailingSpaces() throws SQLException {
     PreparedStatement pstmt =


Reviews:
user: login: vlsi
body: Could you keep the old method names and signatures?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2024-12-08T09:32:57Z
user: login: ecki
body: For me the change looks good (I would probably refactor all usages of tostring() as it’s confusing to me to have it carry transactional important and stateful logic but this way I guess it’s less overall change)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T20:23:19Z
user: login: vlsi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T07:57:44Z
user: login: vlsi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T16:59:55Z
user: login: davecramer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T14:09:07Z
user: login: davecramer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T14:11:54Z
user: login: davecramer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T14:11:57Z
user: login: davecramer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T14:20:58Z
user: login: vlsi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T12:33:44Z
user: login: vlsi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T12:47:27Z

Review comments:
diff_hunk: @@ -37,15 +37,28 @@ public ParameterList createParameterList() {
   }
 
   @Override
-  public String toString(@Nullable ParameterList parameters) {
+  public String toExecutableSql(@Nullable ParameterList parameters) {
+    return toExecutableSql(false);
+  }
+
+  private String toExecutableSql(boolean skipInputStream) {
     StringBuilder sbuf = new StringBuilder(subqueries[0].toString());
     for (int i = 1; i < subqueries.length; i++) {
       sbuf.append(';');
-      sbuf.append(subqueries[i]);
+      if (skipInputStream) {
+        sbuf.append(subqueries[i].toString());
+      } else {
+        sbuf.append(subqueries[i].toExecutableSql(null));
path: pgjdbc/src/main/java/org/postgresql/core/v3/CompositeQuery.java
user: login: vlsi
body: This still looks like a bug. The input `parameters` are not used at all. So `toString()` would be the same as `toExecutableSql()`, so there's no point in having `if` here.

created_at: 2025-05-15T07:57:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Specifies the properties required to convert SQL to String.
+ */
+public interface SqlSerializationContext {
path: pgjdbc/src/main/java/org/postgresql/core/v3/SqlSerializationContext.java
user: login: vlsi
body: @bokken , @sehrope , WDYT of this?
I'm not sure regarding the name, however, I like the interface approach better than two methods `toString` and `toExecutableSql`.
created_at: 2025-05-15T16:59:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Specifies the properties required to convert SQL to String.
+ */
+public interface SqlSerializationContext {
path: pgjdbc/src/main/java/org/postgresql/core/v3/SqlSerializationContext.java
user: login: davecramer
body: I like this idea
created_at: 2025-05-20T14:09:07Z
author_association: MEMBER
diff_hunk: @@ -32,15 +34,32 @@ public interface Query {
   ParameterList createParameterList();
 
   /**
-   * Stringize this query to a human-readable form, substituting particular parameter values for
+   * Returns string representation of the query, substituting particular parameter values for
    * parameter placeholders.
    *
+   * <p>Note: the method replaces the values on a best-effort basis as it might omit the replacements
+   * for parameters that can't be processed several times. For instance, {@link java.io.InputStream}
+   * can be processed only once.
+   *
    * @param parameters a ParameterList returned by this Query's {@link #createParameterList} method,
-   *        or <code>null</code> to leave the parameter placeholders unsubstituted.
-   * @return a human-readable representation of this query
+   *        or {@code null} to leave the parameter placeholders unsubstituted.
+   * @return string representation of this query
    */
   String toString(@Nullable ParameterList parameters);
 
+  /**
+   * Returns string representation of the query, substituting particular parameter values for
+   * parameter placeholders.
+   *
+   * <p>Note: the method will replace all the parameters, so it should not be used only once
path: pgjdbc/src/main/java/org/postgresql/core/Query.java
user: login: davecramer
body: `so it should not be used only once` seems wrong ... did you mean `so it should only be used once` ...
created_at: 2025-05-20T14:11:52Z
author_association: MEMBER
diff_hunk: @@ -32,15 +34,32 @@ public interface Query {
   ParameterList createParameterList();
 
   /**
-   * Stringize this query to a human-readable form, substituting particular parameter values for
+   * Returns string representation of the query, substituting particular parameter values for
    * parameter placeholders.
    *
+   * <p>Note: the method replaces the values on a best-effort basis as it might omit the replacements
+   * for parameters that can't be processed several times. For instance, {@link java.io.InputStream}
+   * can be processed only once.
+   *
    * @param parameters a ParameterList returned by this Query's {@link #createParameterList} method,
-   *        or <code>null</code> to leave the parameter placeholders unsubstituted.
-   * @return a human-readable representation of this query
+   *        or {@code null} to leave the parameter placeholders unsubstituted.
+   * @return string representation of this query
    */
   String toString(@Nullable ParameterList parameters);
 
+  /**
+   * Returns string representation of the query, substituting particular parameter values for
+   * parameter placeholders.
+   *
+   * <p>Note: the method will replace all the parameters, so it should not be used only once
path: pgjdbc/src/main/java/org/postgresql/core/Query.java
user: login: davecramer
body: `so it should not be used only once` seems wrong ... did you mean `so it should only be used once` ...
created_at: 2025-05-20T14:11:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Provides a default implementation for {@link SqlSerializationContext}.
+ */
+enum DefaultSqlSerializationContext implements SqlSerializationContext {
+  STDSTR_IDEMPOTENT(true, true),
path: pgjdbc/src/main/java/org/postgresql/core/v3/DefaultSqlSerializationContext.java
user: login: davecramer
body: I think it would be useful to document why and where each of these are used.
For instance we use IDEMPOTENT to send the query to the backend
STD is standard conforming strings which is a setting in the backend.
created_at: 2025-05-20T14:20:58Z
author_association: MEMBER
diff_hunk: @@ -32,15 +34,32 @@ public interface Query {
   ParameterList createParameterList();
 
   /**
-   * Stringize this query to a human-readable form, substituting particular parameter values for
+   * Returns string representation of the query, substituting particular parameter values for
    * parameter placeholders.
    *
+   * <p>Note: the method replaces the values on a best-effort basis as it might omit the replacements
+   * for parameters that can't be processed several times. For instance, {@link java.io.InputStream}
+   * can be processed only once.
+   *
    * @param parameters a ParameterList returned by this Query's {@link #createParameterList} method,
-   *        or <code>null</code> to leave the parameter placeholders unsubstituted.
-   * @return a human-readable representation of this query
+   *        or {@code null} to leave the parameter placeholders unsubstituted.
+   * @return string representation of this query
    */
   String toString(@Nullable ParameterList parameters);
 
+  /**
+   * Returns string representation of the query, substituting particular parameter values for
+   * parameter placeholders.
+   *
+   * <p>Note: the method will replace all the parameters, so it should not be used only once
path: pgjdbc/src/main/java/org/postgresql/core/Query.java
user: login: vlsi
body: Good catch. The doc was outdated as the behaviour is configurable via `SqlSerializationContext` parameter. I will drop the note and add `@param context specifies configuration for converting the parameters to string`
created_at: 2025-05-21T12:33:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025, PostgreSQL Global Development Group
+ * See the LICENSE file in the project root for more information.
+ */
+
+package org.postgresql.core.v3;
+
+/**
+ * Provides a default implementation for {@link SqlSerializationContext}.
+ */
+enum DefaultSqlSerializationContext implements SqlSerializationContext {
+  STDSTR_IDEMPOTENT(true, true),
path: pgjdbc/src/main/java/org/postgresql/core/v3/DefaultSqlSerializationContext.java
user: login: vlsi
body: I've added the doc to the enum entries
created_at: 2025-05-21T12:47:27Z
author_association: MEMBER

PR comments:
user: login: MrEasy
created_at: 2024-12-09T08:08:03Z
body: > Could you keep the old method names and signatures?

@kimjand added a method, that differs from existing one (see javadoc), not sure what you @vlsi mean with keeping the old names?
author_association: CONTRIBUTOR
user: login: davecramer
created_at: 2025-03-20T10:47:35Z
body: > > Could you keep the old method names and signatures?
> 
> @kimjand added a method, that differs from existing one (see javadoc), not sure what you @vlsi mean with keeping the old names?

Instead of using toStringLiteral() use toString()
author_association: MEMBER
user: login: MrEasy
created_at: 2025-03-20T13:32:34Z
body: > > > Could you keep the old method names and signatures?
> > 
> > 
> > @kimjand added a method, that differs from existing one (see javadoc), not sure what you @vlsi mean with keeping the old names?
> 
> Instead of using toStringLiteral() use toString()

From javadoc, I'd assume this was on purpose. @kimjand could you comment on this?

Would be great if we can get the fix into next release, as this blocks our update to recent versions. 
author_association: CONTRIBUTOR
user: login: kimjand
created_at: 2025-03-20T15:45:55Z
body: On 20-03-2025 14:32, Rico Neubauer wrote:
>             Could you keep the old method names and signatures?
> 
>         @kimjand <https://github.com/kimjand> added a method, that
>         differs from existing one (see javadoc), not sure what you @vlsi
>         <https://github.com/vlsi> mean with keeping the old names?
> 
>     Instead of using toStringLiteral() use toString()
> 
>  From javadoc, I'd assume this was on purpose. @kimjand <https:// 
> github.com/kimjand> could you comment on this?
> 

Yes, this was completely intentional. The core of this bug is that the 
driver can't decide on what the methods are supposed to do. ToString 
claims it provides human readable output, but the driver expects it to 
contain literals for the parameter values.
Now there is a clear distinction between the two cases.

author_association: CONTRIBUTOR
user: login: vlsi
created_at: 2025-04-24T20:29:02Z
body: I'm working on this. Please let me have a couple of days more
author_association: MEMBER
user: login: vlsi
created_at: 2025-05-15T14:32:19Z
body: Looks like for `toString` we need the following:

```java
@Nullable ParameterList params; // optional
boolean standardConformingStrings;
boolean idempotent; // == skipInputStreams
```

I wonder if we use an interface for it instead of (boolean, boolean) parameters?

Currently we have

```java
String toString(@Nullable ParameterList params, boolean standardConformingStrings);
String toExecutable(@Nullable ParameterList params, boolean standardConformingStrings);
```

What if we go for the following?

```java
interface SqlContext {
  boolean getStandardConformingStrings();
  boolean getIdempotent();
}

@Deprecated
String toString(@Nullable ParameterList params, boolean standardConformingStrings);

String toString(@Nullable ParameterList params, SqlContext context);
```

author_association: MEMBER
user: login: vlsi
created_at: 2025-05-16T12:03:38Z
body: I'm happy with the current state of the PR.
Reviews are welcome.
Will merge the PR in a couple of days.
author_association: MEMBER
user: login: davecramer
created_at: 2025-05-21T15:02:37Z
body: LGTM
author_association: MEMBER