This is an automated email from the ASF dual-hosted git repository.
mrproliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 7bb5998bc Introduce parameter binding (`?` placeholders) into BydbQL
(#1209)
7bb5998bc is described below
commit 7bb5998bc472ebcffe3b15765caf6a2792c770c0
Author: mrproliu <[email protected]>
AuthorDate: Thu Jul 9 04:24:07 2026 +0800
Introduce parameter binding (`?` placeholders) into BydbQL (#1209)
---
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.test.ts | 82 +++-
mcp/src/query/validation.ts | 98 ++++
mcp/src/server/mcp.ts | 34 +-
pkg/bydbql/binder.go | 375 +++++++++++++++
pkg/bydbql/binder_equivalence_test.go | 437 +++++++++++++++++
pkg/bydbql/binder_test.go | 597 ++++++++++++++++++++++++
pkg/bydbql/grammar.go | 21 +-
pkg/bydbql/parser.go | 1 +
pkg/bydbql/transformer.go | 13 +
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/data/input/params_bind.ql | 24 +
test/cases/topn/data/input/params_bind.yaml | 28 ++
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 +-
40 files changed, 2223 insertions(+), 86 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index fdc916874..41467ba54 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -77,6 +77,7 @@ Release Notes.
- Add FODC memory-pressure pprof capture: agent grabs heap/goroutine profiles
when RSS nears the cgroup limit, served via the proxy.
- Add bounded plugin telemetry (meter + logger) to the trace-pipeline sampler
SDK. A sampler plugin opts in by implementing `sdk.HostAware`; the engine calls
`UseHost(h)` once per group before the first `Decide`, delivering a scoped
`sdk.Host` the plugin may cache and use in `Decide`. The host enforces all
resource limits: metric names are forced under the
`banyandb_trace_pipeline_plugin_` prefix; `{group, plugin_name}` labels are
always present; per-plugin cardinality is capped at 100 s [...]
- Add a Claude/Codex plugin packaging the BanyanDB MCP server with a
parse-only `validate_bydbql` tool (backed by the `mcp/tools/bydbql-parse` Go
validator) and a BydbQL skill for read-only natural-language-to-BydbQL
generation over STREAM/MEASURE/TRACE/PROPERTY resources.
+- 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'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..a2ab16166 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.
+- `int` parameters are rejected in `TIME` positions at bind time: the
transformer cannot parse a bare integer as a timestamp, so use `str` or
`timestamp` instead.
+- `int` parameters in count positions must fit the wire type of the position —
`[0, 4294967295]` for `LIMIT`/`OFFSET` and `[0, 2147483647]` for the `TOP N`
counts; out-of-range values are rejected at bind time instead of silently
wrapping, and literal counts are held to the same bounds.
+- 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.test.ts b/mcp/src/query/validation.test.ts
index faa6f7f5b..0937e0409 100644
--- a/mcp/src/query/validation.test.ts
+++ b/mcp/src/query/validation.test.ts
@@ -17,7 +17,7 @@
import { describe, expect, it } from 'vitest';
-import { validateBydbQL } from './validation.js';
+import { toTagValueParams, validateBydbQL } from './validation.js';
describe('validateBydbQL (read-only first gate)', () => {
it('accepts a SELECT query and returns the trimmed value', () => {
@@ -60,3 +60,83 @@ describe('validateBydbQL (read-only first gate)', () => {
expect(() => validateBydbQL(overLength)).toThrow('BydbQL exceeds the
maximum length of 4096 characters');
});
});
+
+describe('toTagValueParams (BydbQL parameter validation and conversion)', ()
=> {
+ it('converts every parameter type to its protojson TagValue shape', () => {
+ expect(
+ toTagValueParams([
+ { type: 'str', value: 'error' },
+ { type: 'int', value: 42 },
+ { type: 'int', value: '-7' },
+ { type: 'str_array', value: ['a', 'b'] },
+ { type: 'int_array', value: [1, '2'] },
+ { type: 'null' },
+ ]),
+ ).toEqual([
+ { str: { value: 'error' } },
+ { int: { value: '42' } },
+ { int: { value: '-7' } },
+ { strArray: { value: ['a', 'b'] } },
+ { intArray: { value: ['1', '2'] } },
+ { null: null },
+ ]);
+ });
+
+ it('accepts the int64 boundary values', () => {
+ expect(
+ toTagValueParams([
+ { type: 'int', value: '9223372036854775807' },
+ { type: 'int', value: '-9223372036854775808' },
+ ]),
+ ).toEqual([{ int: { value: '9223372036854775807' } }, { int: { value:
'-9223372036854775808' } }]);
+ });
+
+ it('rejects int64-overflowing values with a specific client-side message',
() => {
+ expect(() => toTagValueParams([{ type: 'int', value: '9223372036854775808'
}])).toThrow(
+ 'params[0]: int value 9223372036854775808 is out of the int64 range',
+ );
+ expect(() => toTagValueParams([{ type: 'int', value:
'99999999999999999999' }])).toThrow(
+ 'params[0]: int value 99999999999999999999 is out of the int64 range',
+ );
+ expect(() => toTagValueParams([{ type: 'int', value:
'999999999999999999999' }])).toThrow(
+ 'must be an integer of at most 20 characters',
+ );
+ });
+
+ it('rejects non-integer and unsafe number values', () => {
+ expect(() => toTagValueParams([{ type: 'int', value: 'abc'
}])).toThrow('params[0]: int value must be an integer');
+ expect(() => toTagValueParams([{ type: 'int', value: 1.5
}])).toThrow('params[0]: int value must be an integer');
+ expect(() => toTagValueParams([{ type: 'int', value:
Number.MAX_SAFE_INTEGER + 1 }])).toThrow(
+ 'params[0]: int value must be an integer',
+ );
+ });
+
+ it('rejects type mismatches', () => {
+ expect(() => toTagValueParams([{ type: 'str', value: 42
}])).toThrow('params[0]: str value must be a string');
+ expect(() => toTagValueParams([{ type: 'str_array', value: 'not-an-array'
}])).toThrow(
+ 'params[0]: array value must be a non-empty array',
+ );
+ expect(() => toTagValueParams([{ type: 'unknown' } as
never])).toThrow('params[0]: type must be one of');
+ expect(() => toTagValueParams(['bare-string' as never])).toThrow(
+ 'params[0]: each parameter must be an object with a "type" field',
+ );
+ });
+
+ it('rejects empty and oversized arrays', () => {
+ expect(() => toTagValueParams([{ type: 'str_array', value: [] }])).toThrow(
+ 'params[0]: array value must be a non-empty array',
+ );
+ const oversized = Array.from({ length: 257 }, (_, index) => String(index));
+ expect(() => toTagValueParams([{ type: 'str_array', value: oversized
}])).toThrow(
+ 'params[0]: array value exceeds 256 entries',
+ );
+ });
+
+ it('rejects oversized str values and too many parameters', () => {
+ expect(() => toTagValueParams([{ type: 'str', value: 'a'.repeat(4097)
}])).toThrow(
+ 'params[0]: value exceeds 4096 characters',
+ );
+ const tooMany = Array.from({ length: 65 }, () => ({ type: 'str', value:
'x' }) as const);
+ expect(() => toTagValueParams([...tooMany])).toThrow('params exceeds 64
entries');
+ });
+});
diff --git a/mcp/src/query/validation.ts b/mcp/src/query/validation.ts
index bd3ca12a9..ea0765929 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,25 @@ 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;
+// An int64 is at most 19 digits plus an optional sign.
+const maxIntParamLength = 20;
+const maxParamArrayLength = 256;
+const allowedParamTypes = ['str', 'int', 'str_array', 'int_array', 'null'] as
const;
+const integerPattern = /^-?\d+$/;
+const int64Min = BigInt('-9223372036854775808');
+const int64Max = BigInt('9223372036854775807');
+
+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 +137,89 @@ 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' && value.length <= maxIntParamLength &&
integerPattern.test(value)) {
+ // Reject int64 overflow here with a specific message instead of deferring
+ // the failure to an opaque protojson decoding error on the server.
+ const parsed = BigInt(value);
+ if (parsed < int64Min || parsed > int64Max) {
+ throw new Error(`params[${position}]: int value ${value} is out of the
int64 range`);
+ }
+ return value;
+ }
+ throw new Error(`params[${position}]: int value must be an integer of at
most ${maxIntParamLength} characters`);
+}
+
+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.
+ *
+ * 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 +256,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 f6fcdb605..6fa72dd0b 100644
--- a/mcp/src/server/mcp.ts
+++ b/mcp/src/server/mcp.ts
@@ -25,7 +25,12 @@ import { loadQueryContext } from '../query/context.js';
import { generateBydbQL } from '../query/llm-prompt.js';
import type { BydbQLParseValidationResult } from '../query/parse-validator.js';
import { validateBydbQLSyntax } from '../query/parse-validator.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) {
@@ -80,7 +85,29 @@ 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',
@@ -200,9 +227,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..4b63c29fe
--- /dev/null
+++ b/pkg/bydbql/binder.go
@@ -0,0 +1,375 @@
+// 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.
+//
+// Binding mutates the grammar in place, so a grammar binds exactly once:
rebinding is
+// rejected and a failed bind leaves the grammar partially bound and unusable
— parse the
+// query again in both cases. Parse-once/bind-many reuse requires an immutable
binding
+// design and is tracked as a follow-up.
+func BindParams(g *Grammar, params []*modelv1.TagValue) error {
+ if g.paramsBound {
+ return fmt.Errorf("grammar is already bound; parse the query
again to bind new parameters")
+ }
+ 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()
+ g.paramsBound = true
+ return nil
+}
+
+// countUnboundParams reports how many `?` placeholders in the grammar are
still
+// unbound, using the same traversal as binding but without allocating slots.
+func countUnboundParams(g *Grammar) int {
+ b := &binder{countOnly: true}
+ b.collect(g)
+ return b.count
+}
+
+type listContainer struct {
+ assign func([]*GrammarValue)
+ values []*GrammarValue
+}
+
+type binder struct {
+ expansions map[*GrammarValue][]*GrammarValue
+ slots []func(*modelv1.TagValue) error
+ lists []listContainer
+ // countOnly makes collect count placeholders instead of building slots.
+ countOnly bool
+ count int
+}
+
+// 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, math.MaxInt32)
+ }
+ 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, math.MaxUint32)
+ }
+ if g.Select.Offset != nil {
+ b.collectIntSlot(&g.Select.Offset.Value,
&g.Select.Offset.Param, math.MaxUint32)
+ }
+ }
+ if g.TopN != nil {
+ b.collectIntSlot(&g.TopN.N, &g.TopN.NParam, math.MaxInt32)
+ b.collectTime(g.TopN.Time)
+ if g.TopN.Where != nil {
+ b.collectAndExpr(g.TopN.Where.Expr)
+ }
+ }
+}
+
+// validateCountValue rejects count values that would silently wrap when the
+// transformer narrows them: LIMIT/OFFSET are uint32 on the wire while the TOP
N
+// counts are int32, so each position passes its own upper bound. It guards
both
+// the bound path and the literal path so the two cannot diverge.
+func validateCountValue(label string, value, maxValue int64) error {
+ if value < 0 || value > maxValue {
+ return fmt.Errorf("%s value %d is out of range [0, %d]", label,
value, maxValue)
+ }
+ return nil
+}
+
+// validateGrammarCounts applies the wrap guard to every literal count position
+// of a grammar: LIMIT, OFFSET, and the two TOP N counts.
+func validateGrammarCounts(g *Grammar) error {
+ if g.Select != nil {
+ if g.Select.Projection != nil && g.Select.Projection.TopN !=
nil {
+ if err := validateCountValue("TOP",
int64(g.Select.Projection.TopN.N), math.MaxInt32); err != nil {
+ return err
+ }
+ }
+ if g.Select.Limit != nil {
+ if err := validateCountValue("LIMIT",
int64(g.Select.Limit.Value), math.MaxUint32); err != nil {
+ return err
+ }
+ }
+ if g.Select.Offset != nil {
+ if err := validateCountValue("OFFSET",
int64(g.Select.Offset.Value), math.MaxUint32); err != nil {
+ return err
+ }
+ }
+ }
+ if g.TopN != nil {
+ if err := validateCountValue("SHOW TOP", int64(g.TopN.N),
math.MaxInt32); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// collectIntSlot registers a placeholder in an integer-only position
+// (LIMIT, OFFSET, or a TOP N count) with that position's upper bound.
+func (b *binder) collectIntSlot(value *int, param *bool, maxValue int64) {
+ if !*param {
+ return
+ }
+ if b.countOnly {
+ b.count++
+ 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)
+ }
+ bound := intVal.Int.GetValue()
+ if err := validateCountValue("int parameter", bound, maxValue);
err != nil {
+ return err
+ }
+ *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
+ }
+ if b.countOnly {
+ b.count++
+ 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
+ }
+ if b.countOnly {
+ b.count++
+ 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
+ if b.countOnly {
+ b.count++
+ continue
+ }
+ b.slots = append(b.slots, func(p *modelv1.TagValue) error {
return b.bindListValue(v, p) })
+ }
+ if hasParam && !b.countOnly {
+ 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_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:
+ // int is rejected as well: the transformer cannot parse a bare
integer
+ // as a timestamp, so accepting it would only defer a certain
failure.
+ return fmt.Errorf("time clause only accepts str 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..3551ca0a4
--- /dev/null
+++ b/pkg/bydbql/binder_equivalence_test.go
@@ -0,0 +1,437 @@
+// 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)),
+ },
+ },
+ // 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)
+ })
+
+ Describe("literal count wrap guard", func() {
+ // The bound path rejects out-of-range counts at bind time; the
literal
+ // path must be equally strict instead of wrapping in the
uint32 cast.
+ It("accepts the uint32 maximum as a literal LIMIT", func() {
+ // Regression for the pre-existing max-limit
integration case:
+ // LIMIT is uint32 on the wire, so 4294967295 is a
legal literal.
+ grammar, err := ParseQuery("SELECT * FROM STREAM sw IN
default LIMIT 4294967295")
+ Expect(err).To(BeNil())
+ Expect(BindParams(grammar, nil)).To(Succeed())
+ result, err :=
transformer.Transform(context.Background(), grammar)
+ Expect(err).To(BeNil())
+ Expect(result).NotTo(BeNil())
+ })
+
+ It("rejects a negative literal LIMIT", func() {
+ grammar, err := ParseQuery("SELECT * FROM STREAM sw IN
default LIMIT -5")
+ Expect(err).To(BeNil())
+ Expect(BindParams(grammar, nil)).To(Succeed())
+ _, err = transformer.Transform(context.Background(),
grammar)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("out of range"))
+ })
+
+ It("rejects an int32-overflowing literal SHOW TOP count",
func() {
+ grammar, err := ParseQuery("SHOW TOP 3000000000 FROM
MEASURE svc_topn IN default")
+ Expect(err).To(BeNil())
+ Expect(BindParams(grammar, nil)).To(Succeed())
+ _, err = transformer.Transform(context.Background(),
grammar)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("out of range"))
+ })
+ })
+
+ 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{}), cmpopts.IgnoreUnexported(Grammar{}))
+ 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..b3f4a7646
--- /dev/null
+++ b/pkg/bydbql/binder_test.go
@@ -0,0 +1,597 @@
+// 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"))
+ })
+
+ It("rejects an int parameter in the TIME clause", func() {
+ grammar := parse("SELECT * FROM STREAM sw IN default
TIME > ?")
+ err := BindParams(grammar,
[]*modelv1.TagValue{intParam(1720000000)})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("only accepts
str or timestamp"))
+ })
+ })
+
+ 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("accepts the uint32 maximum in LIMIT", func() {
+ // LIMIT/OFFSET are uint32 on the wire, so their bound
accepts up to
+ // math.MaxUint32 — the same value the max-limit
literal query uses.
+ grammar := parse("SELECT * FROM STREAM sw IN default
LIMIT ?")
+ Expect(BindParams(grammar,
[]*modelv1.TagValue{intParam(math.MaxUint32)})).To(Succeed())
+
Expect(grammar.Select.Limit.Value).To(Equal(int(math.MaxUint32)))
+ })
+
+ It("rejects an int parameter beyond uint32 in OFFSET", func() {
+ grammar := parse("SELECT * FROM STREAM sw IN default
OFFSET ?")
+ err := BindParams(grammar,
[]*modelv1.TagValue{intParam(math.MaxUint32 + 1)})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("out of range"))
+ })
+
+ It("rejects an int parameter beyond int32 in the SHOW TOP
count", func() {
+ grammar := parse("SHOW TOP ? FROM MEASURE metrics IN
default")
+ 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("already
bound"))
+ })
+
+ It("rejects a second bind even with no parameters", func() {
+ grammar := parse("SELECT * FROM STREAM sw IN default
WHERE service_id = ?")
+ Expect(BindParams(grammar,
[]*modelv1.TagValue{strParam("svc")})).To(Succeed())
+ err := BindParams(grammar, nil)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("already
bound"))
+
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, "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:
×tamppb.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..7062461a5 100644
--- a/pkg/bydbql/grammar.go
+++ b/pkg/bydbql/grammar.go
@@ -33,6 +33,11 @@ import (
type Grammar struct {
Select *GrammarSelectStatement `parser:" @@"`
TopN *GrammarTopNStatement `parser:"| @@"`
+
+ // paramsBound records that BindParams has completed on this grammar: a
+ // bound grammar rejects further binding and lets the transformer skip
the
+ // unbound-placeholder walk.
+ paramsBound bool
}
// GrammarSelectStatement represents a SELECT statement in Participle grammar.
@@ -55,7 +60,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 +80,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 +147,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 +259,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 +328,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..d1460b85e 100644
--- a/pkg/bydbql/transformer.go
+++ b/pkg/bydbql/transformer.go
@@ -101,6 +101,19 @@ 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. A
bound
+ // grammar is guaranteed placeholder-free, so it skips the walk.
+ if !grammar.paramsBound {
+ if unbound := countUnboundParams(grammar); unbound > 0 {
+ return nil, fmt.Errorf("query contains %d unbound
placeholder(s); BindParams must be called before Transform", unbound)
+ }
+ }
+ // Literal counts get the same wrap guard as bound parameters, so LIMIT
-5
+ // fails loudly instead of wrapping when narrowed to uint32.
+ if err := validateGrammarCounts(grammar); err != nil {
+ return nil, err
+ }
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/data/input/params_bind.ql
b/test/cases/topn/data/input/params_bind.ql
new file mode 100644
index 000000000..581cd3392
--- /dev/null
+++ b/test/cases/topn/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.
+
+SHOW TOP ?
+FROM MEASURE service_instance_cpm_minute_top_bottom_100 IN sw_metric
+TIME > ?
+WHERE service_id = ?
+AGGREGATE BY MAX
+ORDER BY DESC
+#!params:
[{"int":{"value":"1"}},{"str":{"value":"-15m"}},{"str":{"value":"svc_1"}}]
diff --git a/test/cases/topn/data/input/params_bind.yaml
b/test/cases/topn/data/input/params_bind.yaml
new file mode 100644
index 000000000..f893cbdb3
--- /dev/null
+++ b/test/cases/topn/data/input/params_bind.yaml
@@ -0,0 +1,28 @@
+# 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_instance_cpm_minute_top_bottom_100"
+groups: ["sw_metric"]
+topN: 1
+fieldValueSort: 1
+agg: 2
+conditions:
+- name: service_id
+ op: 1
+ value:
+ str:
+ value: "svc_1"
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())