You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by li...@apache.org on 2026/07/07 08:22:00 UTC

(skywalking-banyandb) branch bydb-parameter created (now 524825475)

This is an automated email from the ASF dual-hosted git repository.

mrproliu pushed a change to branch bydb-parameter
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


      at 524825475 Introduce parameter binding (`?` placeholders) into BydbQL

This branch includes the following new commits:

     new 524825475 Introduce parameter binding (`?` placeholders) into BydbQL

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(skywalking-banyandb) 01/01: Introduce parameter binding (`?` placeholders) into BydbQL

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

mrproliu pushed a commit to branch bydb-parameter
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit 524825475457372b30e0e06a265768e10fe0ca26
Author: mrproliu <74...@qq.com>
AuthorDate: Tue Jul 7 16:21:42 2026 +0800

    Introduce parameter binding (`?` placeholders) into BydbQL
---
 CHANGES.md                                      |   1 +
 api/proto/banyandb/bydbql/v1/query.proto        |   8 +
 banyand/liaison/grpc/bydbql.go                  |   5 +-
 banyand/liaison/grpc/bydbql_test.go             |  90 ++++
 docs/api-reference.md                           |   1 +
 docs/interacting/bydbql.md                      |  73 +++
 mcp/src/client/index.ts                         |   7 +-
 mcp/src/client/types.ts                         |  12 +
 mcp/src/query/llm-prompt.ts                     |   1 +
 mcp/src/query/validation.ts                     |  82 ++++
 mcp/src/server/mcp.ts                           |  28 +-
 pkg/bydbql/binder.go                            | 311 +++++++++++++
 pkg/bydbql/binder_equivalence_test.go           | 413 +++++++++++++++++
 pkg/bydbql/binder_test.go                       | 573 ++++++++++++++++++++++++
 pkg/bydbql/grammar.go                           |  16 +-
 pkg/bydbql/parser.go                            |   1 +
 pkg/bydbql/transformer.go                       |   5 +
 pkg/test/helpers/bydbql.go                      |  63 +++
 test/cases/measure/data/data.go                 |  18 +-
 test/cases/measure/data/input/params_bind.ql    |  22 +
 test/cases/measure/data/input/params_bind.yaml  |  30 ++
 test/cases/measure/measure.go                   |   1 +
 test/cases/property/data/data.go                |  18 +-
 test/cases/property/data/input/params_bind.ql   |  21 +
 test/cases/property/data/input/params_bind.yaml |  30 ++
 test/cases/property/property.go                 |   1 +
 test/cases/stream/data/data.go                  |  16 +-
 test/cases/stream/data/input/params_bind.ql     |  21 +
 test/cases/stream/data/input/params_bind.yaml   |  40 ++
 test/cases/stream/stream.go                     |   1 +
 test/cases/topn/data/data.go                    |  16 +-
 test/cases/topn/topn.go                         |   1 +
 test/cases/trace/data/data.go                   |  18 +-
 test/cases/trace/data/input/params_bind.ql      |  24 +
 test/cases/trace/data/input/params_bind.yml     |  40 ++
 test/cases/trace/trace.go                       |   2 +
 test/cases/tracepipeline/data/data.go           |  18 +-
 ui/src/components/BydbQL/Index.vue              | 135 +++++-
 38 files changed, 2077 insertions(+), 86 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index bcaee768d..57f6be0a6 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -74,6 +74,7 @@ Release Notes.
 - Add the storage-node in-merge trace retention filter (`PIPELINE_EVENT_MERGE`): a per-group sampler chain evaluates traces at merge time and drops non-retained traces from both the core part and the coupled sidx (`sidx.Merge` gains a keep predicate; a nil predicate restores the wholesale merge). Chain execution fails open on panic/error/length-mismatch/timeout, with a consecutive-timeout circuit breaker. Samplers are native Go plugins (`.so`) loaded at startup from `--trace-pipeline-con [...]
 - Register/update/remove in-merge trace retention samplers at runtime without restarting the data node, replacing the static `--trace-pipeline-config` boot loader (now removed). The sampler pipeline config is carried on `common.v1.Group` (new optional `pipeline` field, with `TracePipelineConfig`/`SamplerPlugin` relocated from `pipeline/v1` into the leaf `common/v1` to avoid a `common`→`pipeline` import cycle) and delivered through the existing `KindGroup` schema watch; the data node rebu [...]
 - Add FODC memory-pressure pprof capture: agent grabs heap/goroutine profiles when RSS nears the cgroup limit, served via the proxy.
+- Introduce positional parameter binding (`?` placeholders) into BydbQL to eliminate QL injection.
 
 ### Bug Fixes
 
diff --git a/api/proto/banyandb/bydbql/v1/query.proto b/api/proto/banyandb/bydbql/v1/query.proto
index 66ff0324d..2a0c5ee84 100644
--- a/api/proto/banyandb/bydbql/v1/query.proto
+++ b/api/proto/banyandb/bydbql/v1/query.proto
@@ -21,6 +21,7 @@ package banyandb.bydbql.v1;
 
 import "banyandb/measure/v1/query.proto";
 import "banyandb/measure/v1/topn.proto";
+import "banyandb/model/v1/common.proto";
 import "banyandb/property/v1/rpc.proto";
 import "banyandb/stream/v1/query.proto";
 import "banyandb/trace/v1/query.proto";
@@ -33,6 +34,13 @@ option java_package = "org.apache.skywalking.banyandb.bydbql.v1";
 message QueryRequest {
   // query is the BydbQL query string
   string query = 1 [(validate.rules).string.min_len = 1];
+  // params are bound to the `?` placeholders in the query string by their order of appearance.
+  // The number of params must match the number of placeholders.
+  // Accepted variants depend on the placeholder's position: str/timestamp for TIME values,
+  // str/int/null for scalar comparisons, str/int/null/str_array/int_array for IN/MATCH/HAVING
+  // value lists (arrays expand in place), and int for LIMIT/OFFSET/TOP N counts.
+  // binary_data is rejected everywhere.
+  repeated model.v1.TagValue params = 2;
 }
 
 // QueryResponse contains the result of a BydbQL query
diff --git a/banyand/liaison/grpc/bydbql.go b/banyand/liaison/grpc/bydbql.go
index a1d9c137f..e22420736 100644
--- a/banyand/liaison/grpc/bydbql.go
+++ b/banyand/liaison/grpc/bydbql.go
@@ -80,12 +80,15 @@ func (b *bydbQLService) Query(ctx context.Context, req *bydbqlv1.QueryRequest) (
 		}
 	}()
 
-	// parse query and transform to native request
+	// parse query, bind parameters, and transform to native request
 	parseStart := time.Now()
 	query, err := bydbql.ParseQuery(req.Query)
 	if err != nil {
 		return nil, status.Errorf(codes.InvalidArgument, "failed to parse query: %v", err)
 	}
+	if err = bydbql.BindParams(query, req.Params); err != nil {
+		return nil, status.Errorf(codes.InvalidArgument, "failed to bind parameters: %v", err)
+	}
 	result, err := b.transformer.Transform(ctx, query)
 	if err != nil {
 		return nil, status.Errorf(codes.Internal, "failed to transform to native request: %v", err)
diff --git a/banyand/liaison/grpc/bydbql_test.go b/banyand/liaison/grpc/bydbql_test.go
new file mode 100644
index 000000000..2d91de7f2
--- /dev/null
+++ b/banyand/liaison/grpc/bydbql_test.go
@@ -0,0 +1,90 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 grpc
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	bydbqlv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/bydbql/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+func newTestBydbQLService() *bydbQLService {
+	return &bydbQLService{metrics: newBypassMetrics()}
+}
+
+func bydbqlStrParam(v string) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: v}}}
+}
+
+func TestBydbQLQuery_ParseError_ReturnsInvalidArgument(t *testing.T) {
+	svc := newTestBydbQLService()
+	_, err := svc.Query(context.Background(), &bydbqlv1.QueryRequest{Query: "NOT A QUERY"})
+	require.Error(t, err)
+	st, ok := status.FromError(err)
+	require.True(t, ok)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+	assert.Contains(t, st.Message(), "failed to parse query")
+}
+
+func TestBydbQLQuery_MissingParams_ReturnsInvalidArgument(t *testing.T) {
+	svc := newTestBydbQLService()
+	_, err := svc.Query(context.Background(), &bydbqlv1.QueryRequest{
+		Query: "SELECT * FROM STREAM sw IN default WHERE service_id = ?",
+	})
+	require.Error(t, err)
+	st, ok := status.FromError(err)
+	require.True(t, ok)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+	assert.Contains(t, st.Message(), "failed to bind parameters")
+	assert.Contains(t, st.Message(), "parameter count mismatch")
+}
+
+func TestBydbQLQuery_ExtraParams_ReturnsInvalidArgument(t *testing.T) {
+	svc := newTestBydbQLService()
+	_, err := svc.Query(context.Background(), &bydbqlv1.QueryRequest{
+		Query:  "SELECT * FROM STREAM sw IN default",
+		Params: []*modelv1.TagValue{bydbqlStrParam("unused")},
+	})
+	require.Error(t, err)
+	st, ok := status.FromError(err)
+	require.True(t, ok)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+	assert.Contains(t, st.Message(), "0 placeholder(s) but 1 parameter(s)")
+}
+
+func TestBydbQLQuery_ParamTypeMismatch_ReturnsInvalidArgument(t *testing.T) {
+	svc := newTestBydbQLService()
+	_, err := svc.Query(context.Background(), &bydbqlv1.QueryRequest{
+		Query: "SELECT * FROM STREAM sw IN default WHERE service_id = ?",
+		Params: []*modelv1.TagValue{
+			{Value: &modelv1.TagValue_StrArray{StrArray: &modelv1.StrArray{Value: []string{"a", "b"}}}},
+		},
+	})
+	require.Error(t, err)
+	st, ok := status.FromError(err)
+	require.True(t, ok)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+	assert.Contains(t, st.Message(), "failed to bind parameters")
+}
diff --git a/docs/api-reference.md b/docs/api-reference.md
index bc91b28c7..e9093d351 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -2117,6 +2117,7 @@ QueryRequest is the main request message for BydbQL queries
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | query | [string](#string) |  | query is the BydbQL query string |
+| params | [banyandb.model.v1.TagValue](#banyandb-model-v1-TagValue) | repeated | params are bound to the `?` placeholders in the query string by their order of appearance. The number of params must match the number of placeholders. Accepted variants depend on the placeholder&#39;s position: str/timestamp for TIME values, str/int/null for scalar comparisons, str/int/null/str_array/int_array for IN/MATCH/HAVING value lists (arrays expand in place), and int for LIMIT/OFFSET/TOP N counts. b [...]
 
 
 
diff --git a/docs/interacting/bydbql.md b/docs/interacting/bydbql.md
index c6917ac75..257432854 100644
--- a/docs/interacting/bydbql.md
+++ b/docs/interacting/bydbql.md
@@ -34,11 +34,14 @@ BydbQL Query String
         ↓
 Abstract Syntax Tree (AST)
         ↓
+ Parameter Binding
+        ↓
    Transformer
 ```
 
 - **Lexer**: Breaks the query string into a sequence of tokens.
 - **Parser**: Builds an Abstract Syntax Tree (AST) from the tokens, validating the query's syntax.
+- **Parameter Binding**: Fills the `?` placeholders in the AST from the request's `params` (see section 2.6); it always runs, so a query holding unbound placeholders is rejected.
 - **Transformer**: Traverses the AST, performs semantic analysis using a schema, and transforms the AST into the appropriate target protobuf `Stream/Measure/Property/Traces/TopN Request` message.
 
 ### 2.2. Distinguishing Query Types
@@ -154,6 +157,76 @@ TIME < '-1d'
 
 The `parseTime` function automatically determines whether a timestamp is absolute (RFC3339) or relative (duration string) and converts it appropriately.
 
