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

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

commit 3d8ffa7baa9780b90ae00647cc749f80f1aeee4b
Author: mrproliu <[email protected]>
AuthorDate: Thu Jul 9 11:36:34 2026 +0800

    Introduce reusable `Prepared` QL binding
---
 CHANGES.md                            |   1 +
 pkg/bydbql/binder.go                  | 157 ++++++++++-----
 pkg/bydbql/binder_equivalence_test.go |  79 +++++++-
 pkg/bydbql/binder_test.go             |   2 +-
 pkg/bydbql/grammar.go                 |  34 ++--
 pkg/bydbql/prepared.go                | 239 ++++++++++++++++++++++
 pkg/bydbql/prepared_internal_test.go  | 221 ++++++++++++++++++++
 pkg/bydbql/transformer.go             | 368 +++++++++++++++++++++-------------
 8 files changed, 900 insertions(+), 201 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 41467ba54..479889b94 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -78,6 +78,7 @@ Release Notes.
 - 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.
+- Add reusable BydbQL binding: `Prepare` a query once, then `Bind` it many 
times without re-parsing or mutating the template.
 
 ### Bug Fixes
 
diff --git a/pkg/bydbql/binder.go b/pkg/bydbql/binder.go
index 4b63c29fe..3d57620cd 100644
--- a/pkg/bydbql/binder.go
+++ b/pkg/bydbql/binder.go
@@ -34,8 +34,8 @@ import (
 //
 // 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.
+// query again in both cases. For parse-once/bind-many reuse, use Prepare and
+// (*PreparedStatement).Bind, which never mutate the parsed template.
 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")
@@ -159,15 +159,11 @@ func (b *binder) collectIntSlot(value *int, param *bool, 
maxValue int64) {
                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 {
+               bound, err := resolveCountParam(p, maxValue)
+               if err != nil {
                        return err
                }
-               *value = int(bound)
+               *value = bound
                *param = false
                return nil
        })
@@ -287,31 +283,17 @@ func (b *binder) collectValueList(values []*GrammarValue, 
assign func([]*Grammar
 }
 
 func (b *binder) bindListValue(v *GrammarValue, p *modelv1.TagValue) error {
-       switch val := p.Value.(type) {
+       switch 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})
+       default:
+               expanded, err := resolveArrayElements(p)
+               if err != nil {
+                       return err
                }
                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
        }
-       return nil
 }
 
 func (b *binder) recordExpansion(v *GrammarValue, expanded []*GrammarValue) {
@@ -336,40 +318,115 @@ func (b *binder) expandLists() {
 }
 
 func bindTimeValue(v *GrammarTimeValue, p *modelv1.TagValue) error {
+       strVal, err := resolveTimeParam(p)
+       if err != nil {
+               return err
+       }
+       v.String = &strVal
+       v.Param = false
+       return nil
+}
+
+func bindScalarValue(v *GrammarValue, p *modelv1.TagValue) error {
+       resolved, err := resolveScalarParam(p)
+       if err != nil {
+               return err
+       }
+       v.String, v.Integer, v.Null = resolved.String, resolved.Integer, 
resolved.Null
+       v.Param = false
+       return nil
+}
+
+// resolveScalarParam produces a fresh literal value node for a str/int/null
+// parameter. It is the single source of scalar type acceptance, shared by the
+// in-place bind path and the reusable Bind path.
+func resolveScalarParam(p *modelv1.TagValue) (*GrammarValue, error) {
        switch val := p.Value.(type) {
        case *modelv1.TagValue_Str:
                strVal := val.Str.GetValue()
-               v.String = &strVal
+               return &GrammarValue{String: &strVal}, nil
+       case *modelv1.TagValue_Int:
+               intVal := val.Int.GetValue()
+               return &GrammarValue{Integer: &intVal}, nil
+       case *modelv1.TagValue_Null:
+               return &GrammarValue{Null: true}, nil
+       default:
+               return nil, fmt.Errorf("this position only accepts str, int, or 
null parameters, got %T", p.Value)
+       }
+}
+
+// resolveTimeParam produces the time string for a str/timestamp parameter. int
+// is rejected: the transformer cannot parse a bare integer as a timestamp, so
+// accepting it would only defer a certain failure.
+func resolveTimeParam(p *modelv1.TagValue) (string, error) {
+       switch val := p.Value.(type) {
+       case *modelv1.TagValue_Str:
+               return val.Str.GetValue(), nil
        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)
+                       return "", fmt.Errorf("invalid timestamp parameter: 
%w", err)
                }
-               strVal := val.Timestamp.AsTime().Format(time.RFC3339Nano)
-               v.String = &strVal
+               return val.Timestamp.AsTime().Format(time.RFC3339Nano), nil
        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)
+               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 {
+// resolveArrayElements produces the literal nodes a str_array/int_array
+// parameter expands to, rejecting empty arrays.
+func resolveArrayElements(p *modelv1.TagValue) ([]*GrammarValue, 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
+       case *modelv1.TagValue_StrArray:
+               if len(val.StrArray.GetValue()) == 0 {
+                       return nil, 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})
+               }
+               return expanded, nil
+       case *modelv1.TagValue_IntArray:
+               if len(val.IntArray.GetValue()) == 0 {
+                       return nil, 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})
+               }
+               return expanded, nil
        default:
-               return fmt.Errorf("this position only accepts str, int, or null 
parameters, got %T", p.Value)
+               return nil, fmt.Errorf("value list only accepts str, int, null, 
str_array, or int_array parameters, got %T", p.Value)
        }
-       v.Param = false
-       return nil
+}
+
+// resolveListParam produces the value node(s) an IN/MATCH/HAVING placeholder
+// resolves to: one for a scalar, N for an array expanded in place. It mirrors
+// bindListValue's scalar-vs-array split so both paths accept the same types.
+func resolveListParam(p *modelv1.TagValue) ([]*GrammarValue, error) {
+       switch p.Value.(type) {
+       case *modelv1.TagValue_Str, *modelv1.TagValue_Int, 
*modelv1.TagValue_Null:
+               v, err := resolveScalarParam(p)
+               if err != nil {
+                       return nil, err
+               }
+               return []*GrammarValue{v}, nil
+       default:
+               return resolveArrayElements(p)
+       }
+}
+
+// resolveCountParam produces the int for a LIMIT/OFFSET/TOP N parameter within
+// that position's upper bound.
+func resolveCountParam(p *modelv1.TagValue, maxValue int64) (int, error) {
+       intVal, ok := p.Value.(*modelv1.TagValue_Int)
+       if !ok {
+               return 0, 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 0, err
+       }
+       return int(bound), nil
 }
diff --git a/pkg/bydbql/binder_equivalence_test.go 
b/pkg/bydbql/binder_equivalence_test.go
index 3551ca0a4..0a9abf41e 100644
--- a/pkg/bydbql/binder_equivalence_test.go
+++ b/pkg/bydbql/binder_equivalence_test.go
@@ -19,6 +19,8 @@ package bydbql_test
 
 import (
        "context"
+       "fmt"
+       "sync"
        "time"
 
        "github.com/alecthomas/participle/v2/lexer"
@@ -329,6 +331,20 @@ func equivalenceCases() []equivalenceCase {
                        params:          []*modelv1.TagValue{intParam(5), 
strParam("-1h"), strParam("svc")},
                        ignoreTimeRange: true,
                },
+               // PROPERTY query: the id-extraction path resolves placeholders 
separately
+               // from the criteria path, so it needs its own coverage.
+               {
+                       name:          "str param in PROPERTY id =",
+                       parameterized: "SELECT env FROM PROPERTY sw_prop IN 
default WHERE id = ?",
+                       literal:       "SELECT env FROM PROPERTY sw_prop IN 
default WHERE id = 'k1'",
+                       params:        []*modelv1.TagValue{strParam("k1")},
+               },
+               {
+                       name:          "str_array param expands in PROPERTY id 
IN with a criteria param",
+                       parameterized: "SELECT env FROM PROPERTY sw_prop IN 
default WHERE id IN (?) AND env = ?",
+                       literal:       "SELECT env FROM PROPERTY sw_prop IN 
default WHERE id IN ('k1', 'k2') AND env = 'prod'",
+                       params:        []*modelv1.TagValue{strArrayParam("k1", 
"k2"), strParam("prod")},
+               },
        }
 }
 
@@ -353,10 +369,16 @@ var _ = Describe("BindParams equivalence with literal 
queries", func() {
                        }},
                        Fields: []*databasev1.FieldSpec{{Name: "value", 
FieldType: databasev1.FieldType_FIELD_TYPE_INT}},
                }, nil).AnyTimes()
+               propertyRegistry := schema.NewMockProperty(ctrl)
+               propertyRegistry.EXPECT().GetProperty(gomock.Any(), 
gomock.Any()).Return(&databasev1.Property{
+                       Metadata: &commonv1.Metadata{Name: "sw_prop", Group: 
"default"},
+                       Tags:     []*databasev1.TagSpec{{Name: "env", Type: 
databasev1.TagType_TAG_TYPE_STRING}},
+               }, nil).AnyTimes()
                mockRepo := metadata.NewMockRepo(ctrl)
                
mockRepo.EXPECT().StreamRegistry().AnyTimes().Return(streamRegistry)
                
mockRepo.EXPECT().TopNAggregationRegistry().AnyTimes().Return(topNRegistry)
                
mockRepo.EXPECT().MeasureRegistry().AnyTimes().Return(measureRegistry)
+               
mockRepo.EXPECT().PropertyRegistry().AnyTimes().Return(propertyRegistry)
                transformer = NewTransformer(mockRepo)
        })
 
