Repository: GoogleCloudDataproc/spark-bigquery-connector
Pull request #1349: Add parameterized queries support as new Spark options
Author: login: BoqianShi
Created: 2025-04-02T21:52:10Z | Merged: 2025-04-17T22:48:02Z

This PR introduces new Spark options (`NamedParameters.<name>`, `PositionalParameters.<index>`) for specifying BigQuery parameterized query arguments, stated in [issues/506](https://github.com/GoogleCloudDataproc/spark-bigquery-connector/issues/506).

Parameters use the `"TYPE:value"` string format (e.g., `"STRING:value"`, `"INT64:123"`). Added validation prevents mixing named/positional parameters and checks format, supported types (explicitly disallowing `ARRAY`/`STRUCT` for now), and indices. Includes updated unit and integration tests.


Diff:
diff --git a/README-template.md b/README-template.md
index 6b0266880d..70363ddd60 100644
--- a/README-template.md
+++ b/README-template.md
@@ -242,6 +242,32 @@ In order to use this feature the `viewsEnabled` configurations MUST be set to
 saving the result into a temporary table, of which Spark will read the results
 from. This may add additional costs on your BigQuery account.
 
+### Reading From Parameterized Queries
+
+The connector supports executing [BigQuery parameterized queries](https://cloud.google.com/bigquery/docs/parameterized-queries) using the
+standard `spark.read.format('bigquery')` API.
+
+To use parameterized queries:
+
+1. Provide the SQL query containing parameters using the
+   `.option("query", "SQL_STRING")` with named (`@param`) or positional (`?`) parameters.
+2. Specify the parameter values using dedicated options:
+  * **Named Parameters:** Use options prefixed with `NamedParameters.`. The
+    parameter name follows the prefix (case-insensitive).
+    * Format: `.option("NamedParameters.<parameter_name>", "TYPE:value")`
+    * Example: `.option("NamedParameters.corpus", "STRING:romeoandjuliet")`
+  * **Positional Parameters:** Use options prefixed with
+    `PositionalParameters.`. The 1-based index follows the prefix.
+    * Format:
+      `.option("PositionalParameters.<parameter_index>", "TYPE:value")`
+    * Example: `.option("PositionalParameters.1", "STRING:romeoandjuliet")`
+
+The `TYPE` in the `TYPE:value` string specifies the BigQuery Standard SQL data
+type. Supported types currently include: `BOOL`, `INT64`, `FLOAT64`, `NUMERIC`,
+`STRING`, `DATE`, `DATETIME`, `JSON`, `TIME`, `GEOGRAPHY`, `TIMESTAMP`.
+
+`ARRAY` and `STRUCT` types are not supported as parameters at this time.
+
 ### Reading From Views
 
 The connector has a preliminary support for reading from
diff --git a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java
index 6f031362ca..976b29326c 100644
--- a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java
+++ b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java
@@ -404,7 +404,7 @@ public TableInfo getReadTable(ReadTableOptions options) {
       // in this case, let's materialize it and use it as the table
       validateViewsEnabled(options);
       String sql = query.get();
-      return materializeQueryToTable(sql);
+      return materializeQueryToTable(sql, options.getQueryParameterHelper());
     }
 
     TableInfo table = getTable(options.tableId());
@@ -446,7 +446,7 @@ public Schema getReadTableSchema(ReadTableOptions options) {
     if (query.isPresent()) {
       validateViewsEnabled(options);
       String sql = query.get();
-      return getQueryResultSchema(sql, Collections.emptyMap());
+      return getQueryResultSchema(sql, Collections.emptyMap(), options.getQueryParameterHelper());
     }
     TableInfo table = getReadTable(options);
     return table != null ? table.getDefinition().getSchema() : null;
@@ -634,8 +634,9 @@ private long getNumberOfRows(String sql) {
    * @param querySql the query to be run
    * @return a reference to the table
    */
-  public TableInfo materializeQueryToTable(String querySql) {
-    return materializeTable(querySql);
+  public TableInfo materializeQueryToTable(
+      String querySql, QueryParameterHelper queryParameterHelper) {
+    return materializeTable(querySql, queryParameterHelper);
   }
 
   /**
@@ -646,9 +647,16 @@ public TableInfo materializeQueryToTable(String querySql) {
    * @return a reference to the table
    */
   public TableInfo materializeQueryToTable(
-      String querySql, Map<String, String> additionalQueryJobLabels) {
+      String querySql,
+      Map<String, String> additionalQueryJobLabels,
+      QueryParameterHelper queryParameterHelper) {
     TempTableBuilder tableBuilder =
-        new TempTableBuilder(this, querySql, jobConfigurationFactory, additionalQueryJobLabels);
+        new TempTableBuilder(
+            this,
+            querySql,
+            jobConfigurationFactory,
+            additionalQueryJobLabels,
+            queryParameterHelper);
 
     return materializeTable(querySql, tableBuilder);
   }
@@ -661,15 +669,18 @@ public TableInfo materializeQueryToTable(
    * @return a reference to the table
    */
   public TableInfo materializeViewToTable(String querySql, TableId viewId) {
-    return materializeTable(querySql);
+    return materializeTable(querySql, QueryParameterHelper.none());
   }
 
   public Schema getQueryResultSchema(
-      String querySql, Map<String, String> additionalQueryJobLabels) {
+      String querySql,
+      Map<String, String> additionalQueryJobLabels,
+      QueryParameterHelper queryParameterHelper) {
     JobInfo jobInfo =
         JobInfo.of(
             jobConfigurationFactory
-                .createQueryJobConfigurationBuilder(querySql, additionalQueryJobLabels)
+                .createParameterizedQueryJobConfigurationBuilder(
+                    querySql, additionalQueryJobLabels, queryParameterHelper)
                 .setDryRun(true)
                 .build());
 
@@ -682,11 +693,16 @@ public Schema getQueryResultSchema(
     return queryStatistics.getSchema();
   }
 
-  private TableInfo materializeTable(String querySql) {
+  private TableInfo materializeTable(String querySql, QueryParameterHelper queryParameterHelper) {
     try {
       return destinationTableCache.get(
           querySql,
-          new TempTableBuilder(this, querySql, jobConfigurationFactory, Collections.emptyMap()));
+          new TempTableBuilder(
+              this,
+              querySql,
+              jobConfigurationFactory,
+              Collections.emptyMap(),
+              queryParameterHelper));
     } catch (Exception e) {
       throw new BigQueryConnectorException(
           BigQueryErrorCode.BIGQUERY_VIEW_DESTINATION_TABLE_CREATION_FAILED,
@@ -843,6 +859,8 @@ public interface ReadTableOptions {
     boolean viewsEnabled();
 
     String viewEnabledParamName();
+
+    QueryParameterHelper getQueryParameterHelper();
   }
 
   public interface LoadDataOptions {
@@ -917,16 +935,19 @@ static class TempTableBuilder implements Callable<TableInfo> {
     final String querySql;
     final JobConfigurationFactory jobConfigurationFactory;
     final Map<String, String> additionalQueryJobLabels;
+    final QueryParameterHelper queryParameterHelper;
 
     TempTableBuilder(
         BigQueryClient bigQueryClient,
         String querySql,
         JobConfigurationFactory jobConfigurationFactory,
-        Map<String, String> additionalQueryJobLabels) {
+        Map<String, String> additionalQueryJobLabels,
+        QueryParameterHelper queryParameterHelper) {
       this.bigQueryClient = bigQueryClient;
       this.querySql = querySql;
       this.jobConfigurationFactory = jobConfigurationFactory;
       this.additionalQueryJobLabels = additionalQueryJobLabels;
+      this.queryParameterHelper = queryParameterHelper;
     }
 
     @Override
@@ -936,11 +957,11 @@ public TableInfo call() {
 
     TableInfo createTableFromQuery() {
       log.info("Materializing the query into a temporary table");
-      JobInfo jobInfo =
-          JobInfo.of(
-              jobConfigurationFactory
-                  .createQueryJobConfigurationBuilder(querySql, additionalQueryJobLabels)
-                  .build());
+      QueryJobConfiguration.Builder queryJobConfigurationBuilder =
+          jobConfigurationFactory.createParameterizedQueryJobConfigurationBuilder(
+              querySql, additionalQueryJobLabels, queryParameterHelper);
+
+      JobInfo jobInfo = JobInfo.of(queryJobConfigurationBuilder.build());
 
       log.info("running query [{}]", querySql);
       JobInfo completedJobInfo = bigQueryClient.waitForJob(bigQueryClient.create(jobInfo));
@@ -988,8 +1009,20 @@ public JobConfigurationFactory(Map<String, String> labels, Priority queryJobPrio
 
     QueryJobConfiguration.Builder createQueryJobConfigurationBuilder(
         String querySql, Map<String, String> additionalQueryJobLabels) {
+      return createParameterizedQueryJobConfigurationBuilder(
+          querySql, additionalQueryJobLabels, QueryParameterHelper.none());
+    }
+
+    QueryJobConfiguration.Builder createParameterizedQueryJobConfigurationBuilder(
+        String querySql,
+        Map<String, String> additionalQueryJobLabels,
+        QueryParameterHelper queryParameterHelper) {
+
       QueryJobConfiguration.Builder builder =
           QueryJobConfiguration.newBuilder(querySql).setPriority(queryJobPriority);
+
+      queryParameterHelper.configureBuilder(builder);
+
       Map<String, String> allLabels = new HashMap<>(additionalQueryJobLabels);
 
       if (labels != null && !labels.isEmpty()) {
diff --git a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
index 2f140b218d..3cb75c3a6d 100644
--- a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
+++ b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
@@ -31,8 +31,10 @@
 import com.google.cloud.bigquery.FieldList;
 import com.google.cloud.bigquery.HivePartitioningOptions;
 import com.google.cloud.bigquery.LegacySQLTypeName;
+import com.google.cloud.bigquery.QueryParameterValue;
 import com.google.cloud.bigquery.RangePartitioning;
 import com.google.cloud.bigquery.Schema;
+import com.google.cloud.bigquery.StandardSQLTypeName;
 import com.google.cloud.bigquery.StandardTableDefinition;
 import com.google.cloud.bigquery.TableDefinition;
 import com.google.cloud.bigquery.TableId;
@@ -42,6 +44,7 @@
 import com.google.cloud.bigquery.storage.v1.ReadStream;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Objects;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -59,9 +62,12 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
+import java.util.TreeMap;
 import java.util.function.Function;
 import java.util.function.IntFunction;
 import java.util.regex.Matcher;
@@ -93,6 +99,9 @@ public class BigQueryUtil {
   // These confuse the qualified table parsing.
   private static final String TABLE_PATTERN = "[\\S&&[^.:]]+";
 
+  private static final String NAMED_PARAM_PREFIX = "namedparameters.";
+  private static final String POSITIONAL_PARAM_PREFIX = "positionalparameters.";
+
   /**
    * Regex for an optionally fully qualified table.
    *
@@ -882,4 +891,194 @@ public static boolean isBigQueryNativeTable(TableInfo table) {
     return table.getDefinition().getType() == TableDefinition.Type.TABLE
         && !isBigLakeManagedTable(table);
   }
+
+  /**
+   * Parses query parameters from Spark options based on NamedParameters.* and
+   * PositionalParameters.* prefixes.
+   *
+   * @param options A map containing Spark DataSource options.
+   * @return A QueryParameterHelper object containing either named or positional parameters, or an
+   *     empty state (ParameterMode.NONE).
+   * @throws IllegalArgumentException if both named and positional parameters are specified, if
+   *     parameter format is invalid, type is unknown/unsupported, value parsing fails, positional
+   *     index is invalid/missing/duplicated, or positional parameters are not contiguous from 1.
+   */
+  public static QueryParameterHelper parseQueryParameters(Map<String, String> options) {
+    Map<String, QueryParameterValue> namedParameters = new HashMap<>();
+    Map<Integer, QueryParameterValue> positionalParametersTemp = new TreeMap<>();
+    ParameterMode currentMode = ParameterMode.NONE;
+
+    for (Map.Entry<String, String> entry : options.entrySet()) {
+      String key = entry.getKey();
+      String keyLower = key.toLowerCase(Locale.ROOT);
+      String value = entry.getValue();
+
+      if (keyLower.startsWith(NAMED_PARAM_PREFIX)) {
+        Preconditions.checkArgument(
+            currentMode == ParameterMode.NAMED || currentMode == ParameterMode.NONE,
+            "Cannot mix NamedParameters.* and PositionalParameters.* options.");
+        currentMode = ParameterMode.NAMED;
+
+        processNamedParameter(key, value, namedParameters);
+      } else if (keyLower.startsWith(POSITIONAL_PARAM_PREFIX)) {
+        Preconditions.checkArgument(
+            currentMode == ParameterMode.POSITIONAL || currentMode == ParameterMode.NONE,
+            "Cannot mix NamedParameters.* and PositionalParameters.* options.");
+        currentMode = ParameterMode.POSITIONAL;
+        processPositionalParameter(key, value, positionalParametersTemp);
+      }
+    }
+
+    switch (currentMode) {
+      case NAMED:
+        return QueryParameterHelper.named(namedParameters);
+      case POSITIONAL:
+        List<QueryParameterValue> positionalList =
+            buildPositionalParameterList(positionalParametersTemp);
+        return QueryParameterHelper.positional(positionalList);
+      case NONE:
+      default:
+        return QueryParameterHelper.none();
+    }
+  }
+
+  /**
+   * Processes a single named parameter option, adding it to the map. Performs validation checks
+   * specific to named parameters.
+   */
+  private static void processNamedParameter(
+      String key, String value, Map<String, QueryParameterValue> namedParams) {
+
+    String paramName = key.substring(NAMED_PARAM_PREFIX.length());
+    Preconditions.checkArgument(
+        !paramName.isEmpty(),
+        String.format("Named parameter name cannot be empty. Option key: '%s'", key));
+
+    QueryParameterValue qpv = parseSingleParameterValue(paramName, value); // Identifier is name
+
+    // Check for duplicates *before* putting
+    Preconditions.checkArgument(
+        !namedParams.containsKey(paramName),
+        String.format(
+            "Duplicate named parameter definition for '%s'. Option key: '%s'", paramName, key));
+
+    namedParams.put(paramName, qpv);
+  }
+
+  /**
+   * Processes a single positional parameter option, adding it to the temporary map. Performs
+   * parsing and validation checks specific to positional parameters.
+   */
+  private static void processPositionalParameter(
+      String key, String value, Map<Integer, QueryParameterValue> positionalParamsTemp) {
+
+    String indexStr = key.substring(POSITIONAL_PARAM_PREFIX.length());
+    Preconditions.checkArgument(
+        !indexStr.isEmpty(),
+        String.format("Positional parameter index cannot be empty. Option key: '%s'", key));
+
+    int index;
+    try {
+      index = Integer.parseInt(indexStr);
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Invalid positional parameter index: '%s' must be an integer. Option key: '%s'",
+              indexStr, key),
+          e);
+    }
+
+    Preconditions.checkArgument(
+        index >= 1,
+        String.format(
+            "Invalid positional parameter index: %d. Indices must be 1-based. Option key: '%s'",
+            index, key));
+
+    QueryParameterValue qpv =
+        parseSingleParameterValue(String.valueOf(index), value); // Identifier is index
+
+    Preconditions.checkArgument(
+        !positionalParamsTemp.containsKey(index),
+        String.format(
+            "Duplicate positional parameter definition for index: %d. Option key: '%s'",
+            index, key));
+
+    positionalParamsTemp.put(index, qpv);
+  }
+
+  /**
+   * Builds the final ordered list of positional parameters from the temporary map, checking for
+   * contiguous indices starting from 1.
+   *
+   * @param positionalParamsTemp Sorted map of index to parameter value.
+   * @return Unmodifiable list of positional parameters in order.
+   * @throws NullPointerException if a gap is found (via Preconditions.checkNotNull).
+   */
+  private static List<QueryParameterValue> buildPositionalParameterList(
+      Map<Integer, QueryParameterValue> positionalParamsTemp) {
+    List<QueryParameterValue> positionalList = new ArrayList<>();
+    int maxIndex = ((TreeMap<Integer, QueryParameterValue>) positionalParamsTemp).lastKey();
+
+    for (int i = 1; i <= maxIndex; i++) {
+      QueryParameterValue currentParam = positionalParamsTemp.get(i);
+      Preconditions.checkNotNull(
+          currentParam,
+          String.format(
+              "Missing positional parameter for index: %d. Parameters must be contiguous starting from 1.",
+              i));
+      positionalList.add(currentParam);
+    }
+    return positionalList;
+  }
+
+  /**
+   * Parses a "TYPE:value" string into a QueryParameterValue.
+   *
+   * @param identifier The name or index (as string) of the parameter, for error messages.
+   * @param typeValueString The combined "TYPE:value" string.
+   * @return The corresponding QueryParameterValue.
+   * @throws IllegalArgumentException if the format is invalid, type is unknown/unsupported, or
+   *     value parsing fails.
+   */
+  private static QueryParameterValue parseSingleParameterValue(
+      String identifier, String typeValueString) {
+    Preconditions.checkNotNull(
+        typeValueString,
+        String.format("Parameter value string cannot be null for identifier: %s", identifier));
+
+    int colonIndex = typeValueString.indexOf(':');
+    Preconditions.checkArgument(
+        colonIndex > 0,
+        String.format(
+            "Invalid parameter value format for identifier '%s': '%s'. Expected 'TYPE:value' with"
+                + " non-empty TYPE before ':'.",
+            identifier, typeValueString));
+
+    String typeStr = typeValueString.substring(0, colonIndex).trim().toUpperCase(Locale.ROOT);
+    String valueStr = typeValueString.substring(colonIndex + 1);
+
+    StandardSQLTypeName type;
+    try {
+      type = StandardSQLTypeName.valueOf(typeStr);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Unknown query parameter type: '%s' for identifier: '%s'. Supported types: %s",
+              typeStr,
+              identifier,
+              Arrays.stream(StandardSQLTypeName.values())
+                  .map(Enum::name)
+                  .collect(Collectors.joining(", "))),
+          e);
+    }
+
+    Preconditions.checkArgument(
+        type != StandardSQLTypeName.ARRAY && type != StandardSQLTypeName.STRUCT,
+        String.format(
+            "Unsupported query parameter type: %s for identifier: '%s'. ARRAY and STRUCT types are"
+                + " not currently supported as query parameters.",
+            type, identifier));
+
+    return QueryParameterValue.newBuilder().setValue(valueStr.trim()).setType(type).build();
+  }
 }
diff --git a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/ParameterMode.java b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/ParameterMode.java
new file mode 100644
index 0000000000..1042c4551e
--- /dev/null
+++ b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/ParameterMode.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.cloud.bigquery.connector.common;
+
+import java.io.Serializable;
+
+public enum ParameterMode implements Serializable {
+  NONE,
+  NAMED,
+  POSITIONAL
+}
diff --git a/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/QueryParameterHelper.java b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/QueryParameterHelper.java
new file mode 100644
index 0000000000..5122e7bc13
--- /dev/null
+++ b/bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/QueryParameterHelper.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.cloud.bigquery.connector.common;
+
+import com.google.cloud.bigquery.QueryJobConfiguration;
+import com.google.cloud.bigquery.QueryParameterValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Helper class to store parsed query parameters (Named or Positional) and their mode.
+ *
+ * <p>Instances of this class are created by the {@link BigQueryUtil} class. This class is immutable
+ * and serializable, designed for Java 8 compatibility.
+ */
+public final class QueryParameterHelper implements Serializable {
+
+  private static final long serialVersionUID = 1L; // For Serializable interface
+
+  private final ParameterMode mode;
+  private final ImmutableMap<String, QueryParameterValue> namedParameters;
+  private final ImmutableList<QueryParameterValue> positionalParameters;
+
+  public QueryParameterHelper(
+      ParameterMode mode,
+      ImmutableMap<String, QueryParameterValue> named,
+      ImmutableList<QueryParameterValue> positional) {
+    this.mode = mode;
+
+    this.namedParameters = named;
+    this.positionalParameters = positional;
+  }
+
+  static QueryParameterHelper none() {
+    return new QueryParameterHelper(ParameterMode.NONE, ImmutableMap.of(), ImmutableList.of());
+  }
+
+  static QueryParameterHelper named(Map<String, QueryParameterValue> namedParameters) {
+    Preconditions.checkNotNull(
+        namedParameters, "Input named parameters map cannot be null for named mode");
+    return new QueryParameterHelper(
+        ParameterMode.NAMED, ImmutableMap.copyOf(namedParameters), ImmutableList.of());
+  }
+
+  static QueryParameterHelper positional(List<QueryParameterValue> positionalParameters) {
+    Preconditions.checkNotNull(
+        positionalParameters,
+        "Input positional parameters list cannot be null for positional mode");
+
+    return new QueryParameterHelper(
+        ParameterMode.POSITIONAL,
+        ImmutableMap.of(), // Pass empty immutable map
+        ImmutableList.copyOf(positionalParameters));
+  }
+
+  public ParameterMode getMode() {
+    return mode;
+  }
+
+  /**
+   * Returns the named parameters.
+   *
+   * @return An Optional containing an immutable map of named parameters if mode is NAMED, otherwise
+   *     Optional.empty().
+   */
+  public Optional<ImmutableMap<String, QueryParameterValue>> getNamedParameters() {
+    return mode == ParameterMode.NAMED ? Optional.of(namedParameters) : Optional.empty();
+  }
+
+  /**
+   * Returns the positional parameters.
+   *
+   * @return An Optional containing an immutable list of positional parameters if mode is
+   *     POSITIONAL, otherwise Optional.empty().
+   */
+  public Optional<ImmutableList<QueryParameterValue>> getPositionalParameters() {
+    return mode == ParameterMode.POSITIONAL ? Optional.of(positionalParameters) : Optional.empty();
+  }
+
+  /** Returns true if no parameters were defined, false otherwise. */
+  public boolean isEmpty() {
+    return mode == ParameterMode.NONE;
+  }
+
+  public QueryJobConfiguration.Builder configureBuilder(QueryJobConfiguration.Builder builder) {
+    Preconditions.checkNotNull(builder, "QueryJobConfiguration.Builder cannot be null");
+
+    this.namedParameters.forEach(
+        (paramName, paramValue) -> {
+          builder.addNamedParameter(paramName, paramValue);
+        });
+    this.positionalParameters.forEach(
+        (paramValue) -> {
+          builder.addPositionalParameter(paramValue);
+        });
+
+    return builder;
+  }
+}
diff --git a/bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java b/bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java
index 1d241ed3ad..b91ffd581a 100644
--- a/bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java
+++ b/bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java
@@ -33,6 +33,7 @@
 import com.google.cloud.bigquery.FormatOptions;
 import com.google.cloud.bigquery.HivePartitioningOptions;
 import com.google.cloud.bigquery.LegacySQLTypeName;
+import com.google.cloud.bigquery.QueryParameterValue;
 import com.google.cloud.bigquery.RangePartitioning;
 import com.google.cloud.bigquery.Schema;
 import com.google.cloud.bigquery.StandardSQLTypeName;
@@ -45,9 +46,13 @@
 import com.google.cloud.bigquery.storage.v1.ReadSession.TableReadOptions;
 import com.google.cloud.bigquery.storage.v1.ReadStream;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.math.BigDecimal;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -947,4 +952,367 @@ public void testCredentialSerialization() {
 
     assertThat(credentials).isEqualTo(expected);
   }
+
+  @Test
+  public void testParseNamedParameters_SuccessAllTypes() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.strParam", "STRING:hello world");
+    options.put("NamedParameters.intParam", "INT64:1234567890");
+    options.put("NamedParameters.boolPropT", "BOOL:true");
+    options.put("NamedParameters.boolPropF", "BOOL:false");
+    options.put("NamedParameters.boolPropCase", "BOOL:TRUE");
+    options.put("NamedParameters.floatParam", "FLOAT64:123.456");
+    options.put("NamedParameters.numericParam", "NUMERIC:987654321.123456789");
+    options.put(
+        "NamedParameters.bignumericParam",
+        "BIGNUMERIC:123456789012345678901234567890.123456789123456789");
+    options.put("NamedParameters.dateParam", "DATE:2023-10-27");
+    options.put("NamedParameters.jsonParam", "JSON:{\"key\": \"value\", \"arr\": [1, 2]}");
+    options.put("NamedParameters.geoParam", "GEOGRAPHY:POINT(1 2)");
+    options.put(
+        "namedparameters.caseinsensitiveprefix",
+        "STRING:prefix works"); // Test prefix case insensitivity
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.isEmpty()).isFalse();
+
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(12);
+
+    assertThat(params.get("strParam")).isEqualTo(QueryParameterValue.string("hello world"));
+    assertThat(params.get("intParam")).isEqualTo(QueryParameterValue.int64(1234567890L));
+    assertThat(Boolean.parseBoolean(params.get("boolPropT").getValue().toString())).isTrue();
+    assertThat(Boolean.parseBoolean(params.get("boolPropF").getValue().toString())).isFalse();
+    assertThat(Boolean.parseBoolean(params.get("boolPropCase").getValue().toString())).isTrue();
+    assertThat(params.get("floatParam")).isEqualTo(QueryParameterValue.float64(123.456));
+    assertThat(params.get("numericParam"))
+        .isEqualTo(QueryParameterValue.numeric(new BigDecimal("987654321.123456789")));
+    assertThat(params.get("bignumericParam"))
+        .isEqualTo(
+            QueryParameterValue.bigNumeric(
+                new BigDecimal("123456789012345678901234567890.123456789123456789")));
+    assertThat(params.get("dateParam")).isEqualTo(QueryParameterValue.date("2023-10-27"));
+    assertThat(params.get("jsonParam"))
+        .isEqualTo(QueryParameterValue.json("{\"key\": \"value\", \"arr\": [1, 2]}"));
+    assertThat(params.get("geoParam")).isEqualTo(QueryParameterValue.geography("POINT(1 2)"));
+    assertThat(params.get("caseinsensitiveprefix"))
+        .isEqualTo(QueryParameterValue.string("prefix works"));
+    assertThat(result.getPositionalParameters()).isEmpty();
+  }
+
+  @Test
+  public void testParseNamedParameters_EmptyStringValue() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.emptyStr", "STRING:");
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(1);
+    assertThat(params.get("emptyStr")).isEqualTo(QueryParameterValue.string(""));
+  }
+
+  @Test
+  public void testParseNamedParameters_SpacesInValue() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.withSpaces", "STRING:  leading and trailing spaces  ");
+    options.put("NamedParameters.numWithSpaces", "INT64:  123  "); // Parser trims value
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(2);
+    assertThat(params.get("withSpaces"))
+        .isEqualTo(QueryParameterValue.string("leading and trailing spaces"));
+    assertThat(params.get("numWithSpaces")).isEqualTo(QueryParameterValue.int64(123L));
+  }
+
+  @Test
+  public void testParseNamedParameters_DuplicateKeysDifferentCase() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.myValue", "STRING:first");
+    options.put("NamedParameters.myvalue", "STRING:second"); // Different key
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+
+    // Both keys exist as they are different strings
+    assertThat(params).hasSize(2);
+    assertThat(params.get("myValue")).isEqualTo(QueryParameterValue.string("first"));
+    assertThat(params.get("myvalue")).isEqualTo(QueryParameterValue.string("second"));
+  }
+
+  @Test
+  public void testParseNamedParameters_IdenticalKeys() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.myValue", "STRING:first");
+    options.put("NamedParameters.myValue", "STRING:second");
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    Map<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(1);
+    assertThat(params.get("myValue")).isEqualTo(QueryParameterValue.string("second"));
+  }
+
+  @Test
+  public void testParsePositionalParameters_Success() {
+    Map<String, String> options = new HashMap<>();
+    options.put("PositionalParameters.1", "STRING:value1");
+    options.put("PositionalParameters.3", "BOOL:false"); // Intentionally out of order for input map
+    options.put("positionalparameters.2", "INT64:99"); // Test prefix case insensitivity
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.POSITIONAL);
+    assertThat(result.isEmpty()).isFalse();
+
+    assertThat(result.getPositionalParameters()).isPresent();
+    ImmutableList<QueryParameterValue> params = result.getPositionalParameters().get();
+    assertThat(params).hasSize(3); // Parser ensures correct size based on max index
+
+    assertThat(params.get(0)).isEqualTo(QueryParameterValue.string("value1")); // Index 0 = Param 1
+    assertThat(params.get(1)).isEqualTo(QueryParameterValue.int64(99L)); // Index 1 = Param 2
+    assertThat(params.get(2)).isEqualTo(QueryParameterValue.bool(false)); // Index 2 = Param 3
+
+    assertThat(result.getNamedParameters()).isEmpty();
+  }
+
+  @Test
+  public void testParsePositionalParameters_SingleParameter() {
+    Map<String, String> options = Collections.singletonMap("PositionalParameters.1", "FLOAT64:1.0");
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.POSITIONAL);
+    assertThat(result.getPositionalParameters()).isPresent();
+    ImmutableList<QueryParameterValue> params = result.getPositionalParameters().get();
+    assertThat(params).hasSize(1);
+    assertThat(params.get(0)).isEqualTo(QueryParameterValue.float64(1.0));
+  }
+
+  @Test
+  public void testParseParameters_NoParameterOptions() {
+    Map<String, String> options = new HashMap<>();
+    options.put("query", "SELECT * FROM table");
+    options.put("table", "my_table"); // A non-parameter option
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NONE);
+    assertThat(result.isEmpty()).isTrue();
+
+    assertThat(result.getNamedParameters()).isEmpty();
+    assertThat(result.getPositionalParameters()).isEmpty();
+  }
+
+  @Test
+  public void testParseParameters_EmptyOptionsMap() {
+    Map<String, String> options = Collections.emptyMap();
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NONE);
+    assertThat(result.isEmpty()).isTrue();
+    assertThat(result.getNamedParameters()).isEmpty();
+    assertThat(result.getPositionalParameters()).isEmpty();
+  }
+
+  @Test
+  public void testParseParameters_ErrorMixedNamedAndPositional() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.name", "STRING:test");
+    options.put("PositionalParameters.1", "INT64:100");
+
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage()).contains("Cannot mix NamedParameters.* and PositionalParameters.*");
+  }
+
+  @Test
+  public void testParseParameters_ErrorMixedPositionalAndNamed() {
+    Map<String, String> options = new HashMap<>();
+    options.put("PositionalParameters.1", "INT64:100");
+    options.put("NamedParameters.name", "STRING:test"); // Add named after positional
+
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage()).contains("Cannot mix NamedParameters.* and PositionalParameters.*");
+  }
+
+  @Test
+  public void testParseParameters_ErrorNullValueString() {
+    final Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.nullValue", null); // Put null value allowed by HashMap
+
+    NullPointerException e =
+        assertThrows(NullPointerException.class, () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Parameter value string cannot be null for identifier: nullValue");
+  }
+
+  @Test
+  public void testParseParameters_ErrorInvalidFormatNoColon() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.bad", "STRING value");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains(
+            "Invalid parameter value format for identifier 'bad': 'STRING value'. Expected 'TYPE:value'");
+  }
+
+  @Test
+  public void testParseParameters_ErrorInvalidFormatEmptyType() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.bad", ":value");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains(
+            "Invalid parameter value format for identifier 'bad': ':value'. Expected 'TYPE:value'");
+  }
+
+  @Test
+  public void testParseParameters_ErrorUnknownType() {
+    Map<String, String> options =
+        Collections.singletonMap(
+            "NamedParameters.bad", "INTEGER:123"); // INTEGER is not StandardSQLTypeName
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains("Unknown query parameter type: 'INTEGER' for identifier: 'bad'");
+  }
+
+  @Test
+  public void testParseParameters_ErrorUnsupportedTypeArray() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.arr", "ARRAY:[1, 2]");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains("Unsupported query parameter type: ARRAY for identifier: 'arr'");
+  }
+
+  @Test
+  public void testParseParameters_ErrorUnsupportedTypeStruct() {
+    Map<String, String> options =
+        Collections.singletonMap(
+            "NamedParameters.st", "STRUCT: STRUCT<a INT64>(1)"); // Format doesn't matter here
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains("Unsupported query parameter type: STRUCT for identifier: 'st'");
+  }
+
+  // --- Error Cases: Invalid Names/Indices ---
+
+  @Test
+  public void testParseNamedParameters_ErrorEmptyName() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.", "STRING:test");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains("Named parameter name cannot be empty. Option key: 'NamedParameters.'");
+  }
+
+  @Test
+  public void testParsePositionalParameters_ErrorEmptyIndex() {
+    Map<String, String> options = Collections.singletonMap("PositionalParameters.", "STRING:test");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains(
+            "Positional parameter index cannot be empty. Option key: 'PositionalParameters.'");
+  }
+
+  @Test
+  public void testParsePositionalParameters_ErrorNonNumericIndex() {
+    Map<String, String> options =
+        Collections.singletonMap("PositionalParameters.abc", "STRING:test");
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+    assertThat(e.getMessage())
+        .contains(
+            "Invalid positional parameter index: 'abc' must be an integer. Option key: 'PositionalParameters.abc'");
+  }
+
+  @Test
+  public void testParsePositionalParameters_ErrorZeroIndex() {
+    int index = 0;
+    Map<String, String> options =
+        Collections.singletonMap("PositionalParameters." + index, "STRING:test");
+
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage())
+        .contains("Invalid positional parameter index: " + index + ". Indices must be 1-based.");
+  }
+
+  @Test
+  public void testParsePositionalParameters_ErrorNegativeIndexMinusOne() {
+    int index = -1;
+    Map<String, String> options =
+        Collections.singletonMap("PositionalParameters." + index, "STRING:test");
+
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class, () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage())
+        .contains("Invalid positional parameter index: " + index + ". Indices must be 1-based.");
+  }
+
+  // Removed redundant negative index test (-10) as -1 covers the logic sufficiently
+
+  @Test
+  public void testParsePositionalParameters_ErrorIndexGap() {
+    final Map<String, String> options = new HashMap<>();
+    options.put("PositionalParameters.1", "STRING:first");
+    options.put("PositionalParameters.3", "INT64:100"); // Missing index 2
+
+    NullPointerException e =
+        assertThrows(
+            NullPointerException.class, // CHANGED Expected Exception Type
+            () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage())
+        .contains(
+            "Missing positional parameter for index: 2. Parameters must be contiguous starting from 1.");
+  }
+
+  @Test
+  public void testParsePositionalParameters_ErrorOnlyGap() {
+    final Map<String, String> options = new HashMap<>();
+    options.put("PositionalParameters.2", "STRING:only two"); // Missing index 1
+
+    // Missing index 1 check throws NullPointerException
+    NullPointerException e =
+        assertThrows(
+            NullPointerException.class, // CHANGED Expected Exception Type
+            () -> BigQueryUtil.parseQueryParameters(options));
+
+    assertThat(e.getMessage())
+        .contains(
+            "Missing positional parameter for index: 1. Parameters must be contiguous starting from 1.");
+  }
 }