+## 2.6. Parameter Binding
+
+BydbQL supports positional parameter binding with the `?` placeholder, so untrusted values never need to be concatenated into the query string. This eliminates QL injection: a bound value is always treated as a plain value and can never alter the query structure.
+
+```sql
+SELECT trace_id, message
+FROM STREAM logs IN group1
+TIME > ?
+WHERE message MATCH(?)
+```
+
+Parameters are supplied alongside the query and are bound to the placeholders in their order of appearance. Each parameter is a `model.v1.TagValue`. Over HTTP:
+
+```json
+POST /api/v1/bydbql/query
+{
+  "query": "SELECT trace_id, message FROM STREAM logs IN group1 TIME > ? WHERE message MATCH(?)",
+  "params": [
+    {"str": {"value": "-30m"}},
+    {"str": {"value": "error"}}
+  ]
+}
+```
+
+### 2.6.1. Supported Positions
+
+Placeholders are accepted in *value* positions only. The complete list, with the parameter types each position accepts:
+
+| Position | Example | Accepted parameter types |
+|---|---|---|
+| `TIME` comparison value | `TIME > ?` | `str` (RFC3339, relative like `-30m`, or `now`), `timestamp` |
+| `TIME BETWEEN` boundaries | `TIME BETWEEN ? AND ?` | same as above; each boundary binds independently |
+| Scalar comparison value (`=`, `!=`, `>`, `>=`, `<`, `<=`) | `WHERE service_id = ?` | `str`, `int`, `null` |
+| `IN` / `NOT IN` value list | `IN (?, ?)`, `IN (?)`, `IN ('a', ?)` | `str`, `int`, `null`, `str_array`, `int_array` |
+| `MATCH` value list | `MATCH(?)`, `MATCH(?, 'analyzer')` | `str`, `int`, `null`, `str_array`, `int_array` |
+| `HAVING` / `NOT HAVING` values | `HAVING ?`, `HAVING (?)` | `str`, `int`, `null`, `str_array`, `int_array` |
+| `LIMIT` count | `LIMIT ?` | `int` only |
+| `OFFSET` count | `OFFSET ?` | `int` only |
+| `SHOW TOP N` count | `SHOW TOP ? FROM MEASURE ...` | `int` only |
+| `SELECT TOP N` projection count | `SELECT TOP ? field ...` | `int` only |
+
+Array expansion: an array parameter bound inside a value list expands in place — `WHERE service_id IN (?)` with a three-entry `str_array` behaves exactly like listing the three strings literally, and literals can be mixed around it (`IN ('a', ?, 'b')`). The container keeps the form a literal query would have: a single-value `HAVING ?` stays a single value unless the array holds multiple entries, while a parenthesized `HAVING (?)` stays a list regardless of the entry count.
+
+### 2.6.2. Unsupported Positions
+
+Placeholders in any position that determines the query *structure* rather than a compared *value* are rejected at parse time with a syntax error. Bound parameters therefore can never change the shape of a query, which is what makes binding injection-proof.
+
+| Category | Positions | Example (all syntax errors) |
+|---|---|---|
+| Resource identifiers | resource name, group name, stage name | `FROM STREAM ?`, `IN ?`, `ON ? STAGES` |
+| Column identifiers | projection columns, condition left-hand side, `ORDER BY` / `GROUP BY` columns, aggregate function arguments | `SELECT ?`, `WHERE ? = 'a'`, `ORDER BY ?`, `SUM(?)` |
+| Function options | `MATCH` analyzer and operator options | `MATCH('x', ?)` |
+| Structure | operators, `ASC`/`DESC`, keywords, doubled placeholders | `= ??` |
+
+Note that unlike some SQL implementations where `ORDER BY ?` silently degrades to a no-op constant sort, BydbQL rejects it outright.
+
+### 2.6.3. Type Rules Summary
+
+- `binary_data` parameters are rejected in every position (BydbQL has no binary literal).
+- `timestamp` parameters are accepted only in `TIME` positions and are validated: a missing or out-of-range timestamp is rejected at bind time.
+- An `int` parameter in a `TIME` position binds (matching an integer literal), but the transformer cannot parse a bare integer as a timestamp, so the query fails; use `str` or `timestamp` instead.
+- `int` parameters in `LIMIT`/`OFFSET`/`TOP N` positions must be within `[0, 2147483647]`; out-of-range values are rejected at bind time instead of silently wrapping.
+- Empty arrays, parameters without a value, and `nil` entries are rejected everywhere.
+- A `?` inside a quoted string literal is part of the string, not a placeholder: `WHERE msg = 'why?'` consumes no parameter.
+- After binding passes, values are checked against the tag schema by the same rules as literals: a `str` that cannot parse as an integer fails against an `INT` tag, while an `int` bound to a `STRING` tag is converted, exactly as if written literally.
+
+### 2.6.4. Errors
+
+The request fails with `InvalidArgument` when the number of parameters does not match the number of placeholders, or when a parameter type is not accepted at its placeholder's position. Error messages include the 1-based position of the offending parameter (the first `?` is parameter #1).
+
 ## 3. WHERE Statement
 
 The WHERE clause in BydbQL provides powerful filtering capabilities with support for various operators, logical expressions, and full-text search. It supports binary tree structures with proper operator precedence and parentheses grouping.
diff --git a/mcp/src/client/index.ts b/mcp/src/client/index.ts
index d94ff1a71..7b2b1d57d 100644
--- a/mcp/src/client/index.ts
+++ b/mcp/src/client/index.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import type { QueryRequest, QueryResponse, Group, ResourceMetadata } from './types.js';
+import type { QueryRequest, QueryResponse, Group, ResourceMetadata, TagValueParam } from './types.js';
 import { httpFetch } from '../utils/http.js';
 
 const maxErrorBodyLength = 2048;