@@ -406,19 +428,31 @@ var _ = Describe("BindParams equivalence with literal 
queries", func() {
                        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
+                       // Both the in-place bind and the reusable Prepare/Bind 
path must
+                       // transform to the same native request as the literal 
query.
                        ctx := context.Background()
                        literalResult, literalErr := transformer.Transform(ctx, 
literalGrammar)
                        boundResult, boundErr := transformer.Transform(ctx, 
boundGrammar)
+
+                       prepared, prepErr := Prepare(testCase.parameterized)
+                       Expect(prepErr).To(BeNil())
+                       bq, bindErr := prepared.Bind(testCase.params)
+                       Expect(bindErr).To(BeNil())
+                       preparedResult, preparedErr := 
transformer.TransformBound(ctx, bq)
+
                        if literalErr != nil {
                                Expect(testCase.expectError).To(BeTrue(), 
"unexpected transform error: %v", literalErr)
                                Expect(boundErr).To(HaveOccurred())
                                
Expect(boundErr.Error()).To(Equal(literalErr.Error()))
+                               Expect(preparedErr).To(HaveOccurred())
+                               
Expect(preparedErr.Error()).To(Equal(literalErr.Error()))
                                return
                        }
                        Expect(testCase.expectError).To(BeFalse(), "expected a 
transform error but both sides succeeded")
                        Expect(boundErr).To(BeNil())
+                       Expect(preparedErr).To(BeNil())
                        Expect(boundResult.Type).To(Equal(literalResult.Type))
+                       
Expect(preparedResult.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{
@@ -430,8 +464,47 @@ var _ = Describe("BindParams equivalence with literal 
queries", func() {
                                        
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)
+                       Expect(cmp.Diff(literalResult.QueryRequest, 
boundResult.QueryRequest, opts...)).
+                               To(BeEmpty(), "in-place bind request mismatch")
+                       Expect(cmp.Diff(literalResult.QueryRequest, 
preparedResult.QueryRequest, opts...)).
+                               To(BeEmpty(), "prepared bind request mismatch")
                })
        }
+
+       It("reuses one prepared statement concurrently without cross-talk", 
func() {
+               prepared, err := Prepare("SELECT * FROM STREAM sw IN default 
WHERE service_id = ?")
+               Expect(err).To(BeNil())
+               const workers = 32
+               var wg sync.WaitGroup
+               errs := make(chan error, workers)
+               for i := 0; i < workers; i++ {
+                       wg.Add(1)
+                       go func(n int) {
+                               defer wg.Done()
+                               want := fmt.Sprintf("svc-%d", n)
+                               bq, bindErr := 
prepared.Bind([]*modelv1.TagValue{strParam(want)})
+                               if bindErr != nil {
+                                       errs <- bindErr
+                                       return
+                               }
+                               result, transformErr := 
transformer.TransformBound(context.Background(), bq)
+                               if transformErr != nil {
+                                       errs <- transformErr
+                                       return
+                               }
+                               got := 
result.QueryRequest.(*streamv1.QueryRequest).Criteria.GetCondition().GetValue().GetStr().GetValue()
+                               if got != want {
+                                       errs <- fmt.Errorf("worker %d: got %q, 
want %q", n, got, want)
+                               }
+                       }(i)
+               }
+               wg.Wait()
+               close(errs)
+               for e := range errs {
+                       Expect(e).NotTo(HaveOccurred())
+               }
+               // Under -race, correct per-worker results also prove the 
shared template
+               // is never mutated during concurrent binds. 
TestBindTemplateStaysImmutable
+               // asserts the template fields directly.
+       })
 })
diff --git a/pkg/bydbql/binder_test.go b/pkg/bydbql/binder_test.go
index b3f4a7646..e6845d4ca 100644
--- a/pkg/bydbql/binder_test.go
+++ b/pkg/bydbql/binder_test.go
@@ -377,7 +377,7 @@ var _ = Describe("BindParams", func() {
                        }
                        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")
+                               "grammar @Param positions changed: wire any new 
position into binder.collect, preparer.walkGrammar, the acceptance matrix, and 
this list")
                })
        })
 