diff --git a/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java b/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java
index d9a7aa6bcc..a63ef8c189 100644
--- a/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java
+++ b/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java
@@ -47,6 +47,7 @@
 import com.google.cloud.bigquery.connector.common.BigQueryCredentialsSupplier;
 import com.google.cloud.bigquery.connector.common.BigQueryProxyConfig;
 import com.google.cloud.bigquery.connector.common.BigQueryUtil;
+import com.google.cloud.bigquery.connector.common.QueryParameterHelper;
 import com.google.cloud.bigquery.connector.common.ReadSessionCreatorConfig;
 import com.google.cloud.bigquery.connector.common.ReadSessionCreatorConfigBuilder;
 import com.google.cloud.bigquery.storage.v1.ArrowSerializationOptions.CompressionCodec;
@@ -250,6 +251,7 @@ public static WriteMethod from(@Nullable String writeMethod) {
   private com.google.common.base.Optional<String> gpn;
   private int bigNumericDefaultPrecision;
   private int bigNumericDefaultScale;
+  private QueryParameterHelper queryParameterHelper;
 
   @VisibleForTesting
   SparkBigQueryConfig() {
@@ -670,7 +672,7 @@ public static SparkBigQueryConfig from(
         getAnyOption(globalOptions, options, BIG_NUMERIC_DEFAULT_SCALE)
             .transform(Integer::parseInt)
             .or(BigQueryUtil.DEFAULT_BIG_NUMERIC_SCALE);
-
+    config.queryParameterHelper = BigQueryUtil.parseQueryParameters(options);
     return config;
   }
 
@@ -800,6 +802,10 @@ public Optional<String> getQuery() {
     return query.toJavaUtil();
   }
 
+  public QueryParameterHelper getQueryParameterHelper() {
+    return queryParameterHelper;
+  }
+
   @Override
   public String getParentProjectId() {
     return parentProjectId;
@@ -1173,6 +1179,11 @@ public boolean viewsEnabled() {
       public String viewEnabledParamName() {
         return SparkBigQueryConfig.VIEWS_ENABLED_OPTION;
       }
+
+      @Override
+      public QueryParameterHelper getQueryParameterHelper() {
+        return SparkBigQueryConfig.this.getQueryParameterHelper();
+      }
     };
   }
 
diff --git a/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/direct/BigQueryRDDFactory.java b/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/direct/BigQueryRDDFactory.java
index a5c6679929..270b87287e 100644
--- a/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/direct/BigQueryRDDFactory.java
+++ b/spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/direct/BigQueryRDDFactory.java
@@ -27,6 +27,8 @@
 import com.google.cloud.bigquery.connector.common.BigQueryConnectorException;
 import com.google.cloud.bigquery.connector.common.BigQueryTracerFactory;
 import com.google.cloud.bigquery.connector.common.BigQueryUtil;
+import com.google.cloud.bigquery.connector.common.ParameterMode;
+import com.google.cloud.bigquery.connector.common.QueryParameterHelper;
 import com.google.cloud.bigquery.connector.common.ReadSessionCreator;
 import com.google.cloud.bigquery.connector.common.ReadSessionResponse;
 import com.google.cloud.bigquery.storage.v1.ReadSession;
@@ -93,7 +95,8 @@ public RDD<InternalRow> buildScanFromSQL(String sql) {
             // Label to add to Query Job indicating that it was invoked as part of query pushdown
             ImmutableMap.<String, String>builder()
                 .put(QUERY_JOB_LABEL, QUERY_PUSHDOWN_JOB_LABEL_VALUE)
-                .build());
+                .build(),
+            new QueryParameterHelper(ParameterMode.NONE, null, null));
 
     TableDefinition actualTableDefinition = actualTable.getDefinition();
 
diff --git a/spark-bigquery-connector-common/src/test/java/com/google/cloud/spark/bigquery/integration/ReadFromQueryIntegrationTestBase.java b/spark-bigquery-connector-common/src/test/java/com/google/cloud/spark/bigquery/integration/ReadFromQueryIntegrationTestBase.java
index 5d641b4b3a..cb3267f627 100644
--- a/spark-bigquery-connector-common/src/test/java/com/google/cloud/spark/bigquery/integration/ReadFromQueryIntegrationTestBase.java
+++ b/spark-bigquery-connector-common/src/test/java/com/google/cloud/spark/bigquery/integration/ReadFromQueryIntegrationTestBase.java
@@ -21,12 +21,14 @@
 import com.google.cloud.bigquery.BigQuery;
 import com.google.cloud.bigquery.BigQueryOptions;
 import com.google.cloud.bigquery.DatasetId;
+import com.google.cloud.bigquery.JobConfiguration;
 import com.google.cloud.bigquery.JobInfo;
 import com.google.cloud.bigquery.QueryJobConfiguration;
 import com.google.cloud.bigquery.Table;
 import com.google.cloud.bigquery.TableDefinition;
 import com.google.cloud.spark.bigquery.events.BigQueryJobCompletedEvent;
 import com.google.common.collect.ImmutableList;
+import com.google.inject.ProvisionException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -210,6 +212,108 @@ public void testReadFromLongQueryWithBigQueryJobTimeout() {
           }
         });
   }