@@ -69,10 +69,13 @@ export class BanyanDBClient {
   /**
    * Execute a BydbQL query and return the result as a formatted string.
    */
-  async query(bydbqlQuery: string): Promise<string> {
+  async query(bydbqlQuery: string, params?: TagValueParam[]): Promise<string> {
     const request: QueryRequest = {
       query: bydbqlQuery,
     };
+    if (params && params.length > 0) {
+      request.params = params;
+    }
 
     const url = `${this.baseUrl}/v1/bydbql/query`;
 
diff --git a/mcp/src/client/types.ts b/mcp/src/client/types.ts
index d643a8c71..635df6488 100644
--- a/mcp/src/client/types.ts
+++ b/mcp/src/client/types.ts
@@ -74,8 +74,20 @@ interface TopNResult {
   lists?: unknown[];
 }
 
+/**
+ * A single BydbQL parameter in protojson TagValue form,
+ * bound to a `?` placeholder by order of appearance.
+ */
+export type TagValueParam =
+  | { str: { value: string } }
+  | { int: { value: string } }
+  | { strArray: { value: string[] } }
+  | { intArray: { value: string[] } }
+  | { null: null };
+
 export interface QueryRequest {
   query: string;
+  params?: TagValueParam[];
 }
 
 export interface QueryResponse {
diff --git a/mcp/src/query/llm-prompt.ts b/mcp/src/query/llm-prompt.ts
index 8cbdc3d85..6b133abab 100644
--- a/mcp/src/query/llm-prompt.ts
+++ b/mcp/src/query/llm-prompt.ts
@@ -229,6 +229,7 @@ Processing Rules:
   - LIMIT clause: Extract ONLY when requesting number of data points/results (NOT time ranges)
   - AGGREGATE BY: Extract from keywords or preserve explicit clauses
   - ORDER BY: Extract ONLY when explicitly requested, BUT MUST validate field name against indexed fields list
+- Parameter Binding: BydbQL supports "?" placeholders in value positions (TIME values, WHERE/HAVING comparison values, IN/MATCH/HAVING value lists, and LIMIT/OFFSET/TOP N counts as int). When the query embeds untrusted or user-provided values, prefer placeholders and pass the values through the "params" argument of list_resources_bydbql instead of concatenating them into the query string.
 - CRITICAL Rules:
   - Do NOT add ORDER BY or LIMIT unless explicitly requested in the description
   - If description only mentions a time range (e.g., "list the last 3 days"), use ONLY TIME clause, no ORDER BY, no LIMIT
diff --git a/mcp/src/query/validation.ts b/mcp/src/query/validation.ts
index 2d91811c4..fd9e60f3f 100644
--- a/mcp/src/query/validation.ts
+++ b/mcp/src/query/validation.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import type { TagValueParam } from '../client/types.js';
+
 const maxDescriptionLength = 2048;
 const maxIdentifierLength = 256;
 const maxBydbQLLength = 4096;
@@ -24,9 +26,21 @@ const allowedQueryHintResourceTypes = ['stream', 'measure', 'trace', 'property']
 const disallowedQueryTokenPatterns = [/[;]/, /--/, /\/\*/, /\*\//];
 const allowedQueryPrefixPatterns = [/^\s*SELECT\b/i, /^\s*SHOW\s+TOP\b/i];
 
+const maxParamCount = 64;
+const maxParamValueLength = 4096;
+const maxParamArrayLength = 256;
+const allowedParamTypes = ['str', 'int', 'str_array', 'int_array', 'null'] as const;
+const integerPattern = /^-?\d+$/;
+
+export type BydbQLParam = {
+  type: (typeof allowedParamTypes)[number];
+  value?: unknown;
+};
+
 export type QueryHints = {
   description?: string;
   BydbQL?: string;
+  params?: BydbQLParam[];
   resource_type?: string;
   resource_name?: string;
   group?: string;
@@ -119,12 +133,77 @@ export function normalizeQueryHints(args: unknown): QueryHints {
   return {
     description: typeof rawArgs.description === 'string' ? rawArgs.description.trim() : undefined,
     BydbQL: typeof rawArgs.BydbQL === 'string' ? rawArgs.BydbQL.trim() : undefined,
+    params: Array.isArray(rawArgs.params) ? (rawArgs.params as BydbQLParam[]) : undefined,
     resource_type: typeof rawArgs.resource_type === 'string' ? rawArgs.resource_type.trim() : undefined,
     resource_name: typeof rawArgs.resource_name === 'string' ? rawArgs.resource_name.trim() : undefined,
     group: typeof rawArgs.group === 'string' ? rawArgs.group.trim() : undefined,
   };
 }
 
+function validateParamString(value: unknown, position: number): string {
+  if (typeof value !== 'string') {
+    throw new Error(`params[${position}]: str value must be a string`);
+  }
+  if (value.length > maxParamValueLength) {
+    throw new Error(`params[${position}]: value exceeds ${maxParamValueLength} characters`);
+  }
+  return value;
+}
+
+function validateParamInteger(value: unknown, position: number): string {
+  if (typeof value === 'number' && Number.isSafeInteger(value)) {
+    return String(value);
+  }
+  if (typeof value === 'string' && integerPattern.test(value)) {
+    return value;
+  }
+  throw new Error(`params[${position}]: int value must be an integer`);
+}
+
+function validateParamArray(value: unknown, position: number): unknown[] {
+  if (!Array.isArray(value) || value.length === 0) {
+    throw new Error(`params[${position}]: array value must be a non-empty array`);
+  }
+  if (value.length > maxParamArrayLength) {
+    throw new Error(`params[${position}]: array value exceeds ${maxParamArrayLength} entries`);
+  }
+  return value;
+}
+
+/**
+ * Validate BydbQL parameters and convert them to protojson TagValue form
+ * accepted by the BanyanDB HTTP API.
+ *
+ * Keep in sync with `buildBoundParams` in ui/src/components/BydbQL/Index.vue,
+ * which builds the same shapes independently (no shared package exists between
+ * mcp/ and ui/). The server-side `timestamp` variant is deliberately not exposed
+ * here: an RFC3339 `str` is equivalent for TIME positions.
+ */
+export function toTagValueParams(params: BydbQLParam[]): TagValueParam[] {
+  if (params.length > maxParamCount) {
+    throw new Error(`params exceeds ${maxParamCount} entries`);
+  }
+  return params.map((param, position) => {
+    if (!param || typeof param !== 'object' || typeof param.type !== 'string') {
+      throw new Error(`params[${position}]: each parameter must be an object with a "type" field`);
+    }
+    switch (param.type) {
+      case 'str':
+        return { str: { value: validateParamString(param.value, position) } };
+      case 'int':
+        return { int: { value: validateParamInteger(param.value, position) } };
+      case 'str_array':
+        return { strArray: { value: validateParamArray(param.value, position).map((entry) => validateParamString(entry, position)) } };
+      case 'int_array':
+        return { intArray: { value: validateParamArray(param.value, position).map((entry) => validateParamInteger(entry, position)) } };
+      case 'null':
+        return { null: null };
+      default:
+        throw new Error(`params[${position}]: type must be one of: ${allowedParamTypes.join(', ')}`);
+    }
+  });
+}
+
 export function validateListGroupsArgs(args: unknown): ListGroupsArgs {
   if (!args || typeof args !== 'object') {
     throw new Error('tool arguments must be an object');
@@ -161,6 +240,9 @@ export function validateQueryHints(queryHints: QueryHints): QueryHints {
   if (queryHints.BydbQL) {
     validatedHints.BydbQL = validateBydbQL(queryHints.BydbQL);
   }
+  if (queryHints.params) {
+    validatedHints.params = queryHints.params;
+  }
   if (queryHints.resource_type) {
     validatedHints.resource_type = validateQueryHintResourceType(queryHints.resource_type);
   }
diff --git a/mcp/src/server/mcp.ts b/mcp/src/server/mcp.ts
index 1f237061b..3edaae6f7 100644
--- a/mcp/src/server/mcp.ts
+++ b/mcp/src/server/mcp.ts
@@ -23,7 +23,7 @@ import { BanyanDBClient, ResourceMetadata } from '../client/index.js';
 import { MAX_TOOL_RESPONSE_LENGTH } from '../config.js';
 import { loadQueryContext } from '../query/context.js';
 import { generateBydbQL } from '../query/llm-prompt.js';
-import { normalizeQueryHints, validateListGroupsArgs, validateQueryHints } from '../query/validation.js';
+import { normalizeQueryHints, toTagValueParams, validateListGroupsArgs, validateQueryHints } from '../query/validation.js';
 
 export function truncateText(text: string, maxLength: number): string {
   if (text.length <= maxLength) {
@@ -78,7 +78,28 @@ function buildListToolsResponse() {
           properties: {
             BydbQL: {
               type: 'string',
-              description: 'BydbQL query to execute against BanyanDB.',
+              description: 'BydbQL query to execute against BanyanDB. May contain `?` placeholders bound by the params array.',
+            },
+            params: {
+              type: 'array',
+              description:
+                'Positional parameters bound to `?` placeholders in the query, in order of appearance. ' +
+                'Always pass untrusted or user-provided values here instead of concatenating them into the query string.',
+              items: {
+                type: 'object',
+                properties: {
+                  type: {
+                    type: 'string',
+                    description: 'Parameter type',
+                    enum: ['str', 'int', 'str_array', 'int_array', 'null'],
+                  },
+                  value: {
+                    description:
+                      'Parameter value: a string for "str", an integer for "int", an array for "str_array"/"int_array"; omit for "null".',
+                  },
+                },
+                required: ['type'],
+              },
             },
             resource_type: {
               type: 'string',
@@ -183,9 +204,10 @@ async function handleListResourcesBydbql(banyandbClient: BanyanDBClient, args: u
   if (!queryHints.BydbQL) {
     throw new Error('BydbQL is required');
   }
+  const params = queryHints.params ? toTagValueParams(queryHints.params) : undefined;
 
   try {
-    const result = await banyandbClient.query(queryHints.BydbQL);
+    const result = await banyandbClient.query(queryHints.BydbQL, params);
     const debugParts: string[] = [];
 
     if (queryHints.resource_type) debugParts.push(`Resource Type: ${queryHints.resource_type}`);
diff --git a/pkg/bydbql/binder.go b/pkg/bydbql/binder.go
new file mode 100644
index 000000000..32d10df25
--- /dev/null
+++ b/pkg/bydbql/binder.go
@@ -0,0 +1,311 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 bydbql
+
+import (
+	"fmt"
+	"math"
+	"time"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// BindParams binds positional parameters to the `?` placeholders in the parsed grammar,
+// in their order of appearance in the query text. After a successful call, the grammar is
+// indistinguishable from one parsed with literal values, so the transformer needs no
+// placeholder awareness. Callers must invoke it before Transform even when params is empty,
+// so a query holding unbound placeholders is rejected instead of silently misinterpreted.
+// Parameter positions in error messages are 1-based. On error the grammar may be partially
+// bound and must be discarded; binding is not retryable on the same grammar.
+func BindParams(g *Grammar, params []*modelv1.TagValue) error {
+	b := &binder{}
+	b.collect(g)
+	if len(b.slots) != len(params) {
+		return fmt.Errorf("parameter count mismatch: query contains %d placeholder(s) but %d parameter(s) provided", len(b.slots), len(params))
+	}
+	for idx, slot := range b.slots {
+		if params[idx].GetValue() == nil {
+			return fmt.Errorf("parameter #%d has no value", idx+1)
+		}
+		if bindErr := slot(params[idx]); bindErr != nil {
+			return fmt.Errorf("failed to bind parameter #%d: %w", idx+1, bindErr)
+		}
+	}
+	b.expandLists()
+	return nil
+}
+
+// countUnboundParams reports how many `?` placeholders in the grammar are still unbound.
+func countUnboundParams(g *Grammar) int {
+	b := &binder{}
+	b.collect(g)
+	return len(b.slots)
+}
+
+type listContainer struct {
+	assign func([]*GrammarValue)
+	values []*GrammarValue
+}
+
+type binder struct {
+	expansions map[*GrammarValue][]*GrammarValue
+	slots      []func(*modelv1.TagValue) error
+	lists      []listContainer
+}
+
+// collect gathers placeholder slots in their order of appearance in the query
+// text: the grammar fixes the clause order and expression trees are visited
+// depth-first left-to-right, so the slot order needs no re-sorting.
+func (b *binder) collect(g *Grammar) {
+	if g.Select != nil {
+		if g.Select.Projection != nil && g.Select.Projection.TopN != nil {
+			topN := g.Select.Projection.TopN
+			b.collectIntSlot(&topN.N, &topN.NParam)
+		}
+		b.collectTime(g.Select.Time)
+		if g.Select.Where != nil {
+			b.collectOrExpr(g.Select.Where.Expr)
+		}
+		if g.Select.Limit != nil {
+			b.collectIntSlot(&g.Select.Limit.Value, &g.Select.Limit.Param)
+		}
+		if g.Select.Offset != nil {
+			b.collectIntSlot(&g.Select.Offset.Value, &g.Select.Offset.Param)
+		}
+	}
+	if g.TopN != nil {
+		b.collectIntSlot(&g.TopN.N, &g.TopN.NParam)
+		b.collectTime(g.TopN.Time)
+		if g.TopN.Where != nil {
+			b.collectAndExpr(g.TopN.Where.Expr)
+		}
+	}
+}
+
+// collectIntSlot registers a placeholder in an integer-only position
+// (LIMIT, OFFSET, or a TOP N count).
+func (b *binder) collectIntSlot(value *int, param *bool) {
+	if !*param {
+		return
+	}
+	b.slots = append(b.slots, func(p *modelv1.TagValue) error {
+		intVal, ok := p.Value.(*modelv1.TagValue_Int)
+		if !ok {
+			return fmt.Errorf("this position only accepts int parameters, got %T", p.Value)
+		}
+		// The transformer narrows these counts to int32/uint32; reject values
+		// that would silently wrap instead of letting the cast corrupt them.
+		bound := intVal.Int.GetValue()
+		if bound < 0 || bound > math.MaxInt32 {
+			return fmt.Errorf("int parameter %d is out of range [0, %d] for this position", bound, math.MaxInt32)
+		}
+		*value = int(bound)
+		*param = false
+		return nil
+	})
+}
+
+func (b *binder) collectTime(clause *GrammarTimeClause) {
+	if clause == nil {
+		return
+	}
+	b.collectTimeValue(clause.Value)
+	if clause.Between != nil {
+		b.collectTimeValue(clause.Between.Begin)
+		b.collectTimeValue(clause.Between.End)
+	}
+}
+
+func (b *binder) collectTimeValue(v *GrammarTimeValue) {
+	if v == nil || !v.Param {
+		return
+	}
+	b.slots = append(b.slots, func(p *modelv1.TagValue) error { return bindTimeValue(v, p) })
+}
+
+func (b *binder) collectOrExpr(expr *GrammarOrExpr) {
+	if expr == nil {
+		return
+	}
+	b.collectAndExpr(expr.Left)
+	for _, right := range expr.Right {
+		b.collectAndExpr(right.Right)
+	}
+}
+
+func (b *binder) collectAndExpr(expr *GrammarAndExpr) {
+	if expr == nil {
+		return
+	}
+	b.collectPredicate(expr.Left)
+	for _, right := range expr.Right {
+		b.collectPredicate(right.Right)
+	}
+}
+
+func (b *binder) collectPredicate(pred *GrammarPredicate) {
+	if pred == nil {
+		return
+	}
+	switch {
+	case pred.Paren != nil:
+		b.collectOrExpr(pred.Paren)
+	case pred.Binary != nil && pred.Binary.Tail != nil:
+		if compare := pred.Binary.Tail.Compare; compare != nil {
+			b.collectScalarValue(compare.Value)
+		}
+		if match := pred.Binary.Tail.Match; match != nil && match.Values != nil {
+			b.collectSingleOrArray(&match.Values.Single, &match.Values.Array)
+		}
+	case pred.In != nil:
+		inPred := pred.In
+		b.collectValueList(inPred.Values, func(vs []*GrammarValue) { inPred.Values = vs })
+	case pred.Having != nil && pred.Having.Values != nil:
+		b.collectSingleOrArray(&pred.Having.Values.Single, &pred.Having.Values.Array)
+	}
+}
+
+// collectSingleOrArray collects placeholders from a MATCH/HAVING container and
+// splices bound values back preserving its original form: a single-value
+// container stays single unless an array expansion produced multiple values,
+// while a parenthesized list stays a list regardless of the value count,
+// exactly as if written literally.
+func (b *binder) collectSingleOrArray(single **GrammarValue, array *[]*GrammarValue) {
+	wasSingle := *single != nil
+	values := *array
+	if wasSingle {
+		values = []*GrammarValue{*single}
+	}
+	b.collectValueList(values, func(vs []*GrammarValue) {
+		if wasSingle && len(vs) == 1 {
+			*single, *array = vs[0], nil
+			return
+		}
+		*single, *array = nil, vs
+	})
+}
+
+func (b *binder) collectScalarValue(v *GrammarValue) {
+	if v == nil || !v.Param {
+		return
+	}
+	b.slots = append(b.slots, func(p *modelv1.TagValue) error { return bindScalarValue(v, p) })
+}
+
+func (b *binder) collectValueList(values []*GrammarValue, assign func([]*GrammarValue)) {
+	hasParam := false
+	for _, v := range values {
+		if v == nil || !v.Param {
+			continue
+		}
+		hasParam = true
+		b.slots = append(b.slots, func(p *modelv1.TagValue) error { return b.bindListValue(v, p) })
+	}
+	if hasParam {
+		b.lists = append(b.lists, listContainer{values: values, assign: assign})
+	}
+}
+
+func (b *binder) bindListValue(v *GrammarValue, p *modelv1.TagValue) error {
+	switch val := p.Value.(type) {
+	case *modelv1.TagValue_Str, *modelv1.TagValue_Int, *modelv1.TagValue_Null:
+		return bindScalarValue(v, p)
+	case *modelv1.TagValue_StrArray:
+		if len(val.StrArray.GetValue()) == 0 {
+			return fmt.Errorf("array parameter must not be empty")
+		}
+		expanded := make([]*GrammarValue, 0, len(val.StrArray.GetValue()))
+		for _, s := range val.StrArray.GetValue() {
+			expanded = append(expanded, &GrammarValue{String: &s})
+		}
+		b.recordExpansion(v, expanded)
+	case *modelv1.TagValue_IntArray:
+		if len(val.IntArray.GetValue()) == 0 {
+			return fmt.Errorf("array parameter must not be empty")
+		}
+		expanded := make([]*GrammarValue, 0, len(val.IntArray.GetValue()))
+		for _, i := range val.IntArray.GetValue() {
+			expanded = append(expanded, &GrammarValue{Integer: &i})
+		}
+		b.recordExpansion(v, expanded)
+	default:
+		return fmt.Errorf("value list only accepts str, int, null, str_array, or int_array parameters, got %T", p.Value)
+	}
+	return nil
+}
+
+func (b *binder) recordExpansion(v *GrammarValue, expanded []*GrammarValue) {
+	if b.expansions == nil {
+		b.expansions = make(map[*GrammarValue][]*GrammarValue)
+	}
+	b.expansions[v] = expanded
+}
+
+func (b *binder) expandLists() {
+	for _, list := range b.lists {
+		rebuilt := make([]*GrammarValue, 0, len(list.values))
+		for _, v := range list.values {
+			if expanded, ok := b.expansions[v]; ok {
+				rebuilt = append(rebuilt, expanded...)
+				continue
+			}
+			rebuilt = append(rebuilt, v)
+		}
+		list.assign(rebuilt)
+	}
+}
+
+func bindTimeValue(v *GrammarTimeValue, p *modelv1.TagValue) error {
+	switch val := p.Value.(type) {
+	case *modelv1.TagValue_Str:
+		strVal := val.Str.GetValue()
+		v.String = &strVal
+	case *modelv1.TagValue_Int:
+		intVal := val.Int.GetValue()
+		v.Integer = &intVal
+	case *modelv1.TagValue_Timestamp:
+		// A nil inner timestamp would silently decode as the Unix epoch and an
+		// out-of-range one would format as a nonsense year; reject both here.
+		if err := val.Timestamp.CheckValid(); err != nil {
+			return fmt.Errorf("invalid timestamp parameter: %w", err)
+		}
+		strVal := val.Timestamp.AsTime().Format(time.RFC3339Nano)
+		v.String = &strVal
+	default:
+		return fmt.Errorf("time clause only accepts str, int, or timestamp parameters, got %T", p.Value)
+	}
+	v.Param = false
+	return nil
+}
+
+func bindScalarValue(v *GrammarValue, p *modelv1.TagValue) error {
+	switch val := p.Value.(type) {
+	case *modelv1.TagValue_Str:
+		strVal := val.Str.GetValue()
+		v.String = &strVal
+	case *modelv1.TagValue_Int:
+		intVal := val.Int.GetValue()
+		v.Integer = &intVal
+	case *modelv1.TagValue_Null:
+		v.Null = true
+	default:
+		return fmt.Errorf("this position only accepts str, int, or null parameters, got %T", p.Value)
+	}
+	v.Param = false
+	return nil
+}
diff --git a/pkg/bydbql/binder_equivalence_test.go b/pkg/bydbql/binder_equivalence_test.go
new file mode 100644
index 000000000..bc63fc683
--- /dev/null
+++ b/pkg/bydbql/binder_equivalence_test.go
@@ -0,0 +1,413 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 bydbql_test
+
+import (
+	"context"
+	"time"
+
+	"github.com/alecthomas/participle/v2/lexer"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"go.uber.org/mock/gomock"
+	"google.golang.org/protobuf/testing/protocmp"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+	. "github.com/apache/skywalking-banyandb/pkg/bydbql"
+)
+
+// equivalenceCase describes a parameterized query that must be indistinguishable,
+// both at the AST level and at the native query request level, from the same
+// query written with literal values.
+type equivalenceCase struct {
+	name          string
+	parameterized string
+	literal       string
+	params        []*modelv1.TagValue
+	// ignoreTimeRange skips the time_range comparison for queries whose range
+	// depends on the wall clock at transform time (relative times, open-ended comparators).
+	ignoreTimeRange bool
+	// expectError marks cases whose transform must fail on BOTH sides with the
+	// same message; without it a case reaching the error branch fails the test,
+	// so no case can pass vacuously.
+	expectError bool
+}
+
+func equivalenceStream() *databasev1.Stream {
+	return &databasev1.Stream{
+		Metadata: &commonv1.Metadata{Name: "sw", Group: "default"},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "searchable",
+			Tags: []*databasev1.TagSpec{
+				{Name: "service_id", Type: databasev1.TagType_TAG_TYPE_STRING},
+				{Name: "message", Type: databasev1.TagType_TAG_TYPE_STRING},
+				{Name: "tags", Type: databasev1.TagType_TAG_TYPE_STRING_ARRAY},
+				{Name: "duration", Type: databasev1.TagType_TAG_TYPE_INT},
+				{Name: "codes", Type: databasev1.TagType_TAG_TYPE_INT_ARRAY},
+				{Name: "created_at", Type: databasev1.TagType_TAG_TYPE_TIMESTAMP},
+			},
+		}},
+	}
+}
+
+func equivalenceCases() []equivalenceCase {
+	base := "SELECT * FROM STREAM sw IN default "
+	return []equivalenceCase{
+		// TIME clause: every accepted parameter type
+		{
+			name:            "str relative time in TIME >",
+			parameterized:   base + "TIME > ?",
+			literal:         base + "TIME > '-30m'",
+			params:          []*modelv1.TagValue{strParam("-30m")},
+			ignoreTimeRange: true,
+		},
+		{
+			name:          "str absolute times in TIME BETWEEN",
+			parameterized: base + "TIME BETWEEN ? AND ?",
+			literal:       base + "TIME BETWEEN '2026-07-06T10:00:00Z' AND '2026-07-06T11:00:00Z'",
+			params:        []*modelv1.TagValue{strParam("2026-07-06T10:00:00Z"), strParam("2026-07-06T11:00:00Z")},
+		},
+		{
+			name:          "timestamp params in TIME BETWEEN",
+			parameterized: base + "TIME BETWEEN ? AND ?",
+			literal:       base + "TIME BETWEEN '2026-07-06T10:00:00Z' AND '2026-07-06T11:00:00Z'",
+			params: []*modelv1.TagValue{
+				timestampParam(time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)),
+				timestampParam(time.Date(2026, 7, 6, 11, 0, 0, 0, time.UTC)),
+			},
+		},
+		{
+			// An int TIME value is grammatically valid but the transformer cannot
+			// parse a bare integer as a timestamp; both sides must fail identically.
+			name:          "int param in TIME >",
+			parameterized: base + "TIME > ?",
+			literal:       base + "TIME > 1720000000",
+			params:        []*modelv1.TagValue{intParam(1720000000)},
+			expectError:   true,
+		},
+		// Scalar comparisons: every operator, every accepted parameter type
+		{
+			name:          "str param in =",
+			parameterized: base + "WHERE service_id = ?",
+			literal:       base + "WHERE service_id = 'webapp'",
+			params:        []*modelv1.TagValue{strParam("webapp")},
+		},
+		{
+			name:          "str param in !=",
+			parameterized: base + "WHERE service_id != ?",
+			literal:       base + "WHERE service_id != 'webapp'",
+			params:        []*modelv1.TagValue{strParam("webapp")},
+		},
+		{
+			name:          "int param in >",
+			parameterized: base + "WHERE duration > ?",
+			literal:       base + "WHERE duration > 100",
+			params:        []*modelv1.TagValue{intParam(100)},
+		},
+		{
+			name:          "int param in >=",
+			parameterized: base + "WHERE duration >= ?",
+			literal:       base + "WHERE duration >= 100",
+			params:        []*modelv1.TagValue{intParam(100)},
+		},
+		{
+			name:          "int param in <",
+			parameterized: base + "WHERE duration < ?",
+			literal:       base + "WHERE duration < 100",
+			params:        []*modelv1.TagValue{intParam(100)},
+		},
+		{
+			name:          "int param in <=",
+			parameterized: base + "WHERE duration <= ?",
+			literal:       base + "WHERE duration <= 100",
+			params:        []*modelv1.TagValue{intParam(100)},
+		},
+		{
+			name:          "null param in =",
+			parameterized: base + "WHERE service_id = ?",
+			literal:       base + "WHERE service_id = NULL",
+			params:        []*modelv1.TagValue{nullParam()},
+		},
+		// IN predicate
+		{
+			name:          "two str params in IN",
+			parameterized: base + "WHERE service_id IN (?, ?)",
+			literal:       base + "WHERE service_id IN ('a', 'b')",
+			params:        []*modelv1.TagValue{strParam("a"), strParam("b")},
+		},
+		{
+			name:          "str_array param expands in IN",
+			parameterized: base + "WHERE service_id IN (?)",
+			literal:       base + "WHERE service_id IN ('a', 'b')",
+			params:        []*modelv1.TagValue{strArrayParam("a", "b")},
+		},
+		{
+			name:          "int_array param expands in IN",
+			parameterized: base + "WHERE duration IN (?)",
+			literal:       base + "WHERE duration IN (100, 200)",
+			params:        []*modelv1.TagValue{intArrayParam(100, 200)},
+		},
+		{
+			name:          "str_array param expands between literals in IN",
+			parameterized: base + "WHERE service_id IN ('a', ?, 'd')",
+			literal:       base + "WHERE service_id IN ('a', 'b', 'c', 'd')",
+			params:        []*modelv1.TagValue{strArrayParam("b", "c")},
+		},
+		{
+			name:          "two array params and a scalar param expand in one IN list",
+			parameterized: base + "WHERE service_id IN (?, ?, ?)",
+			literal:       base + "WHERE service_id IN ('a', 'b', 'c', 'd')",
+			params:        []*modelv1.TagValue{strArrayParam("a", "b"), strParam("c"), strArrayParam("d")},
+		},
+		{
+			name:          "array params expand in two lists of one query",
+			parameterized: base + "WHERE service_id IN (?) AND codes HAVING (?)",
+			literal:       base + "WHERE service_id IN ('a', 'b') AND codes HAVING (200, 500)",
+			params:        []*modelv1.TagValue{strArrayParam("a", "b"), intArrayParam(200, 500)},
+		},
+		{
+			name:          "str param in NOT IN",
+			parameterized: base + "WHERE service_id NOT IN (?)",
+			literal:       base + "WHERE service_id NOT IN ('a')",
+			params:        []*modelv1.TagValue{strParam("a")},
+		},
+		// MATCH predicate
+		{
+			name:          "str param in MATCH",
+			parameterized: base + "WHERE message MATCH(?)",
+			literal:       base + "WHERE message MATCH('error')",
+			params:        []*modelv1.TagValue{strParam("error")},
+		},
+		{
+			name:          "str_array param expands in MATCH",
+			parameterized: base + "WHERE message MATCH(?)",
+			literal:       base + "WHERE message MATCH(('error', 'warn'))",
+			params:        []*modelv1.TagValue{strArrayParam("error", "warn")},
+		},
+		{
+			name:          "str param in MATCH keeps literal analyzer",
+			parameterized: base + "WHERE message MATCH(?, 'simple')",
+			literal:       base + "WHERE message MATCH('error', 'simple')",
+			params:        []*modelv1.TagValue{strParam("error")},
+		},
+		// HAVING predicate
+		{
+			name:          "str param in HAVING",
+			parameterized: base + "WHERE tags HAVING ?",
+			literal:       base + "WHERE tags HAVING 'blue'",
+			params:        []*modelv1.TagValue{strParam("blue")},
+		},
+		{
+			name:          "int_array param expands in HAVING",
+			parameterized: base + "WHERE codes HAVING (?)",
+			literal:       base + "WHERE codes HAVING (200, 500)",
+			params:        []*modelv1.TagValue{intArrayParam(200, 500)},
+		},
+		{
+			name:          "str_array param expands in NOT HAVING",
+			parameterized: base + "WHERE tags NOT HAVING (?)",
+			literal:       base + "WHERE tags NOT HAVING ('red', 'green')",
+			params:        []*modelv1.TagValue{strArrayParam("red", "green")},
+		},
+		// Single value vs list form: the bound container must keep the exact
+		// form a literal query would have, because the transformer emits a
+		// scalar condition for the single form and an array condition for the
+		// list form.
+		{
+			name:          "one-element array in a parenthesized HAVING list stays a list",
+			parameterized: base + "WHERE codes HAVING (?)",
+			literal:       base + "WHERE codes HAVING (200)",
+			params:        []*modelv1.TagValue{intArrayParam(200)},
+		},
+		{
+			name:          "one-element array in a single HAVING value stays a single value",
+			parameterized: base + "WHERE tags HAVING ?",
+			literal:       base + "WHERE tags HAVING 'blue'",
+			params:        []*modelv1.TagValue{strArrayParam("blue")},
+		},
+		{
+			name:          "multi-element array in a single HAVING value becomes a list",
+			parameterized: base + "WHERE tags HAVING ?",
+			literal:       base + "WHERE tags HAVING ('a', 'b')",
+			params:        []*modelv1.TagValue{strArrayParam("a", "b")},
+		},
+		{
+			name:          "scalar in a parenthesized MATCH list stays a list",
+			parameterized: base + "WHERE message MATCH((?))",
+			literal:       base + "WHERE message MATCH(('error'))",
+			params:        []*modelv1.TagValue{strParam("error")},
+		},
+		// Parameter type vs tag schema type: mismatches must behave exactly like
+		// the same mismatch written as a literal — lenient conversion where the
+		// transformer converts, identical errors where it rejects.
+		{
+			name:          "int param on a STRING tag converts like an int literal",
+			parameterized: base + "WHERE service_id = ?",
+			literal:       base + "WHERE service_id = 123",
+			params:        []*modelv1.TagValue{intParam(123)},
+		},
+		{
+			name:          "numeric str param on an INT tag parses like a str literal",
+			parameterized: base + "WHERE duration > ?",
+			literal:       base + "WHERE duration > '100'",
+			params:        []*modelv1.TagValue{strParam("100")},
+		},
+		{
+			name:          "non-numeric str param on an INT tag fails like a str literal",
+			parameterized: base + "WHERE duration > ?",
+			literal:       base + "WHERE duration > 'abc'",
+			params:        []*modelv1.TagValue{strParam("abc")},
+			expectError:   true,
+		},
+		{
+			name:          "non-numeric str_array param on an INT_ARRAY tag fails like literals",
+			parameterized: base + "WHERE codes HAVING (?)",
+			literal:       base + "WHERE codes HAVING ('a', 'b')",
+			params:        []*modelv1.TagValue{strArrayParam("a", "b")},
+			expectError:   true,
+		},
+		{
+			name:          "int_array param on a STRING_ARRAY tag converts like int literals",
+			parameterized: base + "WHERE tags HAVING (?)",
+			literal:       base + "WHERE tags HAVING (1, 2)",
+			params:        []*modelv1.TagValue{intArrayParam(1, 2)},
+		},
+		{
+			name:          "param on a TIMESTAMP tag is rejected like a literal",
+			parameterized: base + "WHERE created_at = ?",
+			literal:       base + "WHERE created_at = '2026-07-06T10:00:00Z'",
+			params:        []*modelv1.TagValue{strParam("2026-07-06T10:00:00Z")},
+			expectError:   true,
+		},
+		{
+			name:          "param on an unknown tag fails like a literal",
+			parameterized: base + "WHERE nonexistent = ?",
+			literal:       base + "WHERE nonexistent = 'x'",
+			params:        []*modelv1.TagValue{strParam("x")},
+			expectError:   true,
+		},
+		// Combined clauses and nested expressions
+		{
+			name:            "params across TIME and nested OR/AND expressions",
+			parameterized:   base + "TIME > ? WHERE (service_id = ? OR duration > ?) AND message MATCH(?)",
+			literal:         base + "TIME > '-15m' WHERE (service_id = 'svc' OR duration > 42) AND message MATCH('boom')",
+			params:          []*modelv1.TagValue{strParam("-15m"), strParam("svc"), intParam(42), strParam("boom")},
+			ignoreTimeRange: true,
+		},
+		// Integer-only positions: LIMIT / OFFSET / TOP N counts
+		{
+			name:          "int params in LIMIT and OFFSET",
+			parameterized: base + "WHERE service_id = 'svc' LIMIT ? OFFSET ?",
+			literal:       base + "WHERE service_id = 'svc' LIMIT 10 OFFSET 5",
+			params:        []*modelv1.TagValue{intParam(10), intParam(5)},
+		},
+		{
+			name:          "int param in SELECT TOP N projection count",
+			parameterized: "SELECT TOP ? value, service FROM MEASURE svc_metrics IN default",
+			literal:       "SELECT TOP 3 value, service FROM MEASURE svc_metrics IN default",
+			params:        []*modelv1.TagValue{intParam(3)},
+		},
+		// SHOW TOP N statement
+		{
+			name:            "params in SHOW TOP N count, TIME, and WHERE",
+			parameterized:   "SHOW TOP ? FROM MEASURE svc_topn IN default TIME > ? WHERE service = ?",
+			literal:         "SHOW TOP 5 FROM MEASURE svc_topn IN default TIME > '-1h' WHERE service = 'svc'",
+			params:          []*modelv1.TagValue{intParam(5), strParam("-1h"), strParam("svc")},
+			ignoreTimeRange: true,
+		},
+	}
+}
+
+var _ = Describe("BindParams equivalence with literal queries", func() {
+	var transformer *Transformer
+
+	BeforeEach(func() {
+		ctrl := gomock.NewController(GinkgoT())
+		streamRegistry := schema.NewMockStream(ctrl)
+		streamRegistry.EXPECT().GetStream(gomock.Any(), gomock.Any()).Return(equivalenceStream(), nil).AnyTimes()
+		topNRegistry := schema.NewMockTopNAggregation(ctrl)
+		topNRegistry.EXPECT().GetTopNAggregation(gomock.Any(), gomock.Any()).Return(&databasev1.TopNAggregation{
+			Metadata:      &commonv1.Metadata{Name: "svc_topn", Group: "default"},
+			SourceMeasure: &commonv1.Metadata{Name: "svc_metrics", Group: "default"},
+		}, nil).AnyTimes()
+		measureRegistry := schema.NewMockMeasure(ctrl)
+		measureRegistry.EXPECT().GetMeasure(gomock.Any(), gomock.Any()).Return(&databasev1.Measure{
+			Metadata: &commonv1.Metadata{Name: "svc_metrics", Group: "default"},
+			TagFamilies: []*databasev1.TagFamilySpec{{
+				Name: "default",
+				Tags: []*databasev1.TagSpec{{Name: "service", Type: databasev1.TagType_TAG_TYPE_STRING}},
+			}},
+			Fields: []*databasev1.FieldSpec{{Name: "value", FieldType: databasev1.FieldType_FIELD_TYPE_INT}},
+		}, nil).AnyTimes()
+		mockRepo := metadata.NewMockRepo(ctrl)
+		mockRepo.EXPECT().StreamRegistry().AnyTimes().Return(streamRegistry)
+		mockRepo.EXPECT().TopNAggregationRegistry().AnyTimes().Return(topNRegistry)
+		mockRepo.EXPECT().MeasureRegistry().AnyTimes().Return(measureRegistry)
+		transformer = NewTransformer(mockRepo)
+	})
+
+	for _, testCase := range equivalenceCases() {
+		It(testCase.name, func() {
+			boundGrammar, err := ParseQuery(testCase.parameterized)
+			Expect(err).To(BeNil())
+			Expect(BindParams(boundGrammar, testCase.params)).To(Succeed())
+			literalGrammar, err := ParseQuery(testCase.literal)
+			Expect(err).To(BeNil())
+			Expect(BindParams(literalGrammar, nil)).To(Succeed())
+
+			// The bound AST must be indistinguishable from the literal AST
+			astDiff := cmp.Diff(literalGrammar, boundGrammar, cmpopts.IgnoreTypes(lexer.Position{}))
+			Expect(astDiff).To(BeEmpty(), "AST mismatch:\n%s", astDiff)
+
+			// Both must transform to the same native query request
+			ctx := context.Background()
+			literalResult, literalErr := transformer.Transform(ctx, literalGrammar)
+			boundResult, boundErr := transformer.Transform(ctx, boundGrammar)
+			if literalErr != nil {
+				Expect(testCase.expectError).To(BeTrue(), "unexpected transform error: %v", literalErr)
+				Expect(boundErr).To(HaveOccurred())
+				Expect(boundErr.Error()).To(Equal(literalErr.Error()))
+				return
+			}
+			Expect(testCase.expectError).To(BeFalse(), "expected a transform error but both sides succeeded")
+			Expect(boundErr).To(BeNil())
+			Expect(boundResult.Type).To(Equal(literalResult.Type))
+			// SELECT * projections are built from a map, so their tag order is
+			// nondeterministic per Transform call; sort them before comparing.
+			opts := []cmp.Option{
+				protocmp.Transform(),
+				protocmp.SortRepeatedFields(&modelv1.TagProjection_TagFamily{}, "tags"),
+			}
+			if testCase.ignoreTimeRange {
+				opts = append(opts,
+					protocmp.IgnoreFields(&streamv1.QueryRequest{}, "time_range"),
+					protocmp.IgnoreFields(&measurev1.TopNRequest{}, "time_range"))
+			}
+			requestDiff := cmp.Diff(literalResult.QueryRequest, boundResult.QueryRequest, opts...)
+			Expect(requestDiff).To(BeEmpty(), "query request mismatch:\n%s", requestDiff)
+		})
+	}
+})
diff --git a/pkg/bydbql/binder_test.go b/pkg/bydbql/binder_test.go
new file mode 100644
index 000000000..ed2fed8dc
--- /dev/null
+++ b/pkg/bydbql/binder_test.go
@@ -0,0 +1,573 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 bydbql_test
+
+import (
+	"math"
+	"reflect"
+	"strings"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	. "github.com/apache/skywalking-banyandb/pkg/bydbql"
+)
+
+func strParam(v string) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: v}}}
+}
+
+func intParam(v int64) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: v}}}
+}
+
+func strArrayParam(vs ...string) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_StrArray{StrArray: &modelv1.StrArray{Value: vs}}}
+}
+
+func intArrayParam(vs ...int64) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_IntArray{IntArray: &modelv1.IntArray{Value: vs}}}
+}
+
+func nullParam() *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_Null{}}
+}
+
+func timestampParam(t time.Time) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_Timestamp{Timestamp: timestamppb.New(t)}}
+}
+
+func binaryParam(data []byte) *modelv1.TagValue {
+	return &modelv1.TagValue{Value: &modelv1.TagValue_BinaryData{BinaryData: data}}
+}
+
+var _ = Describe("BindParams", func() {
+	parse := func(query string) *Grammar {
+		grammar, err := ParseQuery(query)
+		Expect(err).To(BeNil())
+		return grammar
+	}
+
+	Describe("TIME clause binding", func() {
+		It("binds a str parameter as a relative time", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("-30m")})).To(Succeed())
+			Expect(grammar.Select.Time.Value.String).NotTo(BeNil())
+			Expect(*grammar.Select.Time.Value.String).To(Equal("-30m"))
+			Expect(grammar.Select.Time.Value.Param).To(BeFalse())
+		})
+
+		It("binds a timestamp parameter as an RFC3339 string", func() {
+			ts := time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{timestampParam(ts)})).To(Succeed())
+			Expect(grammar.Select.Time.Value.String).NotTo(BeNil())
+			parsed, err := time.Parse(time.RFC3339, *grammar.Select.Time.Value.String)
+			Expect(err).To(BeNil())
+			Expect(parsed.Equal(ts)).To(BeTrue())
+		})
+
+		It("binds both boundaries of TIME BETWEEN", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME BETWEEN ? AND ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("-1h"), strParam("now")})).To(Succeed())
+			Expect(*grammar.Select.Time.Between.Begin.String).To(Equal("-1h"))
+			Expect(*grammar.Select.Time.Between.End.String).To(Equal("now"))
+		})
+
+		It("rejects an array parameter in the TIME clause", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{strArrayParam("a", "b")})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("time clause"))
+		})
+	})
+
+	Describe("WHERE clause binding", func() {
+		It("binds a str parameter in a comparison", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("webapp")})).To(Succeed())
+			value := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+			Expect(value.String).NotTo(BeNil())
+			Expect(*value.String).To(Equal("webapp"))
+			Expect(value.Param).To(BeFalse())
+		})
+
+		It("binds an int parameter in a comparison", func() {
+			grammar := parse("SELECT * FROM MEASURE metrics IN default WHERE value > ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intParam(100)})).To(Succeed())
+			value := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+			Expect(value.Integer).NotTo(BeNil())
+			Expect(*value.Integer).To(Equal(int64(100)))
+		})
+
+		It("binds a null parameter in a comparison", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{nullParam()})).To(Succeed())
+			value := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+			Expect(value.Null).To(BeTrue())
+		})
+
+		It("binds a str parameter in MATCH", func() {
+			grammar := parse("SELECT trace_id, message FROM STREAM logs IN group1 TIME > ? WHERE message MATCH(?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("-30m"), strParam("error")})).To(Succeed())
+			match := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Match
+			Expect(match.Values.Single).NotTo(BeNil())
+			Expect(*match.Values.Single.String).To(Equal("error"))
+		})
+
+		It("expands a str_array parameter in MATCH into multiple values", func() {
+			grammar := parse("SELECT * FROM STREAM logs IN group1 WHERE message MATCH(?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("error", "warn")})).To(Succeed())
+			match := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Match
+			Expect(match.Values.Single).To(BeNil())
+			Expect(match.Values.Array).To(HaveLen(2))
+			Expect(*match.Values.Array[0].String).To(Equal("error"))
+			Expect(*match.Values.Array[1].String).To(Equal("warn"))
+		})
+
+		It("expands multiple array parameters and a scalar in one list in order", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN (?, ?, ?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("a", "b"), strParam("c"), strArrayParam("d", "e")})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.In.Values
+			Expect(values).To(HaveLen(5))
+			for idx, expected := range []string{"a", "b", "c", "d", "e"} {
+				Expect(*values[idx].String).To(Equal(expected))
+			}
+		})
+
+		It("expands an array parameter in IN alongside literal values", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN ('a', ?, 'b')")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("x", "y")})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.In.Values
+			Expect(values).To(HaveLen(4))
+			Expect(*values[0].String).To(Equal("a"))
+			Expect(*values[1].String).To(Equal("x"))
+			Expect(*values[2].String).To(Equal("y"))
+			Expect(*values[3].String).To(Equal("b"))
+		})
+
+		It("expands an int_array parameter in HAVING", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE codes HAVING (?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intArrayParam(200, 500)})).To(Succeed())
+			having := grammar.Select.Where.Expr.Left.Left.Having
+			Expect(having.Values.Array).To(HaveLen(2))
+			Expect(*having.Values.Array[0].Integer).To(Equal(int64(200)))
+			Expect(*having.Values.Array[1].Integer).To(Equal(int64(500)))
+		})
+
+		It("expands array parameters in multiple lists within one query", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN (?) AND codes HAVING (?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("a", "b"), intArrayParam(200, 500)})).To(Succeed())
+			andExpr := grammar.Select.Where.Expr.Left
+			inValues := andExpr.Left.In.Values
+			Expect(inValues).To(HaveLen(2))
+			Expect(*inValues[0].String).To(Equal("a"))
+			Expect(*inValues[1].String).To(Equal("b"))
+			havingValues := andExpr.Right[0].Right.Having.Values
+			Expect(havingValues.Array).To(HaveLen(2))
+			Expect(*havingValues.Array[0].Integer).To(Equal(int64(200)))
+			Expect(*havingValues.Array[1].Integer).To(Equal(int64(500)))
+		})
+
+		It("expands an array parameter in MATCH while keeping the literal analyzer", func() {
+			grammar := parse("SELECT * FROM STREAM logs IN group1 WHERE message MATCH(?, 'simple')")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("error", "warn")})).To(Succeed())
+			match := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Match
+			Expect(match.Values.Array).To(HaveLen(2))
+			Expect(match.Analyzer).NotTo(BeNil())
+			Expect(*match.Analyzer).To(Equal("simple"))
+		})
+
+		It("rejects an empty array parameter", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN (?)")
+			err := BindParams(grammar, []*modelv1.TagValue{strArrayParam()})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("must not be empty"))
+		})
+
+		It("rejects an array parameter in a scalar comparison", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{strArrayParam("a", "b")})
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("rejects a binary parameter anywhere", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{binaryParam([]byte{1, 2})})
+			Expect(err).To(HaveOccurred())
+		})
+	})
+
+	Describe("integer-only positions", func() {
+		It("binds int parameters to LIMIT and OFFSET", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default LIMIT ? OFFSET ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intParam(10), intParam(5)})).To(Succeed())
+			Expect(grammar.Select.Limit.Value).To(Equal(10))
+			Expect(grammar.Select.Limit.Param).To(BeFalse())
+			Expect(grammar.Select.Offset.Value).To(Equal(5))
+			Expect(grammar.Select.Offset.Param).To(BeFalse())
+		})
+
+		It("binds an int parameter to the SHOW TOP N count", func() {
+			grammar := parse("SHOW TOP ? FROM MEASURE metrics IN default")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intParam(7)})).To(Succeed())
+			Expect(grammar.TopN.N).To(Equal(7))
+			Expect(grammar.TopN.NParam).To(BeFalse())
+		})
+
+		It("binds an int parameter to the SELECT TOP N projection count", func() {
+			grammar := parse("SELECT TOP ? value FROM MEASURE metrics IN default")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intParam(3)})).To(Succeed())
+			Expect(grammar.Select.Projection.TopN.N).To(Equal(3))
+			Expect(grammar.Select.Projection.TopN.NParam).To(BeFalse())
+		})
+
+		It("rejects a str parameter in LIMIT", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default LIMIT ?")
+			err := BindParams(grammar, []*modelv1.TagValue{strParam("10")})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("only accepts int"))
+		})
+
+		It("rejects a negative int parameter in LIMIT", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default LIMIT ?")
+			err := BindParams(grammar, []*modelv1.TagValue{intParam(-1)})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("out of range"))
+		})
+
+		It("rejects an int parameter beyond int32 in OFFSET", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default OFFSET ?")
+			err := BindParams(grammar, []*modelv1.TagValue{intParam(math.MaxInt32 + 1)})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("out of range"))
+		})
+	})
+
+	Describe("re-binding semantics", func() {
+		It("rejects binding the same grammar twice with the same parameters", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			params := []*modelv1.TagValue{strParam("svc")}
+			Expect(BindParams(grammar, params)).To(Succeed())
+			err := BindParams(grammar, params)
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("0 placeholder(s) but 1 parameter(s)"))
+		})
+
+		It("treats a second bind with no parameters as a no-op", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("svc")})).To(Succeed())
+			Expect(BindParams(grammar, nil)).To(Succeed())
+			Expect(*grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value.String).To(Equal("svc"))
+		})
+	})
+
+	Describe("ordering and counting", func() {
+		It("binds parameters in order of appearance across clauses", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ? WHERE service_id = ? AND instance_id = ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("-15m"), strParam("svc"), strParam("inst")})).To(Succeed())
+			Expect(*grammar.Select.Time.Value.String).To(Equal("-15m"))
+			andExpr := grammar.Select.Where.Expr.Left
+			Expect(*andExpr.Left.Binary.Tail.Compare.Value.String).To(Equal("svc"))
+			Expect(*andExpr.Right[0].Right.Binary.Tail.Compare.Value.String).To(Equal("inst"))
+		})
+
+		It("binds parameters in a SHOW TOP statement", func() {
+			grammar := parse("SHOW TOP 5 FROM MEASURE metrics IN default TIME > ? WHERE service = ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("-1h"), strParam("svc")})).To(Succeed())
+			Expect(*grammar.TopN.Time.Value.String).To(Equal("-1h"))
+			Expect(*grammar.TopN.Where.Expr.Left.Binary.Tail.Compare.Value.String).To(Equal("svc"))
+		})
+
+		It("rejects fewer parameters than placeholders", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ? WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{strParam("-30m")})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("2 placeholder(s) but 1 parameter(s)"))
+		})
+
+		It("rejects more parameters than placeholders", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default")
+			err := BindParams(grammar, []*modelv1.TagValue{strParam("extra")})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("0 placeholder(s) but 1 parameter(s)"))
+		})
+
+		It("accepts a query without placeholders and no parameters", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = 'webapp'")
+			Expect(BindParams(grammar, nil)).To(Succeed())
+		})
+	})
+
+	Describe("binder coverage of grammar placeholder positions", func() {
+		// Tripwire: binder.collect is the single registry of placeholder positions,
+		// shared by BindParams and the Transform-entry guard. If a new @Param field
+		// is added to the grammar without wiring it into the binder, the placeholder
+		// would silently transform as a zero value. This test fails on any new
+		// @Param field until the binder (and the test matrices) are updated.
+		It("accounts for every @Param field in the grammar", func() {
+			expected := map[string]bool{
+				"GrammarValue.Param":           true,
+				"GrammarTimeValue.Param":       true,
+				"GrammarTopNStatement.NParam":  true,
+				"GrammarTopNProjection.NParam": true,
+				"GrammarLimitClause.Param":     true,
+				"GrammarOffsetClause.Param":    true,
+			}
+			found := make(map[string]bool)
+			visited := make(map[reflect.Type]bool)
+			var walk func(t reflect.Type)
+			walk = func(t reflect.Type) {
+				for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
+					t = t.Elem()
+				}
+				if t.Kind() != reflect.Struct || visited[t] {
+					return
+				}
+				visited[t] = true
+				for i := 0; i < t.NumField(); i++ {
+					field := t.Field(i)
+					if strings.Contains(field.Tag.Get("parser"), "@Param") {
+						found[t.Name()+"."+field.Name] = true
+					}
+					walk(field.Type)
+				}
+			}
+			walk(reflect.TypeOf(Grammar{}))
+			Expect(found).To(Equal(expected),
+				"grammar @Param positions changed: wire any new position into binder.collect, the acceptance matrix, and this list")
+		})
+	})
+
+	Describe("placeholder syntax restrictions", func() {
+		// Placeholders are only legal in value positions. Every structural or
+		// identifier position must stay a syntax error so a bound parameter can
+		// never influence the query shape.
+		forbidden := map[string]string{
+			"resource name":       "SELECT * FROM STREAM ? IN default",
+			"group name":          "SELECT * FROM STREAM sw IN ?",
+			"stage name":          "SELECT * FROM STREAM sw IN default ON ? STAGES",
+			"projection column":   "SELECT ? FROM STREAM sw IN default",
+			"condition left side": "SELECT * FROM STREAM sw IN default WHERE ? = 'a'",
+			"ORDER BY column":     "SELECT * FROM STREAM sw IN default ORDER BY ?",
+			"GROUP BY column":     "SELECT * FROM MEASURE m IN default GROUP BY ?",
+			"MATCH analyzer":      "SELECT * FROM STREAM sw IN default WHERE message MATCH('x', ?)",
+			"doubled placeholder": "SELECT * FROM STREAM sw IN default WHERE service_id = ??",
+		}
+		for position, query := range forbidden {
+			It("rejects a placeholder in "+position+" at parse time", func() {
+				_, err := ParseQuery(query)
+				Expect(err).To(HaveOccurred())
+				Expect(err.Error()).To(ContainSubstring("syntax error"))
+			})
+		}
+	})
+
+	Describe("parameter type acceptance matrix across all placeholder paths", func() {
+		timeAccepted := map[string]bool{"str": true, "int": true, "timestamp": true}
+		scalarAccepted := map[string]bool{"str": true, "int": true, "null": true}
+		listAccepted := map[string]bool{"str": true, "int": true, "null": true, "str_array": true, "int_array": true}
+		intAccepted := map[string]bool{"int": true}
+		paths := []struct {
+			accepted map[string]bool
+			name     string
+			query    string
+		}{
+			{timeAccepted, "TIME comparison value", "SELECT * FROM STREAM sw IN default TIME > ?"},
+			{timeAccepted, "TIME BETWEEN boundary", "SELECT * FROM STREAM sw IN default TIME BETWEEN ? AND '2026-07-06T11:00:00Z'"},
+			{intAccepted, "LIMIT count", "SELECT * FROM STREAM sw IN default LIMIT ?"},
+			{intAccepted, "OFFSET count", "SELECT * FROM STREAM sw IN default OFFSET ?"},
+			{intAccepted, "SHOW TOP N count", "SHOW TOP ? FROM MEASURE m IN default"},
+			{intAccepted, "SELECT TOP N projection count", "SELECT TOP ? value FROM MEASURE m IN default"},
+			{scalarAccepted, "scalar equality value", "SELECT * FROM STREAM sw IN default WHERE service_id = ?"},
+			{scalarAccepted, "scalar ordered comparison value", "SELECT * FROM STREAM sw IN default WHERE duration > ?"},
+			{listAccepted, "IN value list", "SELECT * FROM STREAM sw IN default WHERE service_id IN (?)"},
+			{listAccepted, "MATCH value list", "SELECT * FROM STREAM sw IN default WHERE message MATCH(?)"},
+			{listAccepted, "HAVING single value", "SELECT * FROM STREAM sw IN default WHERE tags HAVING ?"},
+			{listAccepted, "HAVING value list", "SELECT * FROM STREAM sw IN default WHERE codes HAVING (?)"},
+		}
+		paramTypes := []struct {
+			param *modelv1.TagValue
+			name  string
+		}{
+			{strParam("value"), "str"},
+			{intParam(42), "int"},
+			{nullParam(), "null"},
+			{strArrayParam("a", "b"), "str_array"},
+			{intArrayParam(1, 2), "int_array"},
+			{timestampParam(time.Date(2026, 7, 6, 10, 0, 0, 0, time.UTC)), "timestamp"},
+			{binaryParam([]byte{1, 2}), "binary"},
+			{&modelv1.TagValue{}, "empty"},
+			{nil, "nil"},
+		}
+		for _, path := range paths {
+			for _, paramType := range paramTypes {
+				expectAccept := path.accepted[paramType.name]
+				verdict := "rejects"
+				if expectAccept {
+					verdict = "accepts"
+				}
+				It(verdict+" a "+paramType.name+" parameter in "+path.name, func() {
+					grammar := parse(path.query)
+					err := BindParams(grammar, []*modelv1.TagValue{paramType.param})
+					if expectAccept {
+						Expect(err).To(BeNil())
+					} else {
+						Expect(err).To(HaveOccurred())
+					}
+				})
+			}
+		}
+	})
+
+	Describe("single value and list form preservation", func() {
+		It("keeps the single form when a one-element array binds a single MATCH value", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE message MATCH(?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("error")})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Match.Values
+			Expect(values.Single).NotTo(BeNil())
+			Expect(*values.Single.String).To(Equal("error"))
+			Expect(values.Array).To(BeNil())
+		})
+
+		It("switches to the list form when a multi-element array binds a single HAVING value", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE tags HAVING ?")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strArrayParam("a", "b")})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.Having.Values
+			Expect(values.Single).To(BeNil())
+			Expect(values.Array).To(HaveLen(2))
+		})
+
+		It("keeps the list form when a one-element array binds a parenthesized HAVING list", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE codes HAVING (?)")
+			Expect(BindParams(grammar, []*modelv1.TagValue{intArrayParam(200)})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.Having.Values
+			Expect(values.Single).To(BeNil())
+			Expect(values.Array).To(HaveLen(1))
+			Expect(*values.Array[0].Integer).To(Equal(int64(200)))
+		})
+
+		It("keeps the list form when a scalar binds a parenthesized MATCH list", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE message MATCH((?))")
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam("error")})).To(Succeed())
+			values := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Match.Values
+			Expect(values.Single).To(BeNil())
+			Expect(values.Array).To(HaveLen(1))
+		})
+	})
+
+	Describe("parameter type rejections", func() {
+		It("rejects a timestamp parameter in a scalar comparison", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{timestampParam(time.Now())})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("str, int, or null"))
+		})
+
+		It("rejects a timestamp parameter in a value list", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN (?)")
+			err := BindParams(grammar, []*modelv1.TagValue{timestampParam(time.Now())})
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("rejects a null parameter in the TIME clause", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{nullParam()})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("time clause"))
+		})
+
+		It("rejects a binary parameter in the TIME clause", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{binaryParam([]byte{1})})
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("rejects a binary parameter in a value list", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id IN (?)")
+			err := BindParams(grammar, []*modelv1.TagValue{binaryParam([]byte{1})})
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("rejects an int_array parameter in a scalar comparison", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE duration > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{intArrayParam(1, 2)})
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("rejects a nil inner timestamp in the TIME clause instead of decoding it as the epoch", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{{Value: &modelv1.TagValue_Timestamp{}}})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("invalid timestamp"))
+		})
+
+		It("rejects an out-of-range timestamp in the TIME clause", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default TIME > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{
+				{Value: &modelv1.TagValue_Timestamp{Timestamp: &timestamppb.Timestamp{Seconds: math.MaxInt64}}},
+			})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("invalid timestamp"))
+		})
+
+		It("rejects a parameter without a value", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{{}})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("has no value"))
+		})
+
+		It("rejects a nil parameter entry", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			err := BindParams(grammar, []*modelv1.TagValue{nil})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("has no value"))
+		})
+
+		It("reports the 1-based position of the failing parameter", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ? AND duration > ?")
+			err := BindParams(grammar, []*modelv1.TagValue{strParam("ok"), strArrayParam("bad")})
+			Expect(err).To(HaveOccurred())
+			Expect(err.Error()).To(ContainSubstring("parameter #2"))
+		})
+	})
+
+	Describe("injection safety", func() {
+		It("keeps an injection payload as a pure string value", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE service_id = ?")
+			payload := "x' OR '1'='1"
+			Expect(BindParams(grammar, []*modelv1.TagValue{strParam(payload)})).To(Succeed())
+			value := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+			Expect(*value.String).To(Equal(payload))
+			// The bound value never becomes additional predicates
+			Expect(grammar.Select.Where.Expr.Left.Right).To(BeEmpty())
+			Expect(grammar.Select.Where.Expr.Right).To(BeEmpty())
+		})
+
+		It("does not treat a question mark inside a string literal as a placeholder", func() {
+			grammar := parse("SELECT * FROM STREAM sw IN default WHERE message = 'why?'")
+			Expect(BindParams(grammar, nil)).To(Succeed())
+			value := grammar.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+			Expect(*value.String).To(Equal("why?"))
+		})
+	})
+})
diff --git a/pkg/bydbql/grammar.go b/pkg/bydbql/grammar.go
index bf3e5acb5..64cf42b1b 100644
--- a/pkg/bydbql/grammar.go
+++ b/pkg/bydbql/grammar.go
@@ -55,7 +55,8 @@ type GrammarTopNStatement struct {
 	Pos            lexer.Position
 	Show           string                        `parser:"@'SHOW'"`
 	Top            string                        `parser:"@'TOP'"`
-	N              int                           `parser:"@Int"`
+	N              int                           `parser:"( @Int"`
+	NParam         bool                          `parser:"| @Param )"`
 	From           *GrammarFromClause            `parser:"@@"`
 	Time           *GrammarTimeClause            `parser:"@@?"`
 	Where          *GrammarTopNWhereClause       `parser:"@@?"`
@@ -74,7 +75,8 @@ type GrammarProjection struct {
 
 // GrammarTopNProjection represents TOP N projection.
 type GrammarTopNProjection struct {
-	N            int                    `parser:"@Int"`
+	N            int                    `parser:"( @Int"`
+	NParam       bool                   `parser:"| @Param )"`
 	OrderField   *GrammarIdentifierPath `parser:"@@"`
 	Direction    *string                `parser:"@('ASC'|'DESC')?"`
 	OtherColumns []*GrammarColumn       `parser:"  ( ',' @@ ( ',' @@ )* )?"`
@@ -140,10 +142,11 @@ type GrammarTimeBetween struct {
 	End     *GrammarTimeValue `parser:"@@"`
 }
 
-// GrammarTimeValue represents a time value (string or integer).
+// GrammarTimeValue represents a time value (string, integer, or a `?` placeholder).
 type GrammarTimeValue struct {
 	String  *string `parser:"  @String"`
 	Integer *int64  `parser:"| @Int"`
+	Param   bool    `parser:"| @Param"`
 }
 
 // GrammarSelectWhereClause represents WHERE clause.
@@ -251,6 +254,7 @@ type GrammarValue struct {
 	String  *string `parser:"  @String"`
 	Integer *int64  `parser:"| @Int"`
 	Null    bool    `parser:"| @'NULL'"`
+	Param   bool    `parser:"| @Param"`
 }
 
 // GrammarIdentifierPart Can be either an Ident or a Keyword (keywords are allowed in paths, but not as standalone identifiers).
@@ -319,13 +323,15 @@ type GrammarOrderByWithIdent struct {
 // GrammarLimitClause represents LIMIT clause.
 type GrammarLimitClause struct {
 	Limit string `parser:"@'LIMIT'"`
-	Value int    `parser:"@Int"`
+	Value int    `parser:"( @Int"`
+	Param bool   `parser:"| @Param )"`
 }
 
 // GrammarOffsetClause represents OFFSET clause.
 type GrammarOffsetClause struct {
 	Offset string `parser:"@'OFFSET'"`
-	Value  int    `parser:"@Int"`
+	Value  int    `parser:"( @Int"`
+	Param  bool   `parser:"| @Param )"`
 }
 
 // GrammarWithTraceClause represents WITH QUERY_TRACE clause.
diff --git a/pkg/bydbql/parser.go b/pkg/bydbql/parser.go
index d58cb7c1c..35cbc8e15 100644
--- a/pkg/bydbql/parser.go
+++ b/pkg/bydbql/parser.go
@@ -52,6 +52,7 @@ func init() {
 		{Name: "Int", Pattern: `[-+]?\d+`},
 		{Name: "String", Pattern: `'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"`},
 		{Name: "QuotedIdent", Pattern: `"[a-zA-Z_][a-zA-Z0-9_.]*"|'[a-zA-Z_][a-zA-Z0-9_.]*'`},
+		{Name: "Param", Pattern: `\?`},
 		{Name: "Operators", Pattern: `!=|>=|<=|::|[=><,.()*]`},
 		{Name: "whitespace", Pattern: `\s+`},
 	})
diff --git a/pkg/bydbql/transformer.go b/pkg/bydbql/transformer.go
index ac6d6bc40..f31e5bbd7 100644
--- a/pkg/bydbql/transformer.go
+++ b/pkg/bydbql/transformer.go
@@ -101,6 +101,11 @@ func NewTransformer(registry metadata.Repo) *Transformer {
 
 // Transform transforms a Grammar into a native query request.
 func (t *Transformer) Transform(ctx context.Context, grammar *Grammar) (*TransformResult, error) {
+	// Defense in depth: an unbound placeholder would otherwise transform into an
+	// empty string or a zero count silently instead of failing loudly.
+	if unbound := countUnboundParams(grammar); unbound > 0 {
+		return nil, fmt.Errorf("query contains %d unbound placeholder(s); BindParams must be called before Transform", unbound)
+	}
 	if grammar.Select != nil {
 		// Extract resource type from SELECT statement
 		resourceType := grammar.Select.From.ResourceType
diff --git a/pkg/test/helpers/bydbql.go b/pkg/test/helpers/bydbql.go
new file mode 100644
index 000000000..0fe090524
--- /dev/null
+++ b/pkg/test/helpers/bydbql.go
@@ -0,0 +1,63 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 helpers
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"google.golang.org/protobuf/encoding/protojson"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// ExtractQL assembles a BydbQL query string from a .ql file's content, skipping
+// blank and comment lines, and decodes the optional `#!params:` directive into
+// TagValues bound to the query's `?` placeholders. The directive value is a JSON
+// array of protojson-encoded model.v1.TagValue objects, e.g.
+// `#!params: [{"str":{"value":"-15m"}},{"int":{"value":"10"}}]`.
+func ExtractQL(content string) (string, []*modelv1.TagValue, error) {
+	var query strings.Builder
+	var params []*modelv1.TagValue
+	for _, line := range strings.Split(content, "\n") {
+		trimmed := strings.TrimSpace(line)
+		if paramsJSON, found := strings.CutPrefix(trimmed, "#!params:"); found {
+			var rawParams []json.RawMessage
+			if err := json.Unmarshal([]byte(paramsJSON), &rawParams); err != nil {
+				return "", nil, fmt.Errorf("failed to decode #!params directive: %w", err)
+			}
+			for _, rawParam := range rawParams {
+				param := &modelv1.TagValue{}
+				if err := protojson.Unmarshal(rawParam, param); err != nil {
+					return "", nil, fmt.Errorf("failed to decode #!params entry %s: %w", rawParam, err)
+				}
+				params = append(params, param)
+			}
+			continue
+		}
+		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+			continue
+		}
+		if query.Len() > 0 {
+			query.WriteByte(' ')
+		}
+		query.WriteString(trimmed)
+	}
+	return query.String(), params, nil
+}
diff --git a/test/cases/measure/data/data.go b/test/cases/measure/data/data.go
index ae6e582c3..a343acd86 100644
--- a/test/cases/measure/data/data.go
+++ b/test/cases/measure/data/data.go
@@ -157,18 +157,8 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 	}
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
-			continue
-		}
-		if qlQueryStr != "" {
-			qlQueryStr += " "
-		}
-		qlQueryStr += trimmed
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	// Auto-inject stages clause if args.Stages is not empty
 	if len(args.Stages) > 0 {
@@ -196,6 +186,7 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 
 	parsed, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(parsed, qlParams)).To(gm.Succeed())
 
 	transformer := bydbql.NewTransformer(mockRepo)
 	result, err := transformer.Transform(ctx, parsed)
@@ -215,7 +206,8 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 
 	bydbqlClient := bydbqlv1.NewBydbQLServiceClient(conn)
 	bydbqlResp, err := bydbqlClient.Query(ctx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 	innerGm.Expect(bydbqlResp).NotTo(gm.BeNil())
diff --git a/test/cases/measure/data/input/params_bind.ql b/test/cases/measure/data/input/params_bind.ql
new file mode 100644
index 000000000..842e284c3
--- /dev/null
+++ b/test/cases/measure/data/input/params_bind.ql
@@ -0,0 +1,22 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+
+SELECT name, short_name FROM MEASURE service_traffic IN index_mode
+TIME > ?
+WHERE id IN (?)
+#!params: [{"str":{"value":"-15m"}},{"strArray":{"value":["1","2"]}}]
diff --git a/test/cases/measure/data/input/params_bind.yaml b/test/cases/measure/data/input/params_bind.yaml
new file mode 100644
index 000000000..f002dd2eb
--- /dev/null
+++ b/test/cases/measure/data/input/params_bind.yaml
@@ -0,0 +1,30 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+name: "service_traffic"
+groups: ["index_mode"]
+tagProjection:
+  tagFamilies:
+  - name: "default"
+    tags: ["name", "short_name"]
+criteria:
+  condition:
+    name: "id"
+    op: "BINARY_OP_IN"
+    value:
+      str_array:
+        value: ["1", "2"]
diff --git a/test/cases/measure/measure.go b/test/cases/measure/measure.go
index 4594dfeb6..ae5c8b0cf 100644
--- a/test/cases/measure/measure.go
+++ b/test/cases/measure/measure.go
@@ -70,6 +70,7 @@ var measureEntries = []any{
 	g.Entry("match nodes", helpers.Args{Input: "match_nodes", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
 	g.Entry("filter by entity id", helpers.Args{Input: "entity", Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
 	g.Entry("filter by several entity ids", helpers.Args{Input: "entity_in", Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
+	g.Entry("filter with bound parameters", helpers.Args{Input: "params_bind", Want: "entity_in", Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
 	g.Entry("filter by entity id and service id", helpers.Args{Input: "entity_service", Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
 	g.Entry("without field", helpers.Args{Input: "no_field", Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
 	g.Entry("invalid logical expression", helpers.Args{Input: "err_invalid_le", Duration: 25 * time.Minute, Offset: -20 * time.Minute, WantErr: true}),
diff --git a/test/cases/property/data/data.go b/test/cases/property/data/data.go
index 865f075e8..9fbb4ce15 100644
--- a/test/cases/property/data/data.go
+++ b/test/cases/property/data/data.go
@@ -135,18 +135,8 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 	}
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
-			continue
-		}
-		if qlQueryStr != "" {
-			qlQueryStr += " "
-		}
-		qlQueryStr += trimmed
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	ctrl := gomock.NewController(g.GinkgoT())
 	defer ctrl.Finish()
@@ -165,6 +155,7 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 
 	parsed, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(parsed, qlParams)).To(gm.Succeed())
 
 	transformer := bydbql.NewTransformer(mockRepo)
 	result, err := transformer.Transform(ctx, parsed)
@@ -180,7 +171,8 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 
 	bydbqlClient := bydbqlv1.NewBydbQLServiceClient(conn)
 	bydbqlResp, err := bydbqlClient.Query(ctx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 	innerGm.Expect(bydbqlResp).NotTo(gm.BeNil())
diff --git a/test/cases/property/data/input/params_bind.ql b/test/cases/property/data/input/params_bind.ql
new file mode 100644
index 000000000..d03313ffc
--- /dev/null
+++ b/test/cases/property/data/input/params_bind.ql
@@ -0,0 +1,21 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+
+SELECT menu_name, configuration, update_time FROM PROPERTY ui_menu IN sw
+WHERE menu_name = ?
+#!params: [{"str":{"value":"test1"}}]
diff --git a/test/cases/property/data/input/params_bind.yaml b/test/cases/property/data/input/params_bind.yaml
new file mode 100644
index 000000000..757cf5c96
--- /dev/null
+++ b/test/cases/property/data/input/params_bind.yaml
@@ -0,0 +1,30 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+name: "ui_menu"
+groups: ["sw"]
+tagProjection:
+  - menu_name
+  - configuration
+  - update_time
+criteria:
+  condition:
+    name: "menu_name"
+    op: "BINARY_OP_EQ"
+    value:
+      str:
+        value: "test1"
\ No newline at end of file
diff --git a/test/cases/property/property.go b/test/cases/property/property.go
index 36cb20c87..985247591 100644
--- a/test/cases/property/property.go
+++ b/test/cases/property/property.go
@@ -42,6 +42,7 @@ var propertyEntries = []any{
 	g.Entry("all", helpers.Args{Input: "all"}),
 	g.Entry("limit", helpers.Args{Input: "limit"}),
 	g.Entry("query by criteria", helpers.Args{Input: "query_by_criteria"}),
+	g.Entry("query by criteria with bound parameters", helpers.Args{Input: "params_bind", Want: "query_by_criteria"}),
 	g.Entry("query by ids", helpers.Args{Input: "query_by_ids"}),
 	g.Entry("order by asc", helpers.Args{Input: "order_by_asc"}),
 	g.Entry("order by desc", helpers.Args{Input: "order_by_desc"}),
diff --git a/test/cases/stream/data/data.go b/test/cases/stream/data/data.go
index 059598727..e8118dbff 100644
--- a/test/cases/stream/data/data.go
+++ b/test/cases/stream/data/data.go
@@ -210,16 +210,8 @@ func loadDataWithSpec(stream streamv1.StreamService_WriteClient, md *commonv1.Me
 func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *streamv1.QueryRequest, conn *grpclib.ClientConn) {
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
-			if qlQueryStr != "" {
-				qlQueryStr += " "
-			}
-			qlQueryStr += trimmed
-		}
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	// Auto-inject stages clause if args.Stages is not empty
 	if len(args.Stages) > 0 {
@@ -248,6 +240,7 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *stream
 	// parse QL to QueryRequest
 	query, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(query, qlParams)).To(gm.Succeed())
 	transformer := bydbql.NewTransformer(mockRepo)
 	transformCtx, transformCancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer transformCancel()
@@ -274,7 +267,8 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *stream
 	qlCtx, qlCancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer qlCancel()
 	bydbqlResp, err := client.Query(qlCtx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	if args.WantErr {
 		innerGm.Expect(err).To(gm.HaveOccurred())
diff --git a/test/cases/stream/data/input/params_bind.ql b/test/cases/stream/data/input/params_bind.ql
new file mode 100644
index 000000000..71b5fc86c
--- /dev/null
+++ b/test/cases/stream/data/input/params_bind.ql
@@ -0,0 +1,21 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+SELECT trace_id, span_id FROM STREAM sw IN default
+TIME > ?
+WHERE trace_id = ? AND span_id = ?
+#!params: [{"str":{"value":"-15m"}},{"str":{"value":"1"}},{"str":{"value":"1"}}]
diff --git a/test/cases/stream/data/input/params_bind.yaml b/test/cases/stream/data/input/params_bind.yaml
new file mode 100644
index 000000000..ffb8b211f
--- /dev/null
+++ b/test/cases/stream/data/input/params_bind.yaml
@@ -0,0 +1,40 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+name: "sw"
+groups: ["default"]
+projection:
+  tagFamilies:
+  - name: "searchable"
+    tags: ["trace_id", "span_id"]
+criteria:
+  le:
+    op: "LOGICAL_OP_AND"
+    right:
+      condition:
+        name: "span_id"
+        op: "BINARY_OP_EQ"
+        value:
+          str:
+            value: "1"
+    left:
+      condition:
+        name: "trace_id"
+        op: "BINARY_OP_EQ"
+        value:
+          str:
+            value: "1"
diff --git a/test/cases/stream/stream.go b/test/cases/stream/stream.go
index a5520e420..31891cd93 100644
--- a/test/cases/stream/stream.go
+++ b/test/cases/stream/stream.go
@@ -70,6 +70,7 @@ var streamEntries = []any{
 	g.Entry("global index", helpers.Args{Input: "global_index", Duration: 1 * time.Hour}),
 	g.Entry("multi-global index", helpers.Args{Input: "global_indices", Duration: 1 * time.Hour}),
 	g.Entry("filter by non-indexed tag", helpers.Args{Input: "filter_tag", Duration: 1 * time.Hour}),
+	g.Entry("filter with bound parameters", helpers.Args{Input: "params_bind", Want: "filter_tag", Duration: 1 * time.Hour}),
 	g.Entry("filter hidden tag projection", helpers.Args{Input: "filter_hidden_tag", Duration: 1 * time.Hour}),
 	g.Entry("filter by non-indexed tag order by duration desc with limit 3", helpers.Args{Input: "sort_duration_no_index_limit", Duration: 1 * time.Hour}),
 	g.Entry("get empty result by non-indexed tag", helpers.Args{Input: "filter_tag_empty", Duration: 1 * time.Hour, WantEmpty: true}),
diff --git a/test/cases/topn/data/data.go b/test/cases/topn/data/data.go
index 4f9c175a5..1fd44b33d 100644
--- a/test/cases/topn/data/data.go
+++ b/test/cases/topn/data/data.go
@@ -229,16 +229,8 @@ func tagValOrder(v *modelv1.TagValue) int {
 func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Args, yamlQuery *measurev1.TopNRequest, conn *grpclib.ClientConn) {
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
-			if qlQueryStr != "" {
-				qlQueryStr += " "
-			}
-			qlQueryStr += trimmed
-		}
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	// Auto-inject stages clause if args.Stages is not empty
 	if len(args.Stages) > 0 {
@@ -277,6 +269,7 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 	// parse QL to QueryRequest
 	query, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(query, qlParams)).To(gm.Succeed())
 	transformer := bydbql.NewTransformer(mockRepo)
 	transform, err := transformer.Transform(ctx, query)
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
@@ -296,7 +289,8 @@ func verifyQLWithRequest(ctx context.Context, innerGm gm.Gomega, args helpers.Ar
 	// simple check the QL can be executed
 	client := bydbqlv1.NewBydbQLServiceClient(conn)
 	bydbqlResp, err := client.Query(ctx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	if args.WantErr {
 		innerGm.Expect(err).To(gm.HaveOccurred())
diff --git a/test/cases/topn/topn.go b/test/cases/topn/topn.go
index 40b858881..5dcb3fe94 100644
--- a/test/cases/topn/topn.go
+++ b/test/cases/topn/topn.go
@@ -45,6 +45,7 @@ var (
 var topnEntries = []any{
 	g.Entry("max top3 order by desc", helpers.Args{Input: "aggr_desc", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
 	g.Entry("max top3 with condition order by desc", helpers.Args{Input: "condition_aggr_desc", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
+	g.Entry("condition with bound parameters", helpers.Args{Input: "params_bind", Want: "condition_aggr_desc", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
 	g.Entry("max top3 for null group order by desc", helpers.Args{Input: "null_group", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
 	g.Entry("multi-group: max top3 order by desc", helpers.Args{Input: "multi_group_aggr_desc", Want: "aggr_desc", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
 	g.Entry("using equal in aggregation", helpers.Args{Input: "eq", Duration: 25 * time.Minute, Offset: -20 * time.Minute}),
diff --git a/test/cases/trace/data/data.go b/test/cases/trace/data/data.go
index 816812346..2db1c9b99 100644
--- a/test/cases/trace/data/data.go
+++ b/test/cases/trace/data/data.go
@@ -184,18 +184,8 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
 func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev1.QueryRequest, conn *grpclib.ClientConn) {
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
-			continue
-		}
-		if qlQueryStr != "" {
-			qlQueryStr += " "
-		}
-		qlQueryStr += trimmed
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	// Auto-inject stages clause if args.Stages is not empty
 	if len(args.Stages) > 0 {
@@ -223,6 +213,7 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev
 
 	parsed, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(parsed, qlParams)).To(gm.Succeed())
 
 	transformer := bydbql.NewTransformer(mockRepo)
 	transformCtx, transformCancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -246,7 +237,8 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev
 	qlCtx, qlCancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer qlCancel()
 	bydbqlResp, err := bydbqlClient.Query(qlCtx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 	innerGm.Expect(bydbqlResp).NotTo(gm.BeNil())
diff --git a/test/cases/trace/data/input/params_bind.ql b/test/cases/trace/data/input/params_bind.ql
new file mode 100644
index 000000000..0da14e4a2
--- /dev/null
+++ b/test/cases/trace/data/input/params_bind.ql
@@ -0,0 +1,24 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+
+SELECT () FROM TRACE zipkin IN zipkinTrace
+TIME > ?
+WHERE duration >= ? AND duration <= ?
+ORDER BY zipkin-timestamp DESC
+LIMIT ?
+#!params: [{"str":{"value":"-1h"}},{"int":{"value":"10"}},{"int":{"value":"1000"}},{"int":{"value":"10"}}]
diff --git a/test/cases/trace/data/input/params_bind.yml b/test/cases/trace/data/input/params_bind.yml
new file mode 100644
index 000000000..a37f54044
--- /dev/null
+++ b/test/cases/trace/data/input/params_bind.yml
@@ -0,0 +1,40 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you 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.
+
+name: "zipkin"
+groups: ["zipkinTrace"]
+limit: 10
+order_by:
+  index_rule_name: "zipkin-timestamp"
+  sort: "SORT_DESC"
+criteria:
+  le:
+    op: "LOGICAL_OP_AND"
+    left:
+      condition:
+        name: "duration"
+        op: "BINARY_OP_GE"
+        value:
+          int:
+            value: 10
+    right:
+      condition:
+        name: "duration"
+        op: "BINARY_OP_LE"
+        value:
+          int:
+            value: 1000
diff --git a/test/cases/trace/trace.go b/test/cases/trace/trace.go
index e3afe26d5..75c79ee68 100644
--- a/test/cases/trace/trace.go
+++ b/test/cases/trace/trace.go
@@ -51,6 +51,8 @@ var traceEntries = []any{
 	g.Entry("order by duration", helpers.Args{Input: "order_duration_desc", Duration: 1 * time.Hour}),
 	g.Entry("duration range 10-1000 order by timestamp",
 		helpers.Args{Input: "duration_range_order_timestamp", Duration: 1 * time.Hour}),
+	g.Entry("duration range with bound parameters",
+		helpers.Args{Input: "params_bind", Want: "duration_range_order_timestamp", Duration: 1 * time.Hour}),
 	g.Entry("duration range and ipv4 filter order by timestamp",
 		helpers.Args{Input: "duration_range_and_ipv4_order_timestamp", Duration: 1 * time.Hour}),
 	g.Entry("filter by service id", helpers.Args{Input: "eq_service_order_timestamp_desc", Duration: 1 * time.Hour}),
diff --git a/test/cases/tracepipeline/data/data.go b/test/cases/tracepipeline/data/data.go
index 017712e39..47082f227 100644
--- a/test/cases/tracepipeline/data/data.go
+++ b/test/cases/tracepipeline/data/data.go
@@ -154,18 +154,8 @@ var VerifyFn = func(innerGm gm.Gomega, sharedContext helpers.SharedContext, args
 func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev1.QueryRequest, conn *grpclib.ClientConn) {
 	qlContent, err := qlFS.ReadFile("input/" + args.Input + ".ql")
 	innerGm.Expect(err).NotTo(gm.HaveOccurred())
-
-	var qlQueryStr string
-	for _, line := range strings.Split(string(qlContent), "\n") {
-		trimmed := strings.TrimSpace(line)
-		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
-			continue
-		}
-		if qlQueryStr != "" {
-			qlQueryStr += " "
-		}
-		qlQueryStr += trimmed
-	}
+	qlQueryStr, qlParams, err := helpers.ExtractQL(string(qlContent))
+	innerGm.Expect(err).NotTo(gm.HaveOccurred())
 
 	// Auto-inject stages clause if args.Stages is not empty.
 	if len(args.Stages) > 0 {
@@ -191,6 +181,7 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev
 
 	parsed, errStrs := bydbql.ParseQuery(qlQueryStr)
 	innerGm.Expect(errStrs).To(gm.BeNil())
+	innerGm.Expect(bydbql.BindParams(parsed, qlParams)).To(gm.Succeed())
 
 	transformer := bydbql.NewTransformer(mockRepo)
 	transformCtx, transformCancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -214,7 +205,8 @@ func verifyQLWithRequest(innerGm gm.Gomega, args helpers.Args, yamlQuery *tracev
 	qlCtx, qlCancel := context.WithTimeout(context.Background(), 10*time.Second)
 	defer qlCancel()
 	bydbqlResp, qlErr := bydbqlClient.Query(qlCtx, &bydbqlv1.QueryRequest{
-		Query: qlQueryStr,
+		Query:  qlQueryStr,
+		Params: qlParams,
 	})
 	innerGm.Expect(qlErr).NotTo(gm.HaveOccurred())
 	innerGm.Expect(bydbqlResp).NotTo(gm.BeNil())
diff --git a/ui/src/components/BydbQL/Index.vue b/ui/src/components/BydbQL/Index.vue
index 419da41e0..b6539aa51 100644
--- a/ui/src/components/BydbQL/Index.vue
+++ b/ui/src/components/BydbQL/Index.vue
@@ -49,6 +49,10 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
   const executionTime = ref(0);
   const codeMirrorInstance = ref(null);
   const editorLoading = ref(true);
+  // Positional parameters bound to `?` placeholders in the query, in order of appearance
+  const queryParams = ref([]);
+  const paramTypes = ['str', 'int', 'str_array', 'int_array', 'null'];
+  const integerPattern = /^-?\d+$/;
 
   const hasResult = computed(() => queryResult.value !== null);
   const resultType = computed(() => {
@@ -262,7 +266,21 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
       .join('\n')
       .trim();
 
-    const response = await executeBydbQLQuery({ query: cleanQuery });
+    let boundParams;
+    try {
+      boundParams = buildBoundParams();
+    } catch (paramError) {
+      loading.value = false;
+      error.value = paramError.message;
+      ElMessage.error(paramError.message);
+      return;
+    }
+
+    const requestBody = { query: cleanQuery };
+    if (boundParams.length > 0) {
+      requestBody.params = boundParams;
+    }
+    const response = await executeBydbQLQuery(requestBody);
     const endTime = performance.now();
     loading.value = false;
     executionTime.value = Math.round(endTime - startTime);
@@ -281,6 +299,62 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
     queryResult.value = null;
     error.value = null;
     executionTime.value = 0;
+    queryParams.value = [];
+  }
+
+  function addParam() {
+    queryParams.value.push({ type: 'str', value: '' });
+  }
+
+  function removeParam(index) {
+    queryParams.value.splice(index, 1);
+  }
+
+  function splitArrayValue(raw, position) {
+    const entries = raw
+      .split(',')
+      .map((entry) => entry.trim())
+      .filter((entry) => entry !== '');
+    if (entries.length === 0) {
+      throw new Error(`${position} must contain at least one value`);
+    }
+    return entries;
+  }
+
+  // Convert the parameter rows to protojson TagValue form accepted by the HTTP API.
+  // Keep in sync with toTagValueParams in mcp/src/query/validation.ts, which builds
+  // the same shapes independently. The server-side timestamp variant is deliberately
+  // not exposed here: an RFC3339 str is equivalent for TIME positions.
+  function buildBoundParams() {
+    return queryParams.value.map((param, index) => {
+      const position = `Parameter #${index + 1}`;
+      switch (param.type) {
+        case 'str':
+          return { str: { value: param.value } };
+        case 'int': {
+          const trimmed = param.value.trim();
+          if (!integerPattern.test(trimmed)) {
+            throw new Error(`${position} must be an integer`);
+          }
+          return { int: { value: trimmed } };
+        }
+        case 'str_array':
+          return { strArray: { value: splitArrayValue(param.value, position) } };
+        case 'int_array': {
+          const entries = splitArrayValue(param.value, position);
+          for (const entry of entries) {
+            if (!integerPattern.test(entry)) {
+              throw new Error(`${position} must be a comma-separated list of integers`);
+            }
+          }
+          return { intArray: { value: entries } };
+        }
+        case 'null':
+          return { null: null };
+        default:
+          throw new Error(`${position} has an unknown type`);
+      }
+    });
   }
 
   function onCodeMirrorReady(cm) {
@@ -584,6 +658,26 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
           @ready="onCodeMirrorReady"
         />
       </div>
+      <div class="query-params-container">
+        <div class="params-header">
+          <span class="params-title">Parameters (bound to ? placeholders in order)</span>
+          <el-button size="small" @click="addParam" :disabled="loading"> Add Parameter </el-button>
+        </div>
+        <div v-for="(param, index) in queryParams" :key="index" class="param-row">
+          <span class="param-index">?{{ index + 1 }}</span>
+          <el-select v-model="param.type" size="small" class="param-type">
+            <el-option v-for="paramType in paramTypes" :key="paramType" :label="paramType" :value="paramType" />
+          </el-select>
+          <el-input
+            v-model="param.value"
+            size="small"
+            class="param-value"
+            :disabled="param.type === 'null'"
+            :placeholder="param.type.endsWith('_array') ? 'Comma-separated values' : 'Value'"
+          />
+          <el-button size="small" type="danger" text @click="removeParam(index)"> Remove </el-button>
+        </div>
+      </div>
     </el-card>
     <el-card shadow="always" class="result-card">
       <template #header>
@@ -675,6 +769,45 @@ SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
     gap: 10px;
   }
 
+  .query-params-container {
+    margin-top: 10px;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .params-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  .params-title {
+    font-size: 13px;
+    color: var(--el-text-color-secondary);
+  }
+
+  .param-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .param-index {
+    font-family: monospace;
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+    min-width: 24px;
+  }
+
+  .param-type {
+    width: 120px;
+  }
+
+  .param-value {
+    flex: 1;
+  }
+
   .header-right {
     display: flex;
     align-items: center;