diff --git a/pkg/bydbql/grammar.go b/pkg/bydbql/grammar.go
index 7062461a5..e0022a139 100644
--- a/pkg/bydbql/grammar.go
+++ b/pkg/bydbql/grammar.go
@@ -58,10 +58,11 @@ type GrammarSelectStatement struct {
 // GrammarTopNStatement represents a SHOW TOP N statement.
 type GrammarTopNStatement struct {
        Pos            lexer.Position
-       Show           string                        `parser:"@'SHOW'"`
-       Top            string                        `parser:"@'TOP'"`
-       N              int                           `parser:"( @Int"`
-       NParam         bool                          `parser:"| @Param )"`
+       Show           string `parser:"@'SHOW'"`
+       Top            string `parser:"@'TOP'"`
+       N              int    `parser:"( @Int"`
+       NParam         bool   `parser:"| @Param )"`
+       NParamIndex    int
        From           *GrammarFromClause            `parser:"@@"`
        Time           *GrammarTimeClause            `parser:"@@?"`
        Where          *GrammarTopNWhereClause       `parser:"@@?"`
@@ -80,8 +81,9 @@ type GrammarProjection struct {
 
 // GrammarTopNProjection represents TOP N projection.
 type GrammarTopNProjection struct {
-       N            int                    `parser:"( @Int"`
-       NParam       bool                   `parser:"| @Param )"`
+       N            int  `parser:"( @Int"`
+       NParam       bool `parser:"| @Param )"`
+       NParamIndex  int
        OrderField   *GrammarIdentifierPath `parser:"@@"`
        Direction    *string                `parser:"@('ASC'|'DESC')?"`
        OtherColumns []*GrammarColumn       `parser:"  ( ',' @@ ( ',' @@ )* )?"`
@@ -152,6 +154,9 @@ type GrammarTimeValue struct {
        String  *string `parser:"  @String"`
        Integer *int64  `parser:"| @Int"`
        Param   bool    `parser:"| @Param"`
+       // ParamIndex is the placeholder's positional index, assigned by 
Prepare and
+       // meaningful only when Param is true. It keys the per-request bound 
overlay.
+       ParamIndex int
 }
 
 // GrammarSelectWhereClause represents WHERE clause.
@@ -260,6 +265,9 @@ type GrammarValue struct {
        Integer *int64  `parser:"| @Int"`
        Null    bool    `parser:"| @'NULL'"`
        Param   bool    `parser:"| @Param"`
+       // ParamIndex is the placeholder's positional index, assigned by 
Prepare and
+       // meaningful only when Param is true. It keys the per-request bound 
overlay.
+       ParamIndex int
 }
 
 // GrammarIdentifierPart Can be either an Ident or a Keyword (keywords are 
allowed in paths, but not as standalone identifiers).
@@ -327,16 +335,18 @@ type GrammarOrderByWithIdent struct {
 
 // GrammarLimitClause represents LIMIT clause.
 type GrammarLimitClause struct {
-       Limit string `parser:"@'LIMIT'"`
-       Value int    `parser:"( @Int"`
-       Param bool   `parser:"| @Param )"`
+       Limit      string `parser:"@'LIMIT'"`
+       Value      int    `parser:"( @Int"`
+       Param      bool   `parser:"| @Param )"`
+       ParamIndex int
 }
 
 // GrammarOffsetClause represents OFFSET clause.
 type GrammarOffsetClause struct {
-       Offset string `parser:"@'OFFSET'"`
-       Value  int    `parser:"( @Int"`
-       Param  bool   `parser:"| @Param )"`
+       Offset     string `parser:"@'OFFSET'"`
+       Value      int    `parser:"( @Int"`
+       Param      bool   `parser:"| @Param )"`
+       ParamIndex int
 }
 
 // GrammarWithTraceClause represents WITH QUERY_TRACE clause.
diff --git a/pkg/bydbql/prepared.go b/pkg/bydbql/prepared.go
new file mode 100644
index 000000000..92e0aaa5f
--- /dev/null
+++ b/pkg/bydbql/prepared.go
@@ -0,0 +1,239 @@
+// 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"
+
+       modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// placeholderKind classifies a `?` placeholder by the position it occupies,
+// which determines the parameter types Bind accepts and how Transform reads 
it.
+type placeholderKind uint8
+
+const (
+       // phScalar is a WHERE/HAVING comparison value: str, int, or null.
+       phScalar placeholderKind = iota
+       // phList is an element of an IN/MATCH/HAVING value list: scalar or an 
array
+       // that expands in place.
+       phList
+       // phTime is a TIME value: str or timestamp.
+       phTime
+       // phCount is a LIMIT/OFFSET/TOP N count: an int within maxCount.
+       phCount
+)
+
+// placeholderSpec describes one placeholder, recorded once by Prepare and read
+// per request by Bind (to validate the parameter) and Transform (to read it).
+type placeholderSpec struct {
+       maxCount int64 // upper bound for phCount positions
+       kind     placeholderKind
+}
+
+// PreparedStatement is an immutable, reusable parsed query. It is safe to 
cache
+// and to Bind concurrently: Prepare numbers the placeholders once, and Bind
+// never mutates the template — the bound values live in a per-request overlay.
+type PreparedStatement struct {
+       template *Grammar
+       specs    []placeholderSpec
+}
+
+// Prepare parses a query and numbers its `?` placeholders so it can be bound
+// repeatedly with different parameters without re-parsing.
+func Prepare(query string) (*PreparedStatement, error) {
+       g, err := ParseQuery(query)
+       if err != nil {
+               return nil, err
+       }
+       p := &preparer{}
+       p.walkGrammar(g)
+       return &PreparedStatement{template: g, specs: p.specs}, nil
+}
+
+// resolvedParam holds one placeholder's bound value in the per-request 
overlay.
+// Which field is populated follows the placeholder's kind: a scalar/list
+// position fills values (one node, or several for an expanded array), a TIME
+// position fills timeStr, and a count position fills count.
+type resolvedParam struct {
+       timeStr string
+       values  []*GrammarValue
+       count   int
+}
+
+// BoundQuery pairs an immutable prepared statement with the values bound for 
one
+// request. Its values are the per-request overlay indexed by placeholder
+// position, allocated by Bind and never shared or mutated after. Pass it to
+// Transformer.TransformBound.
+type BoundQuery struct {
+       stmt   *PreparedStatement
+       values []resolvedParam
+}
+
+// Bind resolves params against the statement's placeholders and returns a
+// per-request BoundQuery, leaving the template untouched, so a statement may 
be
+// bound repeatedly and concurrently. Parameter positions in errors are 
1-based,
+// matching BindParams.
+func (ps *PreparedStatement) Bind(params []*modelv1.TagValue) (*BoundQuery, 
error) {
+       if len(params) != len(ps.specs) {
+               return nil, fmt.Errorf("parameter count mismatch: query 
contains %d placeholder(s) but %d parameter(s) provided", len(ps.specs), 
len(params))
+       }
+       values := make([]resolvedParam, len(ps.specs))
+       for i, spec := range ps.specs {
+               p := params[i]
+               if p.GetValue() == nil {
+                       return nil, fmt.Errorf("parameter #%d has no value", 
i+1)
+               }
+               var err error
+               switch spec.kind {
+               case phScalar:
+                       var v *GrammarValue
+                       v, err = resolveScalarParam(p)
+                       if err == nil {
+                               values[i].values = []*GrammarValue{v}
+                       }
+               case phList:
+                       values[i].values, err = resolveListParam(p)
+               case phTime:
+                       values[i].timeStr, err = resolveTimeParam(p)
+               case phCount:
+                       values[i].count, err = resolveCountParam(p, 
spec.maxCount)
+               }
+               if err != nil {
+                       return nil, fmt.Errorf("failed to bind parameter #%d: 
%w", i+1, err)
+               }
+       }
+       return &BoundQuery{stmt: ps, values: values}, nil
+}
+
+// preparer walks a parsed grammar in textual order, assigning each placeholder
+// its positional index and recording its spec. The traversal mirrors
+// binder.collect; the reflection tripwire test asserts both cover every
+// placeholder-bearing grammar field.
+type preparer struct {
+       specs []placeholderSpec
+}
+
+func (p *preparer) add(kind placeholderKind, maxCount int64) int {
+       idx := len(p.specs)
+       p.specs = append(p.specs, placeholderSpec{kind: kind, maxCount: 
maxCount})
+       return idx
+}
+
+func (p *preparer) walkGrammar(g *Grammar) {
+       if g.Select != nil {
+               if g.Select.Projection != nil && g.Select.Projection.TopN != 
nil {
+                       if topN := g.Select.Projection.TopN; topN.NParam {
+                               topN.NParamIndex = p.add(phCount, math.MaxInt32)
+                       }
+               }
+               p.walkTime(g.Select.Time)
+               if g.Select.Where != nil {
+                       p.walkOrExpr(g.Select.Where.Expr)
+               }
+               if g.Select.Limit != nil && g.Select.Limit.Param {
+                       g.Select.Limit.ParamIndex = p.add(phCount, 
math.MaxUint32)
+               }
+               if g.Select.Offset != nil && g.Select.Offset.Param {
+                       g.Select.Offset.ParamIndex = p.add(phCount, 
math.MaxUint32)
+               }
+       }
+       if g.TopN != nil {
+               if g.TopN.NParam {
+                       g.TopN.NParamIndex = p.add(phCount, math.MaxInt32)
+               }
+               p.walkTime(g.TopN.Time)
+               if g.TopN.Where != nil {
+                       p.walkAndExpr(g.TopN.Where.Expr)
+               }
+       }
+}
+
+func (p *preparer) walkTime(clause *GrammarTimeClause) {
+       if clause == nil {
+               return
+       }
+       p.walkTimeValue(clause.Value)
+       if clause.Between != nil {
+               p.walkTimeValue(clause.Between.Begin)
+               p.walkTimeValue(clause.Between.End)
+       }
+}
+
+func (p *preparer) walkTimeValue(v *GrammarTimeValue) {
+       if v != nil && v.Param {
+               v.ParamIndex = p.add(phTime, 0)
+       }
+}
+
+func (p *preparer) walkOrExpr(expr *GrammarOrExpr) {
+       if expr == nil {
+               return
+       }
+       p.walkAndExpr(expr.Left)
+       for _, right := range expr.Right {
+               p.walkAndExpr(right.Right)
+       }
+}
+
+func (p *preparer) walkAndExpr(expr *GrammarAndExpr) {
+       if expr == nil {
+               return
+       }
+       p.walkPredicate(expr.Left)
+       for _, right := range expr.Right {
+               p.walkPredicate(right.Right)
+       }
+}
+
+func (p *preparer) walkPredicate(pred *GrammarPredicate) {
+       if pred == nil {
+               return
+       }
+       switch {
+       case pred.Paren != nil:
+               p.walkOrExpr(pred.Paren)
+       case pred.Binary != nil && pred.Binary.Tail != nil:
+               if compare := pred.Binary.Tail.Compare; compare != nil && 
compare.Value != nil && compare.Value.Param {
+                       compare.Value.ParamIndex = p.add(phScalar, 0)
+               }
+               if match := pred.Binary.Tail.Match; match != nil && 
match.Values != nil {
+                       p.walkValueList(match.Values.Single, match.Values.Array)
+               }
+       case pred.In != nil:
+               p.walkValueList(nil, pred.In.Values)
+       case pred.Having != nil && pred.Having.Values != nil:
+               p.walkValueList(pred.Having.Values.Single, 
pred.Having.Values.Array)
+       }
+}
+
+// walkValueList numbers the placeholders in a MATCH/HAVING single-or-array or 
an
+// IN list. A container is parsed as either a single value or an array; both 
are
+// walked the same way here.
+func (p *preparer) walkValueList(single *GrammarValue, array []*GrammarValue) {
+       values := array
+       if single != nil {
+               values = []*GrammarValue{single}
+       }
+       for _, v := range values {
+               if v != nil && v.Param {
+                       v.ParamIndex = p.add(phList, 0)
+               }
+       }
+}
diff --git a/pkg/bydbql/prepared_internal_test.go 
b/pkg/bydbql/prepared_internal_test.go
new file mode 100644
index 000000000..00339ae67
--- /dev/null
+++ b/pkg/bydbql/prepared_internal_test.go
@@ -0,0 +1,221 @@
+// 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 (
+       "math"
+       "strings"
+       "testing"
+
+       modelv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+func tvStr(v string) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: 
&modelv1.Str{Value: v}}}
+}
+
+func tvInt(v int64) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: 
&modelv1.Int{Value: v}}}
+}
+
+func tvStrArray(vs ...string) *modelv1.TagValue {
+       return &modelv1.TagValue{Value: &modelv1.TagValue_StrArray{StrArray: 
&modelv1.StrArray{Value: vs}}}
+}
+
+func TestPrepareNumbersPlaceholdersInTextOrder(t *testing.T) {
+       ps, err := Prepare("SELECT * FROM STREAM sw IN default TIME > ? WHERE 
service_id = ? AND codes IN (?) LIMIT ? OFFSET ?")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       if got := len(ps.specs); got != 5 {
+               t.Fatalf("placeholder count = %d, want 5", got)
+       }
+       wantSpecs := []placeholderSpec{
+               {kind: phTime},
+               {kind: phScalar},
+               {kind: phList},
+               {kind: phCount, maxCount: math.MaxUint32},
+               {kind: phCount, maxCount: math.MaxUint32},
+       }
+       for i, want := range wantSpecs {
+               if ps.specs[i] != want {
+                       t.Errorf("specs[%d] = %+v, want %+v", i, ps.specs[i], 
want)
+               }
+       }
+       sel := ps.template.Select
+       if idx := sel.Time.Value.ParamIndex; idx != 0 {
+               t.Errorf("TIME ParamIndex = %d, want 0", idx)
+       }
+       if idx := 
sel.Where.Expr.Left.Left.Binary.Tail.Compare.Value.ParamIndex; idx != 1 {
+               t.Errorf("scalar ParamIndex = %d, want 1", idx)
+       }
+       if idx := sel.Where.Expr.Left.Right[0].Right.In.Values[0].ParamIndex; 
idx != 2 {
+               t.Errorf("IN element ParamIndex = %d, want 2", idx)
+       }
+       if idx := sel.Limit.ParamIndex; idx != 3 {
+               t.Errorf("LIMIT ParamIndex = %d, want 3", idx)
+       }
+       if idx := sel.Offset.ParamIndex; idx != 4 {
+               t.Errorf("OFFSET ParamIndex = %d, want 4", idx)
+       }
+}
+
+func TestPrepareNumbersShowTopCount(t *testing.T) {
+       ps, err := Prepare("SHOW TOP ? FROM MEASURE m IN default TIME > ? WHERE 
service = ?")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       wantSpecs := []placeholderSpec{
+               {kind: phCount, maxCount: math.MaxInt32},
+               {kind: phTime},
+               {kind: phScalar},
+       }
+       if len(ps.specs) != len(wantSpecs) {
+               t.Fatalf("specs len = %d, want %d", len(ps.specs), 
len(wantSpecs))
+       }
+       for i, want := range wantSpecs {
+               if ps.specs[i] != want {
+                       t.Errorf("specs[%d] = %+v, want %+v", i, ps.specs[i], 
want)
+               }
+       }
+       if idx := ps.template.TopN.NParamIndex; idx != 0 {
+               t.Errorf("SHOW TOP NParamIndex = %d, want 0", idx)
+       }
+}
+
+func TestPrepareLiteralQueryHasNoPlaceholders(t *testing.T) {
+       ps, err := Prepare("SELECT * FROM STREAM sw IN default WHERE service_id 
= 'webapp'")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       if got := len(ps.specs); got != 0 {
+               t.Fatalf("placeholder count = %d, want 0", got)
+       }
+}
+
+func TestPrepareCoversSamePlaceholdersAsBinder(t *testing.T) {
+       // Prepare and binder.collect are parallel traversals; a query 
exercising
+       // every placeholder position must yield the same count from both, or 
one
+       // walk has drifted from the other.
+       query := "SELECT TOP ? f FROM MEASURE m IN default TIME BETWEEN ? AND ? 
" +
+               "WHERE a = ? AND b IN (?) AND c MATCH(?) AND d HAVING (?) LIMIT 
? OFFSET ?"
+       ps, err := Prepare(query)
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       g, errs := ParseQuery(query)
+       if errs != nil {
+               t.Fatalf("ParseQuery: %v", errs)
+       }
+       if got, want := len(ps.specs), countUnboundParams(g); got != want {
+               t.Fatalf("Prepare counted %d placeholders, binder counted %d", 
got, want)
+       }
+}
+
+func TestBindResolvesEachKind(t *testing.T) {
+       ps, err := Prepare("SELECT * FROM STREAM sw IN default TIME > ? WHERE 
service_id = ? AND codes IN (?) LIMIT ?")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       bound, err := ps.Bind([]*modelv1.TagValue{tvStr("-30m"), 
tvStr("webapp"), tvStrArray("a", "b"), tvInt(10)})
+       if err != nil {
+               t.Fatalf("Bind: %v", err)
+       }
+       p := bound.values
+       if p[0].timeStr != "-30m" {
+               t.Errorf("time = %q, want -30m", p[0].timeStr)
+       }
+       if len(p[1].values) != 1 || *p[1].values[0].String != "webapp" {
+               t.Errorf("scalar = %+v, want [webapp]", p[1].values)
+       }
+       if len(p[2].values) != 2 || *p[2].values[0].String != "a" || 
*p[2].values[1].String != "b" {
+               t.Errorf("list = %+v, want [a b]", p[2].values)
+       }
+       if p[3].count != 10 {
+               t.Errorf("count = %d, want 10", p[3].count)
+       }
+}
+
+func TestBindTemplateStaysImmutable(t *testing.T) {
+       ps, err := Prepare("SELECT * FROM STREAM sw IN default WHERE service_id 
= ?")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       if _, err = ps.Bind([]*modelv1.TagValue{tvStr("first")}); err != nil {
+               t.Fatalf("Bind: %v", err)
+       }
+       node := 
ps.template.Select.Where.Expr.Left.Left.Binary.Tail.Compare.Value
+       if !node.Param || node.String != nil {
+               t.Fatalf("template mutated by Bind: Param=%v String=%v", 
node.Param, node.String)
+       }
+       // A second bind with a different value must succeed on the same 
template.
+       bound, err := ps.Bind([]*modelv1.TagValue{tvStr("second")})
+       if err != nil {
+               t.Fatalf("second Bind: %v", err)
+       }
+       if *bound.values[0].values[0].String != "second" {
+               t.Errorf("second bind value = %q, want second", 
*bound.values[0].values[0].String)
+       }
+}
+
+func TestBindErrors(t *testing.T) {
+       ps, err := Prepare("SELECT * FROM STREAM sw IN default WHERE service_id 
= ?")
+       if err != nil {
+               t.Fatalf("Prepare: %v", err)
+       }
+       if _, err = ps.Bind(nil); err == nil || !strings.Contains(err.Error(), 
"count mismatch") {
+               t.Errorf("count mismatch: got %v", err)
+       }
+       if _, err = ps.Bind([]*modelv1.TagValue{tvStrArray("a", "b")}); err == 
nil || !strings.Contains(err.Error(), "parameter #1") {
+               t.Errorf("array in scalar: got %v", err)
+       }
+       if _, err = ps.Bind([]*modelv1.TagValue{{}}); err == nil || 
!strings.Contains(err.Error(), "has no value") {
+               t.Errorf("nil value: got %v", err)
+       }
+}
+
+// BenchmarkBinding contrasts re-parsing per request with parse-once/bind-many.
+func BenchmarkBinding(b *testing.B) {
+       const query = "SELECT * FROM STREAM sw IN default TIME > ? WHERE 
service_id = ? AND codes IN (?) LIMIT ?"
+       params := func() []*modelv1.TagValue {
+               return []*modelv1.TagValue{tvStr("-30m"), tvStr("webapp"), 
tvStrArray("a", "b"), tvInt(10)}
+       }
+       b.Run("ParseAndBindEachRequest", func(b *testing.B) {
+               for i := 0; i < b.N; i++ {
+                       g, errs := ParseQuery(query)
+                       if errs != nil {
+                               b.Fatal(errs)
+                       }
+                       if err := BindParams(g, params()); err != nil {
+                               b.Fatal(err)
+                       }
+               }
+       })
+       b.Run("PrepareOnceBindMany", func(b *testing.B) {
+               ps, err := Prepare(query)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               b.ResetTimer()
+               for i := 0; i < b.N; i++ {
+                       if _, err := ps.Bind(params()); err != nil {
+                               b.Fatal(err)
+                       }
+               }
+       })
+}
diff --git a/pkg/bydbql/transformer.go b/pkg/bydbql/transformer.go
index d1460b85e..d4a169081 100644
--- a/pkg/bydbql/transformer.go
+++ b/pkg/bydbql/transformer.go
@@ -99,18 +99,98 @@ func NewTransformer(registry metadata.Repo) *Transformer {
        }
 }
 