+
+  @Test
+  public void testReadWithNamedParameters() {
+    listener.reset();
+    String namedParamQuery =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = @corpus AND word_count >= @min_word_count ORDER BY word_count DESC";
+
+    Dataset<Row> df =
+        spark
+            .read()
+            .format("bigquery")
+            .option("query", namedParamQuery)
+            .option("viewsEnabled", "true")
+            .option("NamedParameters.corpus", "STRING:romeoandjuliet")
+            .option("NamedParameters.min_word_count", "INT64:250")
+            .load();
+
+    StructType expectedSchema =
+        DataTypes.createStructType(
+            ImmutableList.of(
+                DataTypes.createStructField("word", DataTypes.StringType, true),
+                DataTypes.createStructField("word_count", DataTypes.LongType, true)));
+    assertThat(df.schema()).isEqualTo(expectedSchema);
+
+    assertThat(df.count()).isGreaterThan(0L);
+
+    List<JobInfo> jobInfos = listener.getJobInfos();
+    assertThat(jobInfos).hasSize(1);
+    JobInfo jobInfo = jobInfos.iterator().next();
+    assertThat(jobInfo.getConfiguration().getType()).isEqualTo(JobConfiguration.Type.QUERY);
+    QueryJobConfiguration queryConfig = jobInfo.getConfiguration();
+    assertThat(queryConfig.getQuery()).isEqualTo(namedParamQuery);
+  }
+
+  @Test
+  public void testReadWithPositionalParameters() {
+    listener.reset();
+    String positionalParamQuery =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = ? AND word_count >= ? ORDER BY word_count DESC";
+
+    Dataset<Row> df =
+        spark
+            .read()
+            .format("bigquery")
+            .option("query", positionalParamQuery) // Use hardcoded query
+            .option("viewsEnabled", "true")
+            .option("PositionalParameters.1", "STRING:romeoandjuliet")
+            .option("PositionalParameters.2", "INT64:250")
+            .load();
+
+    StructType expectedSchema =
+        DataTypes.createStructType(
+            ImmutableList.of(
+                DataTypes.createStructField("word", DataTypes.StringType, true),
+                DataTypes.createStructField("word_count", DataTypes.LongType, true)));
+    assertThat(df.schema()).isEqualTo(expectedSchema);
+
+    assertThat(df.count()).isGreaterThan(0L);
+
+    List<JobInfo> jobInfos = listener.getJobInfos();
+    assertThat(jobInfos).hasSize(1);
+    JobInfo jobInfo = jobInfos.iterator().next();
+    assertThat(jobInfo.getConfiguration().getType()).isEqualTo(JobConfiguration.Type.QUERY);
+    QueryJobConfiguration queryConfig = jobInfo.getConfiguration();
+    assertThat(queryConfig.getQuery()).isEqualTo(positionalParamQuery);
+  }
+
+  @Test
+  public void testReadWithMixedParametersFails() {
+    String queryForFailure =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = @corpus AND word_count >= @min_word_count ORDER BY word_count DESC";
+
+    Exception thrown =
+        assertThrows(
+            Exception.class,
+            () -> {
+              spark
+                  .read()
+                  .format("bigquery")
+                  .option("query", queryForFailure)
+                  .option("viewsEnabled", "true")
+                  .option("NamedParameters.corpus", "STRING:whatever")
+                  .option("PositionalParameters.1", "INT64:100")
+                  .load()
+                  .show();
+            });
+
+    Throwable cause = thrown;
+    if (cause instanceof ProvisionException && cause.getCause() != null) {
+      cause = cause.getCause();
+    }
+
+    assertThat(cause).isInstanceOf(IllegalArgumentException.class);
+    assertThat(cause)
+        .hasMessageThat()
+        .contains("Cannot mix NamedParameters.* and PositionalParameters.* options.");
+
+    assertThat(listener.getJobInfos()).isEmpty();
+  }
 }
 
 class TestBigQueryJobCompletionListener extends SparkListener {


Reviews:
user: login: davidrabinowitz
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-02T22:45:58Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T19:15:17Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T19:15:55Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T19:16:15Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:34:31Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:41:59Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:48:07Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:48:14Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T20:54:13Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T21:00:30Z
user: login: BoqianShi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T21:38:38Z
user: login: davidrabinowitz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T18:01:31Z

Review comments:
diff_hunk: @@ -250,6 +251,8 @@ public static WriteMethod from(@Nullable String writeMethod) {
   private com.google.common.base.Optional<String> gpn;
   private int bigNumericDefaultPrecision;
   private int bigNumericDefaultScale;
+  private Map<String, QueryParameterValue> queryNamedParameters = Collections.emptyMap();
path: spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java
user: login: davidrabinowitz
body: Please replace the two collections with a single instance of ParsedQueryParameters
created_at: 2025-04-02T22:36:16Z
author_association: MEMBER
diff_hunk: @@ -882,4 +892,234 @@ public static boolean isBigQueryNativeTable(TableInfo table) {
     return table.getDefinition().getType() == TableDefinition.Type.TABLE
         && !isBigLakeManagedTable(table);
   }
+
+  /**
+   * Helper class to hold the parsed query parameters, distinguishing between named and positional.
+   */
+  public static class ParsedQueryParameters {
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
user: login: davidrabinowitz
body: This should be a top level class, and it should be Serializable 
created_at: 2025-04-02T22:36:56Z
author_association: MEMBER
diff_hunk: @@ -988,8 +1032,28 @@ public JobConfigurationFactory(Map<String, String> labels, Priority queryJobPrio
 
     QueryJobConfiguration.Builder createQueryJobConfigurationBuilder(
         String querySql, Map<String, String> additionalQueryJobLabels) {
+      return createParameterizedQueryJobConfigurationBuilder(
+          querySql, additionalQueryJobLabels, Collections.emptyMap(), Collections.emptyList());
+    }
+
+    QueryJobConfiguration.Builder createParameterizedQueryJobConfigurationBuilder(
+        String querySql,
+        Map<String, String> additionalQueryJobLabels,
+        Map<String, QueryParameterValue> queryNamedParameters,
+        List<QueryParameterValue> queryPositionalParameters) {
+
       QueryJobConfiguration.Builder builder =
           QueryJobConfiguration.newBuilder(querySql).setPriority(queryJobPriority);
+
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java
user: login: davidrabinowitz
body: This section should be inside ParsedQueryParameters
created_at: 2025-04-02T22:39:01Z
author_association: MEMBER
diff_hunk: @@ -250,6 +251,8 @@ public static WriteMethod from(@Nullable String writeMethod) {
   private com.google.common.base.Optional<String> gpn;
   private int bigNumericDefaultPrecision;
   private int bigNumericDefaultScale;
+  private Map<String, QueryParameterValue> queryNamedParameters = Collections.emptyMap();
path: spark-bigquery-connector-common/src/main/java/com/google/cloud/spark/bigquery/SparkBigQueryConfig.java
user: login: BoqianShi
body: Now replaced with top-level class QueryParameterHelper.
created_at: 2025-04-03T19:15:17Z
author_association: MEMBER
diff_hunk: @@ -882,4 +892,234 @@ public static boolean isBigQueryNativeTable(TableInfo table) {
     return table.getDefinition().getType() == TableDefinition.Type.TABLE
         && !isBigLakeManagedTable(table);
   }
+
+  /**
+   * Helper class to hold the parsed query parameters, distinguishing between named and positional.
+   */
+  public static class ParsedQueryParameters {
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
user: login: BoqianShi
body: Addressed
created_at: 2025-04-03T19:15:55Z
author_association: MEMBER
diff_hunk: @@ -988,8 +1032,28 @@ public JobConfigurationFactory(Map<String, String> labels, Priority queryJobPrio
 
     QueryJobConfiguration.Builder createQueryJobConfigurationBuilder(
         String querySql, Map<String, String> additionalQueryJobLabels) {
+      return createParameterizedQueryJobConfigurationBuilder(
+          querySql, additionalQueryJobLabels, Collections.emptyMap(), Collections.emptyList());
+    }
+
+    QueryJobConfiguration.Builder createParameterizedQueryJobConfigurationBuilder(
+        String querySql,
+        Map<String, String> additionalQueryJobLabels,
+        Map<String, QueryParameterValue> queryNamedParameters,
+        List<QueryParameterValue> queryPositionalParameters) {
+
       QueryJobConfiguration.Builder builder =
           QueryJobConfiguration.newBuilder(querySql).setPriority(queryJobPriority);
+
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryClient.java
user: login: BoqianShi
body: Moved to QueryParameterHelper class
created_at: 2025-04-03T19:16:15Z
author_association: MEMBER
diff_hunk: @@ -242,6 +242,32 @@ In order to use this feature the `viewsEnabled` configurations MUST be set to
 saving the result into a temporary table, of which Spark will read the results
 from. This may add additional costs on your BigQuery account.
 
+### Reading From Parameterized Queries
+
+The connector supports executing BigQuery parameterized queries using the
path: README-template.md
user: login: BoqianShi
body: Add a link to BigQuery's document about parameterized queries..
created_at: 2025-04-08T20:34:31Z
author_association: MEMBER
diff_hunk: @@ -882,4 +891,163 @@ public static boolean isBigQueryNativeTable(TableInfo table) {
     return table.getDefinition().getType() == TableDefinition.Type.TABLE
         && !isBigLakeManagedTable(table);
   }
+
+  /**
+   * Parses query parameters from Spark options based on NamedParameters.* and
+   * PositionalParameters.* prefixes.
+   *
+   * @param options A map containing Spark DataSource options.
+   * @return A QueryParameterHelper object containing either named or positional parameters, or an
+   *     empty state (ParameterMode.NONE).
+   * @throws IllegalArgumentException if both named and positional parameters are specified, if
+   *     parameter format is invalid, type is unknown/unsupported, value parsing fails, positional
+   *     index is invalid/missing/duplicated, or positional parameters are not contiguous from 1.
+   */
+  public static QueryParameterHelper parseQueryParameters(Map<String, String> options) {
+    Map<String, QueryParameterValue> namedParameters = new HashMap<>();
+    Map<Integer, QueryParameterValue> positionalParametersTemp = new TreeMap<>();
+    ParameterMode currentMode = ParameterMode.NONE;
+
+    for (Map.Entry<String, String> entry : options.entrySet()) {
+      String key = entry.getKey();
+      String keyLower = key.toLowerCase(Locale.ROOT);
+      String value = entry.getValue();
+
+      if (keyLower.startsWith(NAMED_PARAM_PREFIX)) {
+        Preconditions.checkArgument(
+            currentMode != ParameterMode.POSITIONAL,
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/BigQueryUtil.java
user: login: BoqianShi
body: Readibility.
created_at: 2025-04-08T20:41:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,125 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.cloud.bigquery.connector.common;
+
+import com.google.cloud.bigquery.QueryJobConfiguration;
+import com.google.cloud.bigquery.QueryParameterValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Helper class to store parsed query parameters (Named or Positional) and their mode.
+ *
+ * <p>Instances of this class are created by the {@link BigQueryUtil} class. This class is immutable
+ * and serializable, designed for Java 8 compatibility.
+ */
+public final class QueryParameterHelper implements Serializable {
+
+  private static final long serialVersionUID = 1L; // For Serializable interface
+
+  private final ParameterMode mode;
+  private final ImmutableMap<String, QueryParameterValue> namedParameters;
+  private final ImmutableList<QueryParameterValue> positionalParameters;
+
+  public QueryParameterHelper(
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/QueryParameterHelper.java
user: login: BoqianShi
body: Private

created_at: 2025-04-08T20:48:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,125 @@
+/*
+ * Copyright 2025 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.cloud.bigquery.connector.common;
+
+import com.google.cloud.bigquery.QueryJobConfiguration;
+import com.google.cloud.bigquery.QueryParameterValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Helper class to store parsed query parameters (Named or Positional) and their mode.
+ *
+ * <p>Instances of this class are created by the {@link BigQueryUtil} class. This class is immutable
+ * and serializable, designed for Java 8 compatibility.
+ */
+public final class QueryParameterHelper implements Serializable {
+
+  private static final long serialVersionUID = 1L; // For Serializable interface
+
+  private final ParameterMode mode;
+  private final ImmutableMap<String, QueryParameterValue> namedParameters;
+  private final ImmutableList<QueryParameterValue> positionalParameters;
+
+  public QueryParameterHelper(
path: bigquery-connector-common/src/main/java/com/google/cloud/bigquery/connector/common/QueryParameterHelper.java
user: login: BoqianShi
body: Null check
created_at: 2025-04-08T20:48:14Z
author_association: MEMBER
diff_hunk: @@ -947,4 +952,362 @@ public void testCredentialSerialization() {
 
     assertThat(credentials).isEqualTo(expected);
   }
+
+  @Test
+  public void testParseNamedParameters_SuccessAllTypes() {
+    Map<String, String> options = new HashMap<>();
+    options.put("query", "SELECT ...");
+    options.put("NamedParameters.strParam", "STRING:hello world");
+    options.put("NamedParameters.intParam", "INT64:1234567890");
+    options.put("NamedParameters.boolPropT", "BOOL:true");
+    options.put("NamedParameters.boolPropF", "BOOL:false");
+    options.put("NamedParameters.boolPropCase", "BOOL:TRUE");
+    options.put("NamedParameters.floatParam", "FLOAT64:123.456");
+    options.put("NamedParameters.numericParam", "NUMERIC:987654321.123456789");
path: bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java
user: login: BoqianShi
body: BigNumeric
created_at: 2025-04-08T20:54:12Z
author_association: MEMBER
diff_hunk: @@ -947,4 +952,362 @@ public void testCredentialSerialization() {
 
     assertThat(credentials).isEqualTo(expected);
   }
+
+  @Test
+  public void testParseNamedParameters_SuccessAllTypes() {
+    Map<String, String> options = new HashMap<>();
+    options.put("query", "SELECT ...");
+    options.put("NamedParameters.strParam", "STRING:hello world");
+    options.put("NamedParameters.intParam", "INT64:1234567890");
+    options.put("NamedParameters.boolPropT", "BOOL:true");
+    options.put("NamedParameters.boolPropF", "BOOL:false");
+    options.put("NamedParameters.boolPropCase", "BOOL:TRUE");
+    options.put("NamedParameters.floatParam", "FLOAT64:123.456");
+    options.put("NamedParameters.numericParam", "NUMERIC:987654321.123456789");
+    options.put("NamedParameters.dateParam", "DATE:2023-10-27");
+    options.put("NamedParameters.jsonParam", "JSON:{\"key\": \"value\", \"arr\": [1, 2]}");
+    options.put("NamedParameters.geoParam", "GEOGRAPHY:POINT(1 2)");
+    options.put(
+        "namedparameters.caseinsensitiveprefix",
+        "STRING:prefix works"); // Test prefix case insensitivity
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.isEmpty()).isFalse();
+
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(11);
+
+    assertThat(params.get("strParam")).isEqualTo(QueryParameterValue.string("hello world"));
+    assertThat(params.get("intParam")).isEqualTo(QueryParameterValue.int64(1234567890L));
+    assertThat(Boolean.parseBoolean(params.get("boolPropT").getValue().toString())).isTrue();
+    assertThat(Boolean.parseBoolean(params.get("boolPropF").getValue().toString())).isFalse();
+    assertThat(Boolean.parseBoolean(params.get("boolPropCase").getValue().toString())).isTrue();
+    assertThat(params.get("floatParam")).isEqualTo(QueryParameterValue.float64(123.456));
+    assertThat(params.get("numericParam"))
+        .isEqualTo(QueryParameterValue.numeric(new BigDecimal("987654321.123456789")));
+    assertThat(params.get("dateParam")).isEqualTo(QueryParameterValue.date("2023-10-27"));
+    assertThat(params.get("jsonParam"))
+        .isEqualTo(QueryParameterValue.json("{\"key\": \"value\", \"arr\": [1, 2]}"));
+    assertThat(params.get("geoParam")).isEqualTo(QueryParameterValue.geography("POINT(1 2)"));
+    assertThat(params.get("caseinsensitiveprefix"))
+        .isEqualTo(QueryParameterValue.string("prefix works"));
+    assertThat(result.getPositionalParameters()).isEmpty();
+  }
+
+  @Test
+  public void testParseNamedParameters_EmptyStringValue() {
+    Map<String, String> options = Collections.singletonMap("NamedParameters.emptyStr", "STRING:");
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(1);
+    assertThat(params.get("emptyStr")).isEqualTo(QueryParameterValue.string(""));
+  }
+
+  @Test
+  public void testParseNamedParameters_SpacesInValue() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.withSpaces", "STRING:  leading and trailing spaces  ");
+    options.put("NamedParameters.numWithSpaces", "INT64:  123  "); // Parser trims value
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(2);
+    assertThat(params.get("withSpaces"))
+        .isEqualTo(QueryParameterValue.string("leading and trailing spaces"));
+    assertThat(params.get("numWithSpaces")).isEqualTo(QueryParameterValue.int64(123L));
+  }
+
+  @Test
+  public void testParseNamedParameters_DuplicateKeysDifferentCase() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.myValue", "STRING:first");
+    options.put("NamedParameters.myvalue", "STRING:second"); // Different key
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    assertThat(result.getNamedParameters()).isPresent();
+    ImmutableMap<String, QueryParameterValue> params = result.getNamedParameters().get();
+
+    // Both keys exist as they are different strings
+    assertThat(params).hasSize(2);
+    assertThat(params.get("myValue")).isEqualTo(QueryParameterValue.string("first"));
+    assertThat(params.get("myvalue")).isEqualTo(QueryParameterValue.string("second"));
+  }
+
+  @Test
+  public void testParseNamedParameters_ErrorDuplicateKeysIdentical() {
+    Map<String, String> options = new HashMap<>();
+    options.put("NamedParameters.myValue", "STRING:first");
+    options.put("NamedParameters.myValue", "STRING:second");
+
+    QueryParameterHelper result = BigQueryUtil.parseQueryParameters(options);
+    assertThat(result.getMode()).isEqualTo(ParameterMode.NAMED);
+    Map<String, QueryParameterValue> params = result.getNamedParameters().get();
+    assertThat(params).hasSize(1);
+    assertThat(params.get("myValue")).isEqualTo(QueryParameterValue.string("second"));
+  }
+
+  @Test
+  public void testParsePositionalParameters_Success() {
+    Map<String, String> options = new HashMap<>();
+    options.put("query", "SELECT ... WHERE col1 = ? AND col2 = ? AND col3 = ?");
path: bigquery-connector-common/src/test/java/com/google/cloud/bigquery/connector/common/BigQueryUtilTest.java
user: login: BoqianShi
body: Not used
created_at: 2025-04-08T21:00:30Z
author_association: MEMBER
diff_hunk: @@ -210,6 +212,108 @@ public void testReadFromLongQueryWithBigQueryJobTimeout() {
           }
         });
   }
+
+  @Test
+  public void testReadWithNamedParameters() {
+    listener.reset();
+    String namedParamQuery =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = @corpus AND word_count >= @min_word_count ORDER BY word_count DESC";
+
+    Dataset<Row> df =
+        spark
+            .read()
+            .format("bigquery")
+            .option("query", namedParamQuery)
+            .option("viewsEnabled", "true")
+            .option("NamedParameters.corpus", "STRING:romeoandjuliet")
+            .option("NamedParameters.min_word_count", "INT64:250")
+            .load();
+
+    StructType expectedSchema =
+        DataTypes.createStructType(
+            ImmutableList.of(
+                DataTypes.createStructField("word", DataTypes.StringType, true),
+                DataTypes.createStructField("word_count", DataTypes.LongType, true)));
+    assertThat(df.schema()).isEqualTo(expectedSchema);
+
+    assertThat(df.count()).isGreaterThan(0L);
+
+    List<JobInfo> jobInfos = listener.getJobInfos();
+    assertThat(jobInfos).hasSize(1);
+    JobInfo jobInfo = jobInfos.iterator().next();
+    assertThat(jobInfo.getConfiguration().getType()).isEqualTo(JobConfiguration.Type.QUERY);
+    QueryJobConfiguration queryConfig = jobInfo.getConfiguration();
+    assertThat(queryConfig.getQuery()).isEqualTo(namedParamQuery);
+  }
+
+  @Test
+  public void testReadWithPositionalParameters() {
+    listener.reset();
+    String positionalParamQuery =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = ? AND word_count >= ? ORDER BY word_count DESC";
+
+    Dataset<Row> df =
+        spark
+            .read()
+            .format("bigquery")
+            .option("query", positionalParamQuery) // Use hardcoded query
+            .option("viewsEnabled", "true")
+            .option("PositionalParameters.1", "STRING:romeoandjuliet")
+            .option("PositionalParameters.2", "INT64:250")
+            .load();
+
+    StructType expectedSchema =
+        DataTypes.createStructType(
+            ImmutableList.of(
+                DataTypes.createStructField("word", DataTypes.StringType, true),
+                DataTypes.createStructField("word_count", DataTypes.LongType, true)));
+    assertThat(df.schema()).isEqualTo(expectedSchema);
+
+    assertThat(df.count()).isGreaterThan(0L);
+
+    List<JobInfo> jobInfos = listener.getJobInfos();
+    assertThat(jobInfos).hasSize(1);
+    JobInfo jobInfo = jobInfos.iterator().next();
+    assertThat(jobInfo.getConfiguration().getType()).isEqualTo(JobConfiguration.Type.QUERY);
+    QueryJobConfiguration queryConfig = jobInfo.getConfiguration();
+    assertThat(queryConfig.getQuery()).isEqualTo(positionalParamQuery);
+  }
+
+  @Test
+  public void testReadWithMixedParametersFails() {
+    String queryForFailure =
+        "SELECT word, word_count FROM `bigquery-public-data.samples.shakespeare`"
+            + " WHERE corpus = @corpus AND word_count >= @min_word_count ORDER BY word_count DESC";
+
+    Exception thrown =
+        assertThrows(
+            Exception.class,
+            () -> {
+              spark
+                  .read()
+                  .format("bigquery")
+                  .option("query", queryForFailure) // Use hardcoded query
+                  .option("viewsEnabled", "true")
+                  .option("NamedParameters.corpus", "STRING:whatever")
+                  .option("PositionalParameters.1", "INT64:100")
+                  .load()
+                  .show();
+            });
+
+    Throwable cause = thrown;
+    if (cause instanceof ProvisionException && cause.getCause() != null) {
path: spark-bigquery-connector-common/src/test/java/com/google/cloud/spark/bigquery/integration/ReadFromQueryIntegrationTestBase.java
user: login: BoqianShi
body: Provision Exception 
created_at: 2025-04-08T21:38:38Z
author_association: MEMBER

PR comments:
user: login: BoqianShi
created_at: 2025-04-02T22:13:51Z
body: /gcbrun
author_association: MEMBER
user: login: BoqianShi
created_at: 2025-04-03T01:16:45Z
body: /gcbrun
author_association: MEMBER
user: login: BoqianShi
created_at: 2025-04-09T22:26:06Z
body: /gcbrun
author_association: MEMBER