-// Transform transforms a Grammar into a native query request.
+// transformRun holds the per-request state of a single transformation: the
+// stateless Transformer plus the bound parameter overlay (nil for a literal or
+// in-place-bound grammar). The conversion methods hang off transformRun so the
+// shared Transformer stays safe for concurrent requests.
+type transformRun struct {
+       *Transformer
+       bound []resolvedParam
+}
+
+// resolveValue returns the effective literal value node for a scalar position:
+// the overlay's bound node for a placeholder, or the node itself for a literal
+// (also the in-place-bound path, where bound is nil and the node was mutated).
+func (r *transformRun) resolveValue(v *GrammarValue) *GrammarValue {
+       if v != nil && v.Param && r.bound != nil {
+               return r.bound[v.ParamIndex].values[0]
+       }
+       return v
+}
+
+// resolveValues expands a list of value nodes, splicing each placeholder's 
bound
+// values (one, or several for an array) in place of the placeholder node. A 
list
+// with no placeholder (a literal list, or a re-resolve of an already-expanded
+// one) is returned unchanged, so the bound path allocates only when it must.
+func (r *transformRun) resolveValues(values []*GrammarValue) []*GrammarValue {
+       if r.bound == nil {
+               return values
+       }
+       hasParam := false
+       for _, v := range values {
+               if v != nil && v.Param {
+                       hasParam = true
+                       break
+               }
+       }
+       if !hasParam {
+               return values
+       }
+       out := make([]*GrammarValue, 0, len(values))
+       for _, v := range values {
+               if v != nil && v.Param {
+                       out = append(out, r.bound[v.ParamIndex].values...)
+                       continue
+               }
+               out = append(out, v)
+       }
+       return out
+}
+
+// resolveTimeString returns a TIME value's effective string: the overlay's 
bound
+// string for a placeholder, or the node's own literal.
+func (r *transformRun) resolveTimeString(v *GrammarTimeValue) string {
+       if v != nil && v.Param && r.bound != nil {
+               return r.bound[v.ParamIndex].timeStr
+       }
+       return v.ToString()
+}
+
+// resolveCount returns a count position's effective value: the overlay's bound
+// int for a placeholder, or the node's own literal.
+func (r *transformRun) resolveCount(value int, param bool, paramIndex int) int 
{
+       if param && r.bound != nil {
+               return r.bound[paramIndex].count
+       }
+       return value
+}
+
+// Transform transforms a Grammar into a native query request. The grammar must
+// be free of unbound placeholders: either a literal query or one bound in 
place
+// by BindParams. Use TransformBound for a reusable prepared statement.
 func (t *Transformer) Transform(ctx context.Context, grammar *Grammar) 
(*TransformResult, error) {
+       return (&transformRun{Transformer: t}).transform(ctx, grammar)
+}
+
+// TransformBound transforms a bound prepared statement, reading parameter 
values
+// from the per-request overlay instead of the immutable template.
+func (t *Transformer) TransformBound(ctx context.Context, bq *BoundQuery) 
(*TransformResult, error) {
+       return (&transformRun{Transformer: t, bound: bq.values}).transform(ctx, 
bq.stmt.template)
+}
+
+func (r *transformRun) 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 {
+       // empty string or a zero count silently instead of failing loudly. The
+       // overlay path (r.bound != nil) carries the values; the in-place path 
sets
+       // paramsBound; a literal grammar has no placeholders to begin with.
+       if !grammar.paramsBound && r.bound == nil {
                if unbound := countUnboundParams(grammar); unbound > 0 {
-                       return nil, fmt.Errorf("query contains %d unbound 
placeholder(s); BindParams must be called before Transform", unbound)
+                       return nil, fmt.Errorf("query contains %d unbound 
placeholder(s); bind parameters before transforming", unbound)
                }
        }
        // Literal counts get the same wrap guard as bound parameters, so LIMIT 
-5
-       // fails loudly instead of wrapping when narrowed to uint32.
+       // fails loudly instead of wrapping when narrowed to uint32. Overlay 
counts
+       // were already range-checked at Bind time.
        if err := validateGrammarCounts(grammar); err != nil {
                return nil, err
        }
@@ -119,13 +199,13 @@ func (t *Transformer) Transform(ctx context.Context, 
grammar *Grammar) (*Transfo
                resourceType := grammar.Select.From.ResourceType
                switch strings.ToUpper(resourceType) {
                case "STREAM":
-                       return t.transformStreamQuery(ctx, grammar)
+                       return r.transformStreamQuery(ctx, grammar)
                case "MEASURE":
-                       return t.transformMeasureQuery(ctx, grammar)
+                       return r.transformMeasureQuery(ctx, grammar)
                case "TRACE":
-                       return t.transformTraceQuery(ctx, grammar)
+                       return r.transformTraceQuery(ctx, grammar)
                case "PROPERTY":
-                       return t.transformPropertyQuery(ctx, grammar)
+                       return r.transformPropertyQuery(ctx, grammar)
                default:
                        return nil, fmt.Errorf("unsupported resource type in 
select statement: %s", resourceType)
                }
@@ -133,14 +213,14 @@ func (t *Transformer) Transform(ctx context.Context, 
grammar *Grammar) (*Transfo
        if grammar.TopN != nil {
                resourceType := grammar.TopN.From.ResourceType
                if strings.EqualFold(resourceType, "MEASURE") {
-                       return t.transformTopNMeasureQuery(ctx, grammar)
+                       return r.transformTopNMeasureQuery(ctx, grammar)
                }
                return nil, fmt.Errorf("unsupported resource type in topn 
statement: %s", resourceType)
        }
        return nil, errors.New("grammar must contain either Select or TopN 
statement")
 }
 
-func (t *Transformer) transformStreamQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
+func (r *transformRun) transformStreamQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
        statement := grammar.Select
        if statement == nil {
                return nil, errors.New("stream query must be a select 
statement")
@@ -151,15 +231,15 @@ func (t *Transformer) transformStreamQuery(ctx 
context.Context, grammar *Grammar
        resourceName := statement.From.ResourceName
 
        // validate query
-       if err := t.validateGroupOrResourceName(groups, resourceName); err != 
nil {
+       if err := r.validateGroupOrResourceName(groups, resourceName); err != 
nil {
                return nil, err
        }
 
        // query schema for getting tags
-       projection, _, allTags, _, err := t.convertTagAndField(
+       projection, _, allTags, _, err := r.convertTagAndField(
                groups, resourceName,
                func(group, name string) ([]*databasev1.TagFamilySpec, 
[]*databasev1.FieldSpec, error) {
-                       stream, getErr := 
t.schemaRegistry.StreamRegistry().GetStream(ctx, &commonv1.Metadata{
+                       stream, getErr := 
r.schemaRegistry.StreamRegistry().GetStream(ctx, &commonv1.Metadata{
                                Name:  name,
                                Group: group,
                        })
@@ -173,26 +253,26 @@ func (t *Transformer) transformStreamQuery(ctx 
context.Context, grammar *Grammar
        }
 
        // convert time range
-       timeRange, err := t.convertTimeRange(time.Now(), statement.Time)
+       timeRange, err := r.convertTimeRange(time.Now(), statement.Time)
        if err != nil {
                return nil, fmt.Errorf("failed to convert time range: %w", err)
        }
 
        // convert order by
-       orderBy := t.convertSelectOrderBy(statement.OrderBy)
+       orderBy := r.convertSelectOrderBy(statement.OrderBy)
 
        // convert criteria
-       criteria, err := t.convertSelectCriteria(statement.Where, allTags)
+       criteria, err := r.convertSelectCriteria(statement.Where, allTags)
        if err != nil {
                return nil, fmt.Errorf("failed to convert criteria: %w", err)
        }
 
        var offset, limit uint32
        if statement.Offset != nil {
-               offset = uint32(statement.Offset.Value)
+               offset = uint32(r.resolveCount(statement.Offset.Value, 
statement.Offset.Param, statement.Offset.ParamIndex))
        }
        if statement.Limit != nil {
-               limit = uint32(statement.Limit.Value)
+               limit = uint32(r.resolveCount(statement.Limit.Value, 
statement.Limit.Param, statement.Limit.ParamIndex))
        }
 
        // extract stages
@@ -219,7 +299,7 @@ func (t *Transformer) transformStreamQuery(ctx 
context.Context, grammar *Grammar
        }, nil
 }
 
-func (t *Transformer) transformMeasureQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
+func (r *transformRun) transformMeasureQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
        statement := grammar.Select
        if statement == nil {
                return nil, errors.New("measure query must be a select 
statement")
@@ -230,15 +310,15 @@ func (t *Transformer) transformMeasureQuery(ctx 
context.Context, grammar *Gramma
        resourceName := statement.From.ResourceName
 
        // validate query
-       if err := t.validateGroupOrResourceName(groups, resourceName); err != 
nil {
+       if err := r.validateGroupOrResourceName(groups, resourceName); err != 
nil {
                return nil, err
        }
 
        // query schema for getting tags and fields
-       projection, fields, allTags, allFields, err := t.convertTagAndField(
+       projection, fields, allTags, allFields, err := r.convertTagAndField(
                groups, resourceName,
                func(group, name string) ([]*databasev1.TagFamilySpec, 
[]*databasev1.FieldSpec, error) {
-                       measure, getErr := 
t.schemaRegistry.MeasureRegistry().GetMeasure(ctx, &commonv1.Metadata{
+                       measure, getErr := 
r.schemaRegistry.MeasureRegistry().GetMeasure(ctx, &commonv1.Metadata{
                                Name:  name,
                                Group: group,
                        })
@@ -259,28 +339,28 @@ func (t *Transformer) transformMeasureQuery(ctx 
context.Context, grammar *Gramma
        }
 
        // convert time range
-       timeRange, err := t.convertTimeRange(time.Now(), statement.Time)
+       timeRange, err := r.convertTimeRange(time.Now(), statement.Time)
        if err != nil {
                return nil, fmt.Errorf("failed to convert time range: %w", err)
        }
 
        // convert order by
-       orderBy := t.convertSelectOrderBy(statement.OrderBy)
+       orderBy := r.convertSelectOrderBy(statement.OrderBy)
 
        // convert criteria
-       criteria, err := t.convertSelectCriteria(statement.Where, allTags)
+       criteria, err := r.convertSelectCriteria(statement.Where, allTags)
        if err != nil {
                return nil, fmt.Errorf("failed to convert criteria: %w", err)
        }
 
        // convert aggregation
-       agg, err := t.convertAggregation(statement.Projection, allFields)
+       agg, err := r.convertAggregation(statement.Projection, allFields)
        if err != nil {
                return nil, fmt.Errorf("failed to convert aggregation: %w", err)
        }
 
        // convert group by
-       groupBy, err := t.convertGroupBy(statement.GroupBy, projection, fields)
+       groupBy, err := r.convertGroupBy(statement.GroupBy, projection, fields)
        if err != nil {
                return nil, fmt.Errorf("failed to convert group by: %w", err)
        }
@@ -288,17 +368,17 @@ func (t *Transformer) transformMeasureQuery(ctx 
context.Context, grammar *Gramma
                return nil, errors.New("when aggregation and group by are both 
present, group by must include a field")
        }
 
-       top, err := t.convertTOP(statement.Projection, allFields)
+       top, err := r.convertTOP(statement.Projection, allFields)
        if err != nil {
                return nil, fmt.Errorf("failed to convert top: %w", err)
        }
 
        var offset, limit uint32
        if statement.Offset != nil {
-               offset = uint32(statement.Offset.Value)
+               offset = uint32(r.resolveCount(statement.Offset.Value, 
statement.Offset.Param, statement.Offset.ParamIndex))
        }
        if statement.Limit != nil {
-               limit = uint32(statement.Limit.Value)
+               limit = uint32(r.resolveCount(statement.Limit.Value, 
statement.Limit.Param, statement.Limit.ParamIndex))
        }
 
        // extract stages
@@ -329,7 +409,7 @@ func (t *Transformer) transformMeasureQuery(ctx 
context.Context, grammar *Gramma
        }, nil
 }
 
-func (t *Transformer) transformTraceQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
+func (r *transformRun) transformTraceQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
        statement := grammar.Select
        if statement == nil {
                return nil, errors.New("trace query must be a select statement")
@@ -340,11 +420,11 @@ func (t *Transformer) transformTraceQuery(ctx 
context.Context, grammar *Grammar)
        resourceName := statement.From.ResourceName
 
        // validate query
-       if err := t.validateGroupOrResourceName(groups, resourceName); err != 
nil {
+       if err := r.validateGroupOrResourceName(groups, resourceName); err != 
nil {
                return nil, err
        }
 
-       timeRange, err := t.convertTimeRange(time.Now(), statement.Time)
+       timeRange, err := r.convertTimeRange(time.Now(), statement.Time)
        if err != nil {
                return nil, fmt.Errorf("failed to convert time range: %w", err)
        }
@@ -352,7 +432,7 @@ func (t *Transformer) transformTraceQuery(ctx 
context.Context, grammar *Grammar)
        // query the trace schema for getting tags
        allTags := make(map[string]*tagSpecWithFamily)
        for _, g := range groups {
-               trace, getErr := t.schemaRegistry.TraceRegistry().GetTrace(ctx, 
&commonv1.Metadata{
+               trace, getErr := r.schemaRegistry.TraceRegistry().GetTrace(ctx, 
&commonv1.Metadata{
                        Name:  resourceName,
                        Group: g,
                })
@@ -391,20 +471,20 @@ func (t *Transformer) transformTraceQuery(ctx 
context.Context, grammar *Grammar)
        }
 
        // convert criteria
-       criteria, err := t.convertSelectCriteria(statement.Where, allTags)
+       criteria, err := r.convertSelectCriteria(statement.Where, allTags)
        if err != nil {
                return nil, fmt.Errorf("failed to convert criteria: %w", err)
        }
 
        // convert order by
-       orderBy := t.convertSelectOrderBy(statement.OrderBy)
+       orderBy := r.convertSelectOrderBy(statement.OrderBy)
 
        var offset, limit uint32
        if statement.Offset != nil {
-               offset = uint32(statement.Offset.Value)
+               offset = uint32(r.resolveCount(statement.Offset.Value, 
statement.Offset.Param, statement.Offset.ParamIndex))
        }
        if statement.Limit != nil {
-               limit = uint32(statement.Limit.Value)
+               limit = uint32(r.resolveCount(statement.Limit.Value, 
statement.Limit.Param, statement.Limit.ParamIndex))
        }
 
        // extract stages
@@ -431,7 +511,7 @@ func (t *Transformer) transformTraceQuery(ctx 
context.Context, grammar *Grammar)
        }, nil
 }
 
-func (t *Transformer) transformPropertyQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
+func (r *transformRun) transformPropertyQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
        statement := grammar.Select
        if statement == nil {
                return nil, errors.New("property query must be a select 
statement")
@@ -442,14 +522,14 @@ func (t *Transformer) transformPropertyQuery(ctx 
context.Context, grammar *Gramm
        resourceName := statement.From.ResourceName
 
        // validate query
-       if err := t.validateGroupOrResourceName(groups, resourceName); err != 
nil {
+       if err := r.validateGroupOrResourceName(groups, resourceName); err != 
nil {
                return nil, err
        }
 
        // get property schema to extract all tags
        allTags := make(map[string]*tagSpecWithFamily)
        for _, g := range groups {
-               property, getErr := 
t.schemaRegistry.PropertyRegistry().GetProperty(ctx, &commonv1.Metadata{
+               property, getErr := 
r.schemaRegistry.PropertyRegistry().GetProperty(ctx, &commonv1.Metadata{
                        Name:  resourceName,
                        Group: g,
                })
@@ -499,7 +579,7 @@ func (t *Transformer) transformPropertyQuery(ctx 
context.Context, grammar *Gramm
        var criteria *modelv1.Criteria
        if statement.Where != nil && statement.Where.Expr != nil {
                var extractErr error
-               ids, criteria, extractErr = 
t.extractIDsAndCriteria(statement.Where.Expr, allTags)
+               ids, criteria, extractErr = 
r.extractIDsAndCriteria(statement.Where.Expr, allTags)
                if extractErr != nil {
                        return nil, fmt.Errorf("failed to convert criteria: 
%w", extractErr)
                }
@@ -508,13 +588,13 @@ func (t *Transformer) transformPropertyQuery(ctx 
context.Context, grammar *Gramm
        // handle limit
        var limit uint32
        if statement.Limit != nil {
-               limit = uint32(statement.Limit.Value)
+               limit = uint32(r.resolveCount(statement.Limit.Value, 
statement.Limit.Param, statement.Limit.ParamIndex))
        }
 
        // handle ORDER BY
        var orderBy *propertyv1.QueryOrder
        if statement.OrderBy != nil {
-               modelQueryOrder := t.convertSelectOrderBy(statement.OrderBy)
+               modelQueryOrder := r.convertSelectOrderBy(statement.OrderBy)
                if modelQueryOrder != nil {
                        orderBy = &propertyv1.QueryOrder{
                                TagName: modelQueryOrder.IndexRuleName,
@@ -539,7 +619,7 @@ func (t *Transformer) transformPropertyQuery(ctx 
context.Context, grammar *Gramm
        }, nil
 }
 
-func (t *Transformer) transformTopNMeasureQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
+func (r *transformRun) transformTopNMeasureQuery(ctx context.Context, grammar 
*Grammar) (*TransformResult, error) {
        statement := grammar.TopN
        if statement == nil {
                return nil, errors.New("topn measure query must be a topn 
statement")
@@ -550,12 +630,12 @@ func (t *Transformer) transformTopNMeasureQuery(ctx 
context.Context, grammar *Gr
        resourceName := statement.From.ResourceName
 
        // validate query
-       if err := t.validateGroupOrResourceName(groups, resourceName); err != 
nil {
+       if err := r.validateGroupOrResourceName(groups, resourceName); err != 
nil {
                return nil, err
        }
 
        // convert time range
-       timeRange, err := t.convertTimeRange(time.Now(), statement.Time)
+       timeRange, err := r.convertTimeRange(time.Now(), statement.Time)
        if err != nil {
                return nil, fmt.Errorf("failed to convert time range: %w", err)
        }
@@ -563,20 +643,20 @@ func (t *Transformer) transformTopNMeasureQuery(ctx 
context.Context, grammar *Gr
        // convert agg
        var aggFunc modelv1.AggregationFunction
        if statement.AggregateBy != nil {
-               aggFunc, err = 
t.convertAggregationFunc(statement.AggregateBy.Function.Function)
+               aggFunc, err = 
r.convertAggregationFunc(statement.AggregateBy.Function.Function)
                if err != nil {
                        return nil, err
                }
        }
 
        // convert conditions
-       conditions, err := t.convertTopNAndConditions(ctx, statement.Where, 
groups, resourceName)
+       conditions, err := r.convertTopNAndConditions(ctx, statement.Where, 
groups, resourceName)
        if err != nil {
                return nil, fmt.Errorf("failed to convert criteria: %w", err)
        }
 
        // convert order by
-       orderBy := t.convertTopNOrderBy(statement.OrderBy)
+       orderBy := r.convertTopNOrderBy(statement.OrderBy)
        sort := modelv1.Sort_SORT_UNSPECIFIED
        if orderBy != nil {
                sort = orderBy.Sort
@@ -595,7 +675,7 @@ func (t *Transformer) transformTopNMeasureQuery(ctx 
context.Context, grammar *Gr
                        Groups:         groups,
                        Name:           resourceName,
                        TimeRange:      timeRange,
-                       TopN:           int32(statement.N),
+                       TopN:           int32(r.resolveCount(statement.N, 
statement.NParam, statement.NParamIndex)),
                        Agg:            aggFunc,
                        Conditions:     conditions,
                        FieldValueSort: sort,
@@ -605,22 +685,22 @@ func (t *Transformer) transformTopNMeasureQuery(ctx 
context.Context, grammar *Gr
        }, nil
 }
 
-func (t *Transformer) convertSelectCriteria(where *GrammarSelectWhereClause, 
allTags map[string]*tagSpecWithFamily) (*modelv1.Criteria, error) {
+func (r *transformRun) convertSelectCriteria(where *GrammarSelectWhereClause, 
allTags map[string]*tagSpecWithFamily) (*modelv1.Criteria, error) {
        if where == nil || where.Expr == nil {
                return nil, nil
        }
 
-       return t.convertOrExpr(where.Expr, allTags, nil)
+       return r.convertOrExpr(where.Expr, allTags, nil)
 }
 
-func (t *Transformer) convertTopNAndConditions(ctx context.Context, where 
*GrammarTopNWhereClause, groups []string, resourceName string) 
([]*modelv1.Condition, error) {
+func (r *transformRun) convertTopNAndConditions(ctx context.Context, where 
*GrammarTopNWhereClause, groups []string, resourceName string) 
([]*modelv1.Condition, error) {
        if where == nil || where.Expr == nil {
                return nil, nil
        }
 
        allTags := make(map[string]*tagSpecWithFamily)
        for _, g := range groups {
-               aggregation, getErr := 
t.schemaRegistry.TopNAggregationRegistry().GetTopNAggregation(ctx, 
&commonv1.Metadata{
+               aggregation, getErr := 
r.schemaRegistry.TopNAggregationRegistry().GetTopNAggregation(ctx, 
&commonv1.Metadata{
                        Name:  resourceName,
                        Group: g,
                })
@@ -628,7 +708,7 @@ func (t *Transformer) convertTopNAndConditions(ctx 
context.Context, where *Gramm
                        return nil, fmt.Errorf("failed to get topn aggregation 
%s/%s: %w", g, resourceName, getErr)
                }
                sourceMeasure := aggregation.SourceMeasure
-               measure, getErr := 
t.schemaRegistry.MeasureRegistry().GetMeasure(ctx, sourceMeasure)
+               measure, getErr := 
r.schemaRegistry.MeasureRegistry().GetMeasure(ctx, sourceMeasure)
                if getErr != nil {
                        return nil, fmt.Errorf("failed to get measure %s/%s: 
%w", sourceMeasure.Group, sourceMeasure.Name, getErr)
                }
@@ -644,7 +724,7 @@ func (t *Transformer) convertTopNAndConditions(ctx 
context.Context, where *Gramm
        }
 
        var conditions []*modelv1.Condition
-       _, err := t.convertAndExpr(where.Expr, allTags, func(c 
*modelv1.Condition) {
+       _, err := r.convertAndExpr(where.Expr, allTags, func(c 
*modelv1.Condition) {
                conditions = append(conditions, c)
        })
        if err != nil {
@@ -654,7 +734,7 @@ func (t *Transformer) convertTopNAndConditions(ctx 
context.Context, where *Gramm
        return conditions, nil
 }
 
-func (t *Transformer) convertGroupBy(g *GrammarGroupByClause, queryTags 
*modelv1.TagProjection, queryFields []string) (*measurev1.QueryRequest_GroupBy, 
error) {
+func (r *transformRun) convertGroupBy(g *GrammarGroupByClause, queryTags 
*modelv1.TagProjection, queryFields []string) (*measurev1.QueryRequest_GroupBy, 
error) {
        if g == nil {
                return nil, nil
        }
@@ -754,7 +834,7 @@ func (t *Transformer) convertGroupBy(g 
*GrammarGroupByClause, queryTags *modelv1
        return groupBy, nil
 }
 
-func (t *Transformer) convertAggregation(projection *GrammarProjection, 
allFields map[string]*databasev1.FieldSpec) 
(*measurev1.QueryRequest_Aggregation, error) {
+func (r *transformRun) convertAggregation(projection *GrammarProjection, 
allFields map[string]*databasev1.FieldSpec) 
(*measurev1.QueryRequest_Aggregation, error) {
        var columns []*GrammarColumn
        if projection != nil && len(projection.Columns) > 0 {
                columns = append(columns, projection.Columns...)
@@ -789,7 +869,7 @@ func (t *Transformer) convertAggregation(projection 
*GrammarProjection, allField
                return nil, fmt.Errorf("field %s not found in schema", 
aggColName)
        }
 
-       aggFunc, err := t.convertAggregationFunc(aggCol.Aggregate.Function)
+       aggFunc, err := r.convertAggregationFunc(aggCol.Aggregate.Function)
        if err != nil {
                return nil, err
        }
@@ -800,7 +880,7 @@ func (t *Transformer) convertAggregation(projection 
*GrammarProjection, allField
        }, nil
 }
 
-func (t *Transformer) convertAggregationFunc(f string) 
(modelv1.AggregationFunction, error) {
+func (r *transformRun) convertAggregationFunc(f string) 
(modelv1.AggregationFunction, error) {
        switch strings.ToUpper(f) {
        case "MEAN", "AVG":
                return modelv1.AggregationFunction_AGGREGATION_FUNCTION_MEAN, 
nil
@@ -817,12 +897,12 @@ func (t *Transformer) convertAggregationFunc(f string) 
(modelv1.AggregationFunct
        }
 }
 
-func (t *Transformer) convertOrExpr(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
+func (r *transformRun) convertOrExpr(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
        if expr == nil {
                return nil, nil
        }
 
-       leftCriteria, err := t.convertAndExpr(expr.Left, allTags, accept)
+       leftCriteria, err := r.convertAndExpr(expr.Left, allTags, accept)
        if err != nil {
                return nil, err
        }
@@ -833,7 +913,7 @@ func (t *Transformer) convertOrExpr(expr *GrammarOrExpr, 
allTags map[string]*tag
 
        // process all OR operations
        for _, orRight := range expr.Right {
-               rightCriteria, err := t.convertAndExpr(orRight.Right, allTags, 
accept)
+               rightCriteria, err := r.convertAndExpr(orRight.Right, allTags, 
accept)
                if err != nil {
                        return nil, err
                }
@@ -852,12 +932,12 @@ func (t *Transformer) convertOrExpr(expr *GrammarOrExpr, 
allTags map[string]*tag
        return leftCriteria, nil
 }
 
-func (t *Transformer) convertAndExpr(expr *GrammarAndExpr, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
+func (r *transformRun) convertAndExpr(expr *GrammarAndExpr, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
        if expr == nil {
                return nil, nil
        }
 
-       leftCriteria, err := t.convertPredicate(expr.Left, allTags, accept)
+       leftCriteria, err := r.convertPredicate(expr.Left, allTags, accept)
        if err != nil {
                return nil, err
        }
@@ -868,7 +948,7 @@ func (t *Transformer) convertAndExpr(expr *GrammarAndExpr, 
allTags map[string]*t
 
        // process all AND operations
        for _, andRight := range expr.Right {
-               rightCriteria, err := t.convertPredicate(andRight.Right, 
allTags, accept)
+               rightCriteria, err := r.convertPredicate(andRight.Right, 
allTags, accept)
                if err != nil {
                        return nil, err
                }
@@ -887,31 +967,31 @@ func (t *Transformer) convertAndExpr(expr 
*GrammarAndExpr, allTags map[string]*t
        return leftCriteria, nil
 }
 
-func (t *Transformer) convertPredicate(pred *GrammarPredicate, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
+func (r *transformRun) convertPredicate(pred *GrammarPredicate, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
        if pred == nil {
                return nil, nil
        }
 
        if pred.Paren != nil {
-               return t.convertOrExpr(pred.Paren, allTags, accept)
+               return r.convertOrExpr(pred.Paren, allTags, accept)
        }
 
        if pred.Binary != nil {
-               return t.convertBinaryPredicate(pred.Binary, allTags, accept)
+               return r.convertBinaryPredicate(pred.Binary, allTags, accept)
        }
 
        if pred.In != nil {
-               return t.convertInPredicate(pred.In, allTags, accept)
+               return r.convertInPredicate(pred.In, allTags, accept)
        }
 
        if pred.Having != nil {
-               return t.convertHavingPredicate(pred.Having, allTags, accept)
+               return r.convertHavingPredicate(pred.Having, allTags, accept)
        }
 
        return nil, errors.New("empty predicate")
 }
 
-func (t *Transformer) convertBinaryPredicate(
+func (r *transformRun) convertBinaryPredicate(
        pred *GrammarBinaryPredicate,
        allTags map[string]*tagSpecWithFamily,
        accept func(c *modelv1.Condition),
@@ -927,17 +1007,17 @@ func (t *Transformer) convertBinaryPredicate(
        }
 
        if pred.Tail.Match != nil {
-               return t.convertMatchPredicate(identifierName, pred.Tail.Match, 
accept)
+               return r.convertMatchPredicate(identifierName, pred.Tail.Match, 
accept)
        }
 
        if pred.Tail.Compare != nil {
-               return t.convertComparePredicate(identifierName, 
pred.Tail.Compare, tagSpec, accept)
+               return r.convertComparePredicate(identifierName, 
pred.Tail.Compare, tagSpec, accept)
        }
 
        return nil, errors.New("empty binary predicate tail")
 }
 
-func (t *Transformer) convertMatchPredicate(identifierName string, match 
*GrammarMatchTail, accept func(c *modelv1.Condition)) (*modelv1.Criteria, 
error) {
+func (r *transformRun) convertMatchPredicate(identifierName string, match 
*GrammarMatchTail, accept func(c *modelv1.Condition)) (*modelv1.Criteria, 
error) {
        if match.Values == nil {
                return nil, fmt.Errorf("MATCH operator requires values")
        }
@@ -948,6 +1028,9 @@ func (t *Transformer) convertMatchPredicate(identifierName 
string, match *Gramma
        } else if match.Values.Array != nil {
                values = match.Values.Array
        }
+       // Expand any placeholder into its bound value(s); a single placeholder
+       // bound to an array becomes multiple values, matching the literal form.
+       values = r.resolveValues(values)
 
        if len(values) == 0 {
                return nil, fmt.Errorf("MATCH requires at least one value")
@@ -970,14 +1053,14 @@ func (t *Transformer) 
convertMatchPredicate(identifierName string, match *Gramma
                // single value: set as Str
                cond.Value = &modelv1.TagValue{
                        Value: &modelv1.TagValue_Str{
-                               Str: &modelv1.Str{Value: 
t.grammarValueToString(values[0])},
+                               Str: &modelv1.Str{Value: 
r.grammarValueToString(values[0])},
                        },
                }
        } else {
                // multiple values: set as string array
                strArr := make([]string, len(values))
                for i, val := range values {
-                       strArr[i] = t.grammarValueToString(val)
+                       strArr[i] = r.grammarValueToString(val)
                }
                cond.Value = &modelv1.TagValue{
                        Value: &modelv1.TagValue_StrArray{
@@ -1016,7 +1099,7 @@ func (t *Transformer) 
convertMatchPredicate(identifierName string, match *Gramma
        }, nil
 }
 
-func (t *Transformer) convertComparePredicate(
+func (r *transformRun) convertComparePredicate(
        identifierName string,
        compare *GrammarCompareTail,
        tagSpec *tagSpecWithFamily,
@@ -1024,10 +1107,10 @@ func (t *Transformer) convertComparePredicate(
 ) (*modelv1.Criteria, error) {
        cond := &modelv1.Condition{
                Name: identifierName,
-               Op:   t.convertCompareOp(compare.Operator),
+               Op:   r.convertCompareOp(compare.Operator),
        }
 
-       if err := t.setGrammarConditionValue(cond, compare.Value, tagSpec); err 
!= nil {
+       if err := r.setGrammarConditionValue(cond, compare.Value, tagSpec); err 
!= nil {
                return nil, fmt.Errorf("failed to set condition for tag %s: 
%w", identifierName, err)
        }
 
@@ -1042,7 +1125,7 @@ func (t *Transformer) convertComparePredicate(
        }, nil
 }
 
-func (t *Transformer) convertInPredicate(pred *GrammarInPredicate, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
+func (r *transformRun) convertInPredicate(pred *GrammarInPredicate, allTags 
map[string]*tagSpecWithFamily, accept func(c *modelv1.Condition)) 
(*modelv1.Criteria, error) {
        identifierName, nameErr := pred.Identifier.ToString(false)
        if nameErr != nil {
                return nil, fmt.Errorf("failed to parse identifier: %w", 
nameErr)
@@ -1063,7 +1146,7 @@ func (t *Transformer) convertInPredicate(pred 
*GrammarInPredicate, allTags map[s
                Op:   op,
        }
 
-       if err := t.setGrammarMultiValueCondition(cond, pred.Values, tagSpec); 
err != nil {
+       if err := r.setGrammarMultiValueCondition(cond, pred.Values, tagSpec); 
err != nil {
                return nil, fmt.Errorf("failed to set condition for tag %s: 
%w", identifierName, err)
        }
 
@@ -1078,7 +1161,7 @@ func (t *Transformer) convertInPredicate(pred 
*GrammarInPredicate, allTags map[s
        }, nil
 }
 
-func (t *Transformer) convertHavingPredicate(
+func (r *transformRun) convertHavingPredicate(
        pred *GrammarHavingPredicate,
        allTags map[string]*tagSpecWithFamily,
        accept func(c *modelv1.Condition),
@@ -1103,11 +1186,21 @@ func (t *Transformer) convertHavingPredicate(
                Op:   op,
        }
 
+       // Preserve the literal single-vs-list form: a single-value HAVING stays
+       // single unless a bound array expands it to several values, while a
+       // parenthesized list stays a list regardless of count.
+       wasSingle := pred.Values.Single != nil
+       values := pred.Values.Array
+       if wasSingle {
+               values = []*GrammarValue{pred.Values.Single}
+       }
+       values = r.resolveValues(values)
+
        var err error
-       if pred.Values.Single != nil {
-               err = t.setGrammarConditionValue(cond, pred.Values.Single, 
tagSpec)
-       } else if pred.Values.Array != nil {
-               err = t.setGrammarMultiValueCondition(cond, pred.Values.Array, 
tagSpec)
+       if wasSingle && len(values) == 1 {
+               err = r.setGrammarConditionValue(cond, values[0], tagSpec)
+       } else {
+               err = r.setGrammarMultiValueCondition(cond, values, tagSpec)
        }
 
        if err != nil {
@@ -1125,7 +1218,8 @@ func (t *Transformer) convertHavingPredicate(
        }, nil
 }
 
-func (t *Transformer) setGrammarConditionValue(cond *modelv1.Condition, val 
*GrammarValue, tagSpec *tagSpecWithFamily) error {
+func (r *transformRun) setGrammarConditionValue(cond *modelv1.Condition, val 
*GrammarValue, tagSpec *tagSpecWithFamily) error {
+       val = r.resolveValue(val)
        if val.Null {
                cond.Value = &modelv1.TagValue{
                        Value: &modelv1.TagValue_Null{
@@ -1140,12 +1234,12 @@ func (t *Transformer) setGrammarConditionValue(cond 
*modelv1.Condition, val *Gra
                // Convert to Str
                cond.Value = &modelv1.TagValue{
                        Value: &modelv1.TagValue_Str{
-                               Str: &modelv1.Str{Value: 
t.grammarValueToString(val)},
+                               Str: &modelv1.Str{Value: 
r.grammarValueToString(val)},
                        },
                }
 
        case databasev1.TagType_TAG_TYPE_INT, 
databasev1.TagType_TAG_TYPE_INT_ARRAY:
-               intVal, err := t.grammarValueToInt64(val)
+               intVal, err := r.grammarValueToInt64(val)
                if err != nil {
                        return err
                }
@@ -1165,7 +1259,8 @@ func (t *Transformer) setGrammarConditionValue(cond 
*modelv1.Condition, val *Gra
        return nil
 }
 
-func (t *Transformer) setGrammarMultiValueCondition(cond *modelv1.Condition, 
values []*GrammarValue, tagSpec *tagSpecWithFamily) error {
+func (r *transformRun) setGrammarMultiValueCondition(cond *modelv1.Condition, 
values []*GrammarValue, tagSpec *tagSpecWithFamily) error {
+       values = r.resolveValues(values)
        switch tagSpec.tag.Type {
        case databasev1.TagType_TAG_TYPE_STRING, 
databasev1.TagType_TAG_TYPE_STRING_ARRAY:
                strArr := make([]string, len(values))
@@ -1173,7 +1268,7 @@ func (t *Transformer) setGrammarMultiValueCondition(cond 
*modelv1.Condition, val
                        if val.Null {
                                return fmt.Errorf("NULL is not allowed in array 
values")
                        }
-                       strArr[i] = t.grammarValueToString(val)
+                       strArr[i] = r.grammarValueToString(val)
                }
                cond.Value = &modelv1.TagValue{
                        Value: &modelv1.TagValue_StrArray{
@@ -1187,7 +1282,7 @@ func (t *Transformer) setGrammarMultiValueCondition(cond 
*modelv1.Condition, val
                        if val.Null {
                                return fmt.Errorf("NULL is not allowed in array 
values")
                        }
-                       intVal, err := t.grammarValueToInt64(val)
+                       intVal, err := r.grammarValueToInt64(val)
                        if err != nil {
                                return err
                        }
@@ -1206,25 +1301,25 @@ func (t *Transformer) 
setGrammarMultiValueCondition(cond *modelv1.Condition, val
        return nil
 }
 
-func (t *Transformer) convertTimeRange(now time.Time, timeClause 
*GrammarTimeClause) (*modelv1.TimeRange, error) {
+func (r *transformRun) convertTimeRange(now time.Time, timeClause 
*GrammarTimeClause) (*modelv1.TimeRange, error) {
        if timeClause == nil {
                return nil, nil
        }
        var begin, end time.Time
 
        if timeClause.Between != nil {
-               beginTS, err := t.parseTimestamp(now, 
timeClause.Between.Begin.ToString())
+               beginTS, err := r.parseTimestamp(now, 
r.resolveTimeString(timeClause.Between.Begin))
                if err != nil {
                        return nil, err
                }
-               endTS, err := t.parseTimestamp(now, 
timeClause.Between.End.ToString())
+               endTS, err := r.parseTimestamp(now, 
r.resolveTimeString(timeClause.Between.End))
                if err != nil {
                        return nil, err
                }
                begin = *beginTS
                end = *endTS
        } else if timeClause.Comparator != nil && timeClause.Value != nil {
-               timestamp, err := t.parseTimestamp(now, 
timeClause.Value.ToString())
+               timestamp, err := r.parseTimestamp(now, 
r.resolveTimeString(timeClause.Value))
                if err != nil {
                        return nil, err
                }
@@ -1255,7 +1350,7 @@ func (t *Transformer) convertTimeRange(now time.Time, 
timeClause *GrammarTimeCla
        }, nil
 }
 
-func (t *Transformer) parseTimestamp(now time.Time, timestamp string) 
(*time.Time, error) {
+func (r *transformRun) parseTimestamp(now time.Time, timestamp string) 
(*time.Time, error) {
        // Try parsing as absolute time first (RFC3339)
        if parsedTime, err := time.Parse(time.RFC3339, timestamp); err == nil {
                return &parsedTime, nil
@@ -1275,7 +1370,7 @@ func (t *Transformer) parseTimestamp(now time.Time, 
timestamp string) (*time.Tim
        return &resultTime, nil
 }
 
-func (t *Transformer) convertTagAndField(
+func (r *transformRun) convertTagAndField(
        groups []string,
        name string,
        query func(group, name string) ([]*databasev1.TagFamilySpec, 
[]*databasev1.FieldSpec, error),
@@ -1323,12 +1418,12 @@ func (t *Transformer) convertTagAndField(
 
        if includeAll {
                for _, spec := range allTags {
-                       if addErr := t.checkOrAddGrammarTagOrField(allTags, 
allFields, &targetTagFamilies, &targetFields, columnExists, spec.tag.Name, 
columnTypeTag, nil); addErr != nil {
+                       if addErr := r.checkOrAddGrammarTagOrField(allTags, 
allFields, &targetTagFamilies, &targetFields, columnExists, spec.tag.Name, 
columnTypeTag, nil); addErr != nil {
                                return nil, nil, nil, nil, addErr
                        }
                }
                for _, f := range allFields {
-                       if addErr := t.checkOrAddGrammarTagOrField(allTags, 
allFields, &targetTagFamilies, &targetFields, columnExists, f.Name, 
columnTypeField, nil); addErr != nil {
+                       if addErr := r.checkOrAddGrammarTagOrField(allTags, 
allFields, &targetTagFamilies, &targetFields, columnExists, f.Name, 
columnTypeField, nil); addErr != nil {
                                return nil, nil, nil, nil, addErr
                        }
                }
@@ -1346,7 +1441,7 @@ func (t *Transformer) convertTagAndField(
                if col.TypeSpec != nil {
                        colType = strings.ToUpper(*col.TypeSpec)
                }
-               if addErr := t.checkOrAddGrammarTagOrField(allTags, allFields, 
&targetTagFamilies, &targetFields, columnExists, colName, colType, 
col.Aggregate); addErr != nil {
+               if addErr := r.checkOrAddGrammarTagOrField(allTags, allFields, 
&targetTagFamilies, &targetFields, columnExists, colName, colType, 
col.Aggregate); addErr != nil {
                        return nil, nil, nil, nil, addErr
                }
        }
@@ -1358,7 +1453,7 @@ func (t *Transformer) convertTagAndField(
        return tagProjection, targetFields, allTags, allFields, nil
 }
 
-func (t *Transformer) findGrammarTagOrField(
+func (r *transformRun) findGrammarTagOrField(
        sourceTags map[string]*tagSpecWithFamily,
        sourceFields map[string]*databasev1.FieldSpec,
        targetName string,
@@ -1378,7 +1473,7 @@ type tagSpecWithFamily struct {
        family string
 }
 
-func (t *Transformer) checkOrAddGrammarTagOrField(
+func (r *transformRun) checkOrAddGrammarTagOrField(
        sourceTags map[string]*tagSpecWithFamily,
        sourceFields map[string]*databasev1.FieldSpec,
        targetTags *[]*modelv1.TagProjection_TagFamily,
@@ -1412,7 +1507,7 @@ func (t *Transformer) checkOrAddGrammarTagOrField(
                return nil
        }
 
-       tag, field := t.findGrammarTagOrField(sourceTags, sourceFields, 
colName, colType)
+       tag, field := r.findGrammarTagOrField(sourceTags, sourceFields, 
colName, colType)
        if tag == nil && field == nil {
                return fmt.Errorf("column %s not found in schema", colName)
        }
@@ -1472,7 +1567,7 @@ func (t *Transformer) checkOrAddGrammarTagOrField(
        return nil
 }
 
-func (t *Transformer) validateGroupOrResourceName(groups []string, 
resourceName string) error {
+func (r *transformRun) validateGroupOrResourceName(groups []string, 
resourceName string) error {
        if len(groups) == 0 {
                return errors.New("at least one group must be specified")
        }
@@ -1483,7 +1578,7 @@ func (t *Transformer) validateGroupOrResourceName(groups 
[]string, resourceName
        return nil
 }
 
-func (t *Transformer) convertSelectOrderBy(orderBy 
*GrammarSelectOrderByClause) *modelv1.QueryOrder {
+func (r *transformRun) convertSelectOrderBy(orderBy 
*GrammarSelectOrderByClause) *modelv1.QueryOrder {
        if orderBy == nil {
                return nil
        }
@@ -1513,7 +1608,7 @@ func (t *Transformer) convertSelectOrderBy(orderBy 
*GrammarSelectOrderByClause)
        }
 }
 
-func (t *Transformer) convertTopNOrderBy(orderBy *GrammarTopNOrderByClause) 
*modelv1.QueryOrder {
+func (r *transformRun) convertTopNOrderBy(orderBy *GrammarTopNOrderByClause) 
*modelv1.QueryOrder {
        if orderBy == nil {
                return nil
        }
@@ -1528,7 +1623,7 @@ func (t *Transformer) convertTopNOrderBy(orderBy 
*GrammarTopNOrderByClause) *mod
        }
 }
 
-func (t *Transformer) convertCompareOp(op string) modelv1.Condition_BinaryOp {
+func (r *transformRun) convertCompareOp(op string) modelv1.Condition_BinaryOp {
        switch op {
        case "=":
                return modelv1.Condition_BINARY_OP_EQ
@@ -1547,7 +1642,7 @@ func (t *Transformer) convertCompareOp(op string) 
modelv1.Condition_BinaryOp {
        }
 }
 
-func (t *Transformer) convertTOP(projection *GrammarProjection, fields 
map[string]*databasev1.FieldSpec) (*measurev1.QueryRequest_Top, error) {
+func (r *transformRun) convertTOP(projection *GrammarProjection, fields 
map[string]*databasev1.FieldSpec) (*measurev1.QueryRequest_Top, error) {
        if projection == nil || projection.TopN == nil {
                return nil, nil
        }
@@ -1570,13 +1665,14 @@ func (t *Transformer) convertTOP(projection 
*GrammarProjection, fields map[strin
        }
 
        return &measurev1.QueryRequest_Top{
-               Number:         int32(topn.N),
+               Number:         int32(r.resolveCount(topn.N, topn.NParam, 
topn.NParamIndex)),
                FieldName:      orderFieldName,
                FieldValueSort: sort,
        }, nil
 }
 
-func (t *Transformer) grammarValueToString(val *GrammarValue) string {
+func (r *transformRun) grammarValueToString(val *GrammarValue) string {
+       val = r.resolveValue(val)
        if val.String != nil {
                return *val.String
        }
@@ -1586,7 +1682,8 @@ func (t *Transformer) grammarValueToString(val 
*GrammarValue) string {
        return ""
 }
 
-func (t *Transformer) grammarValueToInt64(val *GrammarValue) (int64, error) {
+func (r *transformRun) grammarValueToInt64(val *GrammarValue) (int64, error) {
+       val = r.resolveValue(val)
        if val.Integer != nil {
                return *val.Integer, nil
        }
@@ -1602,13 +1699,13 @@ func (t *Transformer) grammarValueToInt64(val 
*GrammarValue) (int64, error) {
 
 // extractIDsAndCriteria separates ID conditions from other conditions in 
property queries.
 // ID conditions are extracted into a string array, while other conditions are 
converted to criteria.
-func (t *Transformer) extractIDsAndCriteria(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
+func (r *transformRun) extractIDsAndCriteria(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
        if expr == nil {
                return nil, nil, nil
        }
 
        // Process the expression tree and collect IDs and criteria
-       ids, criteria, err := t.extractIDsFromOrExpr(expr, allTags)
+       ids, criteria, err := r.extractIDsFromOrExpr(expr, allTags)
        if err != nil {
                return nil, nil, err
        }
@@ -1617,8 +1714,8 @@ func (t *Transformer) extractIDsAndCriteria(expr 
*GrammarOrExpr, allTags map[str
 }
 
 // extractIDsFromOrExpr processes OR expressions for ID extraction.
-func (t *Transformer) extractIDsFromOrExpr(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
-       leftIDs, leftCriteria, err := t.extractIDsFromAndExpr(expr.Left, 
allTags)
+func (r *transformRun) extractIDsFromOrExpr(expr *GrammarOrExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
+       leftIDs, leftCriteria, err := r.extractIDsFromAndExpr(expr.Left, 
allTags)
        if err != nil {
                return nil, nil, err
        }
@@ -1633,7 +1730,7 @@ func (t *Transformer) extractIDsFromOrExpr(expr 
*GrammarOrExpr, allTags map[stri
        currentCriteria := leftCriteria
 
        for _, orRight := range expr.Right {
-               rightIDs, rightCriteria, err := 
t.extractIDsFromAndExpr(orRight.Right, allTags)
+               rightIDs, rightCriteria, err := 
r.extractIDsFromAndExpr(orRight.Right, allTags)
                if err != nil {
                        return nil, nil, err
                }
@@ -1658,8 +1755,8 @@ func (t *Transformer) extractIDsFromOrExpr(expr 
*GrammarOrExpr, allTags map[stri
 }
 
 // extractIDsFromAndExpr processes AND expressions for ID extraction.
-func (t *Transformer) extractIDsFromAndExpr(expr *GrammarAndExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
-       leftIDs, leftCriteria, err := t.extractIDsFromPredicate(expr.Left, 
allTags)
+func (r *transformRun) extractIDsFromAndExpr(expr *GrammarAndExpr, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
+       leftIDs, leftCriteria, err := r.extractIDsFromPredicate(expr.Left, 
allTags)
        if err != nil {
                return nil, nil, err
        }
@@ -1674,7 +1771,7 @@ func (t *Transformer) extractIDsFromAndExpr(expr 
*GrammarAndExpr, allTags map[st
        currentCriteria := leftCriteria
 
        for _, andRight := range expr.Right {
-               rightIDs, rightCriteria, err := 
t.extractIDsFromPredicate(andRight.Right, allTags)
+               rightIDs, rightCriteria, err := 
r.extractIDsFromPredicate(andRight.Right, allTags)
                if err != nil {
                        return nil, nil, err
                }
@@ -1699,9 +1796,9 @@ func (t *Transformer) extractIDsFromAndExpr(expr 
*GrammarAndExpr, allTags map[st
 }
 
 // extractIDsFromPredicate processes predicates for ID extraction.
-func (t *Transformer) extractIDsFromPredicate(pred *GrammarPredicate, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
+func (r *transformRun) extractIDsFromPredicate(pred *GrammarPredicate, allTags 
map[string]*tagSpecWithFamily) ([]string, *modelv1.Criteria, error) {
        if pred.Paren != nil {
-               return t.extractIDsFromOrExpr(pred.Paren, allTags)
+               return r.extractIDsFromOrExpr(pred.Paren, allTags)
        }
 
        if pred.Binary != nil {
@@ -1714,17 +1811,17 @@ func (t *Transformer) extractIDsFromPredicate(pred 
*GrammarPredicate, allTags ma
                if strings.EqualFold(identifierName, "ID") {
                        if pred.Binary.Tail.Compare != nil && 
pred.Binary.Tail.Compare.Operator == "=" {
                                // ID = 'value'
-                               if pred.Binary.Tail.Compare.Value.Null {
+                               if 
r.resolveValue(pred.Binary.Tail.Compare.Value).Null {
                                        return nil, nil, fmt.Errorf("ID cannot 
be NULL")
                                }
-                               idValue := 
t.grammarValueToString(pred.Binary.Tail.Compare.Value)
+                               idValue := 
r.grammarValueToString(pred.Binary.Tail.Compare.Value)
                                return []string{idValue}, nil, nil
                        }
                        return nil, nil, fmt.Errorf("unsupported operator for 
ID condition (only = is supported)")
                }
 
                // Not an ID condition, convert to criteria
-               criteria, err := t.convertBinaryPredicate(pred.Binary, allTags, 
nil)
+               criteria, err := r.convertBinaryPredicate(pred.Binary, allTags, 
nil)
                return nil, criteria, err
        }
 
@@ -1740,27 +1837,28 @@ func (t *Transformer) extractIDsFromPredicate(pred 
*GrammarPredicate, allTags ma
                                return nil, nil, fmt.Errorf("NOT IN is not 
supported for ID condition")
                        }
                        // ID IN ('value1', 'value2', ...)
-                       if len(pred.In.Values) == 0 {
+                       values := r.resolveValues(pred.In.Values)
+                       if len(values) == 0 {
                                return nil, nil, fmt.Errorf("ID IN requires at 
least one value")
                        }
-                       ids := make([]string, 0, len(pred.In.Values))
-                       for _, val := range pred.In.Values {
+                       ids := make([]string, 0, len(values))
+                       for _, val := range values {
                                if val.Null {
                                        return nil, nil, fmt.Errorf("ID cannot 
be NULL in IN clause")
                                }
-                               ids = append(ids, t.grammarValueToString(val))
+                               ids = append(ids, r.grammarValueToString(val))
                        }
                        return ids, nil, nil
                }
 
                // not an ID condition, convert to criteria
-               criteria, err := t.convertInPredicate(pred.In, allTags, nil)
+               criteria, err := r.convertInPredicate(pred.In, allTags, nil)
                return nil, criteria, err
        }
 
        if pred.Having != nil {
                // HAVING is not an ID condition
-               criteria, err := t.convertHavingPredicate(pred.Having, allTags, 
nil)
+               criteria, err := r.convertHavingPredicate(pred.Having, allTags, 
nil)
                return nil, criteria, err
        }
 

Reply via email to