This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 9fb8a8fb6 Introduce reusable `Prepared` QL binding (#1213)
9fb8a8fb6 is described below
commit 9fb8a8fb63336916690adcbcbd4600a67ed26814
Author: mrproliu <[email protected]>
AuthorDate: Mon Jul 13 08:49:33 2026 +0800
Introduce reusable `Prepared` QL binding (#1213)
* Introduce reusable `Prepared` QL binding
---
CHANGES.md | 1 +
banyand/liaison/grpc/bydbql.go | 119 +++++++++-
banyand/liaison/grpc/bydbql_cache.go | 142 +++++++++++
banyand/liaison/grpc/bydbql_cache_test.go | 199 ++++++++++++++++
banyand/liaison/grpc/bydbql_test.go | 50 +++-
banyand/liaison/grpc/metrics.go | 14 ++
banyand/liaison/grpc/server.go | 24 +-
banyand/liaison/grpc/topk.go | 132 +++++++++++
banyand/liaison/grpc/topk_test.go | 124 ++++++++++
docs/operation/configuration.md | 27 +++
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 | 289 +++++++++++++++++++++++
pkg/bydbql/prepared_internal_test.go | 335 ++++++++++++++++++++++++++
pkg/bydbql/transformer.go | 377 +++++++++++++++++++-----------
17 files changed, 1897 insertions(+), 208 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 41467ba54..1ae9f7a1b 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. The liaison caches prepared
statements on the gRPC query path (LRU bounded by entry count and bytes, on by
default; `--bydbql-prepared-cache-size`/`--bydbql-prepared-cache-max-bytes`,
`bydbql_prepared_cache_*` metrics) so repeated templates skip parsing. To
pinpoint un-cacheable and slow queries without high-cardinality labels, the
query access log tags each entry by [...]
### Bug Fixes
diff --git a/banyand/liaison/grpc/bydbql.go b/banyand/liaison/grpc/bydbql.go
index e22420736..97e714ac9 100644
--- a/banyand/liaison/grpc/bydbql.go
+++ b/banyand/liaison/grpc/bydbql.go
@@ -35,6 +35,7 @@ import (
"github.com/apache/skywalking-banyandb/pkg/accesslog"
"github.com/apache/skywalking-banyandb/pkg/bydbql"
"github.com/apache/skywalking-banyandb/pkg/logger"
+ "github.com/apache/skywalking-banyandb/pkg/run"
)
type bydbQLService struct {
@@ -44,10 +45,15 @@ type bydbQLService struct {
metrics *metrics
repo metadata.Repo
transformer *bydbql.Transformer
+ cache *preparedCache
streamSvc *streamService
measureSvc *measureService
traceSvc *traceService
propertyServer *propertyServer
+ // dumper tracks and periodically logs the top cache-miss and slow
queries; it is
+ // nil (its observe methods no-op) unless the periodic top-K log is
enabled.
+ dumper *topKDumper
+ slowThreshold time.Duration
}
func (b *bydbQLService) setLogger(log *logger.Logger) {
@@ -65,6 +71,10 @@ func (b *bydbQLService) activeQueryAccessLog(root string,
sampled bool) (err err
func (b *bydbQLService) Query(ctx context.Context, req *bydbqlv1.QueryRequest)
(resp *bydbqlv1.QueryResponse, err error) {
start := time.Now()
b.metrics.totalStarted.Inc(1, "", "bydbql", "query")
+ // cacheResult tags the access-log entry with the prepared-statement
cache
+ // outcome so operators can find un-cached queries: entries logged under
+ // "bydbql-miss" / "bydbql-bypass" are the ones that did not hit the
cache.
+ var cacheResult string
defer func() {
duration := time.Since(start)
b.metrics.totalFinished.Inc(1, "", "bydbql", "query")
@@ -73,23 +83,36 @@ func (b *bydbQLService) Query(ctx context.Context, req
*bydbqlv1.QueryRequest) (
}
b.metrics.totalLatency.Inc(duration.Seconds(), "", "bydbql",
"query")
+ if b.slowThreshold > 0 && duration > b.slowThreshold {
+ b.metrics.bydbqlSlowQueryTotal.Inc(1)
+ b.dumper.observeSlow(req.Query, duration)
+ }
+
if b.queryAccessLog != nil {
- if errAccessLog :=
b.queryAccessLog.WriteQuery("bydbql", start, duration, req, err); errAccessLog
!= nil {
+ service := "bydbql"
+ if cacheResult != "" {
+ service = "bydbql-" + cacheResult
+ }
+ if errAccessLog := b.queryAccessLog.WriteQuery(service,
start, duration, req, err); errAccessLog != nil {
b.l.Error().Err(errAccessLog).Msg("bydbql
access log error")
}
}
}()
- // parse query, bind parameters, and transform to native request
+ // prepare (parse once, cached), bind parameters, and transform to
native request
parseStart := time.Now()
- query, err := bydbql.ParseQuery(req.Query)
+ stmt, cacheResult, err := b.cache.getOrPrepare(req.Query)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to
parse query: %v", err)
}
- if err = bydbql.BindParams(query, req.Params); err != nil {
+ if cacheResult == "miss" {
+ b.dumper.observeMiss(req.Query)
+ }
+ bound, err := stmt.Bind(req.Params)
+ if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to
bind parameters: %v", err)
}
- result, err := b.transformer.Transform(ctx, query)
+ result, err := b.transformer.TransformBound(ctx, bound)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to transform
to native request: %v", err)
}
@@ -143,6 +166,92 @@ func (b *bydbQLService) Query(ctx context.Context, req
*bydbqlv1.QueryRequest) (
return resp, nil
}
+// topKDumper tracks the top cache-miss and slow queries and, on a supervised
+// goroutine, periodically logs the cumulative top-K. All methods are
nil-safe, so the
+// call sites need no guards when the top-K log is disabled (the dumper is
nil).
+type topKDumper struct {
+ miss *topK
+ slow *topK
+ l *logger.Logger
+ cancel context.CancelFunc
+}
+
+// newTopKDumper starts the trackers and the dump goroutine; a non-positive
interval
+// disables the feature and returns nil.
+func newTopKDumper(interval time.Duration, l *logger.Logger) *topKDumper {
+ if interval <= 0 {
+ return nil
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ d := &topKDumper{miss: newTopK(bydbqlTopKSize), slow:
newTopK(bydbqlTopKSize), l: l, cancel: cancel}
+ run.Go(ctx, "liaison.grpc.bydbql.topk-dump", l, func(ctx
context.Context) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ d.dump()
+ }
+ }
+ })
+ return d
+}
+
+func (d *topKDumper) observeMiss(query string) {
+ if d != nil {
+ d.miss.observe(query, 0)
+ }
+}
+
+func (d *topKDumper) observeSlow(query string, dur time.Duration) {
+ if d != nil {
+ d.slow.observe(query, dur)
+ }
+}
+
+func (d *topKDumper) close() {
+ if d != nil && d.cancel != nil {
+ d.cancel()
+ }
+}
+
+// minReparseMisses is the smallest cumulative miss count worth logging. Every
+// parameterized template misses exactly once on its cold-start lookup, so
count==1 is
+// benign; only count>=2 means the template was evicted and re-parsed
(thrashing).
+const minReparseMisses = 2
+
+func (d *topKDumper) dump() {
+ d.logTopK(d.miss.snapshot(), minReparseMisses, "top bydbql cache-miss
queries", func(s topKSlot) string {
+ return fmt.Sprintf("%q count=%d", s.key, s.count)
+ })
+ d.logTopK(d.slow.snapshotByLatency(), 1, "top bydbql slow queries",
func(s topKSlot) string {
+ return fmt.Sprintf("%q count=%d max_latency=%s", s.key,
s.count, s.maxDur)
+ })
+}
+
+// logTopK logs the entries with at least minCount occurrences, formatted by
line.
+func (d *topKDumper) logTopK(entries []topKSlot, minCount uint64, msg string,
line func(topKSlot) string) {
+ lines := formatTopK(entries, minCount, line)
+ if len(lines) == 0 {
+ return
+ }
+ d.l.Info().Strs("top", lines).Msg(msg)
+}
+
+// formatTopK renders the entries with at least minCount occurrences into log
lines.
+func formatTopK(entries []topKSlot, minCount uint64, line func(topKSlot)
string) []string {
+ lines := make([]string, 0, len(entries))
+ for _, s := range entries {
+ if s.count < minCount {
+ continue
+ }
+ lines = append(lines, line(s))
+ }
+ return lines
+}
+
func (b *bydbQLService) Close() error {
if b.queryAccessLog != nil {
if err := b.queryAccessLog.Close(); err != nil {
diff --git a/banyand/liaison/grpc/bydbql_cache.go
b/banyand/liaison/grpc/bydbql_cache.go
new file mode 100644
index 000000000..ba03a96f7
--- /dev/null
+++ b/banyand/liaison/grpc/bydbql_cache.go
@@ -0,0 +1,142 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "sync/atomic"
+
+ lru "github.com/hashicorp/golang-lru"
+
+ "github.com/apache/skywalking-banyandb/pkg/bydbql"
+)
+
+// cacheValue is a cached prepared statement plus its accounted byte cost
(query
+// text plus the estimated statement footprint), so evictions can adjust
curBytes.
+type cacheValue struct {
+ ps *bydbql.PreparedStatement
+ cost int
+}
+
+// preparedCache is a concurrency-safe LRU over parsed BydbQL prepared
statements,
+// keyed by the exact query text. A cached statement is immutable and safe to
Bind
+// concurrently, and the parse result depends only on the query text, so
entries
+// never need invalidation. The count bound is enforced by the underlying LRU;
the
+// byte bound (accounted key text plus estimated statement size) is enforced
on top
+// via curBytes and RemoveOldest. All shared state is either behind the LRU's
own
+// lock or atomic, so the cache needs no mutex of its own.
+type preparedCache struct {
+ metrics *metrics
+ lru *lru.Cache
+ maxBytes int
+ curBytes atomic.Int64
+ hits atomic.Uint64
+ misses atomic.Uint64
+}
+
+// newPreparedCache builds a cache holding up to size entries and maxBytes of
+// accounted size. A size <= 0 disables caching (lru is nil): getOrPrepare then
+// parses every request and bypasses the cache without recording hits or
misses.
+// A maxBytes <= 0 drops the byte bound, leaving only the count.
+func newPreparedCache(size, maxBytes int, m *metrics) *preparedCache {
+ c := &preparedCache{maxBytes: maxBytes, metrics: m}
+ if size > 0 {
+ // onEvict keeps curBytes in step with both the LRU's own
count-based
+ // evictions and our byte-based RemoveOldest calls; it runs
without the
+ // cache lock, so the atomic add is safe.
+ if l, err := lru.NewWithEvict(size, func(_, value interface{}) {
+ c.curBytes.Add(-int64(value.(*cacheValue).cost))
+ }); err == nil {
+ c.lru = l
+ }
+ }
+ return c
+}
+
+// getOrPrepare returns the prepared statement for query, parsing and caching
it on
+// a miss. It records the cache metrics internally; the returned result ("hit",
+// "miss", "bypass", or "" for a disabled cache) lets the caller tag the query
+// access log so un-cached queries can be found. Callers handle only the parse
error.
+func (c *preparedCache) getOrPrepare(query string) (*bydbql.PreparedStatement,
string, error) {
+ if c.lru != nil {
+ if v, ok := c.lru.Get(query); ok {
+ c.hits.Add(1)
+ c.emit("hit")
+ return v.(*cacheValue).ps, "hit", nil
+ }
+ }
+ ps, err := bydbql.Prepare(query)
+ if err != nil {
+ // A parse failure is not a cache event: it is never cached (so
it cannot
+ // pin memory) and is already counted by the RPC-level error
metric.
+ // Excluding it keeps hit_ratio from being depressed by
malformed queries.
+ return nil, "", err
+ }
+ if c.lru == nil {
+ return ps, "", nil // caching disabled entirely; nothing to
record
+ }
+ // Only parameterized queries are reusable templates worth caching. A
literal
+ // query bakes its values into the text, so every distinct value is a
unique key
+ // that would never hit and would only evict useful templates. It
bypasses the
+ // cache; a bypass is counted separately from hit/miss so the hit ratio
still
+ // reflects only cacheable queries.
+ if ps.NumPlaceholders() == 0 {
+ c.emit("bypass")
+ return ps, "bypass", nil
+ }
+ c.misses.Add(1)
+ c.store(query, ps)
+ c.emit("miss")
+ return ps, "miss", nil
+}
+
+// store caches ps under the byte bound, skipping a statement that alone
exceeds
+// maxBytes. ContainsOrAdd inserts atomically only when the key is absent, so
+// concurrent misses on the same query count its bytes exactly once. Call only
for
+// an enabled cache and a parameterized query.
+func (c *preparedCache) store(query string, ps *bydbql.PreparedStatement) {
+ cost := len(query) + ps.EstimatedSize()
+ if c.maxBytes > 0 && cost > c.maxBytes {
+ return
+ }
+ if found, _ := c.lru.ContainsOrAdd(query, &cacheValue{ps: ps, cost:
cost}); found {
+ return
+ }
+ c.curBytes.Add(int64(cost))
+ for c.maxBytes > 0 && c.curBytes.Load() > int64(c.maxBytes) &&
c.lru.Len() > 1 {
+ c.lru.RemoveOldest()
+ }
+}
+
+// emit publishes the cache counter (labeled by result: hit/miss/bypass) and
the
+// gauges from the atomic counters.
+func (c *preparedCache) emit(result string) {
+ if c.metrics == nil {
+ return
+ }
+ hits, misses := c.hits.Load(), c.misses.Load()
+ c.metrics.bydbqlPreparedCacheTotal.Inc(1, result)
+ if total := hits + misses; total > 0 {
+ c.metrics.bydbqlPreparedCacheHitRatio.Set(float64(hits) /
float64(total))
+ }
+ entries := 0
+ if c.lru != nil {
+ entries = c.lru.Len()
+ }
+ c.metrics.bydbqlPreparedCacheCount.Set(float64(entries))
+ c.metrics.bydbqlPreparedCacheBytes.Set(float64(c.curBytes.Load()))
+}
diff --git a/banyand/liaison/grpc/bydbql_cache_test.go
b/banyand/liaison/grpc/bydbql_cache_test.go
new file mode 100644
index 000000000..3c2dcf368
--- /dev/null
+++ b/banyand/liaison/grpc/bydbql_cache_test.go
@@ -0,0 +1,199 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// bydbqlQuery is a distinct, parameterized (cacheable) query per i.
+func bydbqlQuery(i int) string {
+ return fmt.Sprintf("SELECT * FROM STREAM sw IN default WHERE service_id
= 'v%d' AND duration > ?", i)
+}
+
+// bydbqlLiteralQuery is a distinct query per i with no placeholder (not
cacheable).
+func bydbqlLiteralQuery(i int) string {
+ return fmt.Sprintf("SELECT * FROM STREAM sw IN default WHERE service_id
= 'v%d'", i)
+}
+
+// probeEntryCost returns the accounted byte cost of one cached entry (query
text
+// plus estimated statement size), so byte-bound tests stay valid as the
estimate
+// or query template changes.
+func probeEntryCost(t *testing.T) int {
+ t.Helper()
+ c := newPreparedCache(10, 0, nil)
+ _, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ require.Positive(t, c.curBytes.Load())
+ return int(c.curBytes.Load())
+}
+
+func TestPreparedCacheHitReturnsSameInstance(t *testing.T) {
+ c := newPreparedCache(16, 0, nil)
+ first, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ second, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ assert.Same(t, first, second, "a cache hit must return the same
prepared statement")
+ assert.Equal(t, uint64(1), c.hits.Load())
+ assert.Equal(t, uint64(1), c.misses.Load())
+}
+
+func TestPreparedCacheEvictsLeastRecentlyUsedByCount(t *testing.T) {
+ c := newPreparedCache(2, 0, nil)
+ get := func(i int) {
+ _, _, err := c.getOrPrepare(bydbqlQuery(i))
+ require.NoError(t, err)
+ }
+ get(1)
+ get(2)
+ get(1) // touch q1 so q2 becomes the least-recently-used entry
+ get(3) // adding q3 must evict q2, not q1
+ assert.Equal(t, 2, c.lru.Len())
+
+ missBefore := c.misses.Load()
+ get(1)
+ assert.Equal(t, missBefore, c.misses.Load(), "recently used q1 should
still be cached")
+ get(2)
+ assert.Equal(t, missBefore+1, c.misses.Load(), "least-recently-used q2
should have been evicted")
+}
+
+func TestPreparedCacheEvictsByBytes(t *testing.T) {
+ // A cap of 2.5x one entry's cost holds at most two entries. Deriving
it from a
+ // probe keeps the test valid regardless of the estimated statement
size.
+ perEntry := probeEntryCost(t)
+ maxBytes := perEntry*2 + perEntry/2
+ c := newPreparedCache(100, maxBytes, nil)
+ for i := 0; i < 5; i++ {
+ _, _, err := c.getOrPrepare(bydbqlQuery(i))
+ require.NoError(t, err)
+ }
+ assert.LessOrEqual(t, int(c.curBytes.Load()), maxBytes, "byte cap must
bound total accounted size")
+ assert.Equal(t, 2, c.lru.Len(), "a 2.5x-cost byte cap must hold exactly
two entries")
+ assert.Equal(t, perEntry*c.lru.Len(), int(c.curBytes.Load()), "curBytes
must track the live entries")
+}
+
+func TestPreparedCacheSkipsOversizedQuery(t *testing.T) {
+ c := newPreparedCache(16, 8, nil) // every real query exceeds 8 bytes
+ stmt, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ require.NotNil(t, stmt, "an oversized query must still be prepared,
just not cached")
+ assert.Equal(t, 0, c.lru.Len())
+ assert.Zero(t, c.curBytes.Load())
+}
+
+func TestPreparedCacheDisabled(t *testing.T) {
+ c := newPreparedCache(0, 0, nil)
+ first, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ second, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ assert.NotSame(t, first, second, "a disabled cache must re-prepare
every request")
+ assert.Nil(t, c.lru, "a disabled cache holds no LRU")
+ // A disabled cache bypasses without recording cache hits or misses.
+ assert.Equal(t, uint64(0), c.hits.Load())
+ assert.Equal(t, uint64(0), c.misses.Load())
+}
+
+func TestPreparedCacheSkipsLiteralQuery(t *testing.T) {
+ c := newPreparedCache(16, 1<<20, nil)
+ first, _, err := c.getOrPrepare(bydbqlLiteralQuery(0))
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ second, _, err := c.getOrPrepare(bydbqlLiteralQuery(0))
+ require.NoError(t, err)
+ // A placeholder-free query is not a reusable template: never cached,
re-parsed
+ // each time, and not counted so it cannot depress hit_ratio.
+ assert.NotSame(t, first, second, "a literal query must be re-prepared,
not cached")
+ assert.Equal(t, 0, c.lru.Len(), "a literal query must never be stored")
+ assert.Equal(t, uint64(0), c.hits.Load())
+ assert.Equal(t, uint64(0), c.misses.Load())
+}
+
+func TestPreparedCacheBypassNotCounted(t *testing.T) {
+ // A literal query is counted as a bypass (emit("bypass")) but must not
move the
+ // hit/miss counters, so the hit ratio stays meaningful.
+ c := newPreparedCache(16, 1<<20, newBypassMetrics())
+ for i := 0; i < 5; i++ {
+ _, _, err := c.getOrPrepare(bydbqlLiteralQuery(i))
+ require.NoError(t, err)
+ }
+ assert.Equal(t, 0, c.lru.Len(), "literal queries are never cached")
+ assert.Equal(t, uint64(0), c.hits.Load())
+ assert.Equal(t, uint64(0), c.misses.Load(), "a bypass must not count as
a hit or miss")
+}
+
+func TestPreparedCacheParseErrorNotCached(t *testing.T) {
+ c := newPreparedCache(16, 0, nil)
+ _, _, err := c.getOrPrepare("NOT A QUERY")
+ require.Error(t, err)
+ assert.Equal(t, 0, c.lru.Len(), "a parse failure must not be cached")
+ // A parse failure is not a cache event, so it must not move hit/miss
counters.
+ assert.Equal(t, uint64(0), c.hits.Load())
+ assert.Equal(t, uint64(0), c.misses.Load())
+}
+
+func TestPreparedCacheEmitsMetrics(t *testing.T) {
+ // A non-nil metrics wiring exercises emit() end to end (counter label,
hit-ratio
+ // math, and the count/bytes gauges) on both the miss and hit paths.
+ c := newPreparedCache(4, 1<<20, newBypassMetrics())
+ _, _, err := c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ _, _, err = c.getOrPrepare(bydbqlQuery(0))
+ require.NoError(t, err)
+ assert.Equal(t, uint64(1), c.hits.Load())
+ assert.Equal(t, uint64(1), c.misses.Load())
+}
+
+func TestPreparedCacheConcurrentReuse(t *testing.T) {
+ // size 8 with 64 distinct keys and a byte cap of ~4 entries forces
both count-
+ // and byte-driven eviction to run concurrently, so -race covers the
eviction
+ // path that mutates curBytes.
+ const (
+ workers = 32
+ iters = 50
+ keys = 64
+ size = 8
+ )
+ maxBytes := probeEntryCost(t) * 4
+ c := newPreparedCache(size, maxBytes, nil)
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ for j := 0; j < iters; j++ {
+ stmt, _, err :=
c.getOrPrepare(bydbqlQuery((n*iters + j) % keys))
+ assert.NoError(t, err)
+ assert.NotNil(t, stmt)
+ }
+ }(i)
+ }
+ wg.Wait()
+ assert.LessOrEqual(t, c.lru.Len(), size)
+ assert.LessOrEqual(t, int(c.curBytes.Load()), maxBytes, "byte cap must
hold under concurrent eviction")
+ assert.GreaterOrEqual(t, c.curBytes.Load(), int64(0), "curBytes must
never drift negative")
+ assert.Equal(t, uint64(workers*iters), c.hits.Load()+c.misses.Load())
+}
diff --git a/banyand/liaison/grpc/bydbql_test.go
b/banyand/liaison/grpc/bydbql_test.go
index 2d91de7f2..cac5bb4ea 100644
--- a/banyand/liaison/grpc/bydbql_test.go
+++ b/banyand/liaison/grpc/bydbql_test.go
@@ -20,6 +20,7 @@ package grpc
import (
"context"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -28,10 +29,12 @@ import (
bydbqlv1
"github.com/apache/skywalking-banyandb/api/proto/banyandb/bydbql/v1"
modelv1
"github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
)
func newTestBydbQLService() *bydbQLService {
- return &bydbQLService{metrics: newBypassMetrics()}
+ m := newBypassMetrics()
+ return &bydbQLService{metrics: m, cache: newPreparedCache(16, 1<<20, m)}
}
func bydbqlStrParam(v string) *modelv1.TagValue {
@@ -88,3 +91,48 @@ func
TestBydbQLQuery_ParamTypeMismatch_ReturnsInvalidArgument(t *testing.T) {
assert.Equal(t, codes.InvalidArgument, st.Code())
assert.Contains(t, st.Message(), "failed to bind parameters")
}
+
+// newTestDumper builds a topKDumper without starting the dump goroutine.
+func newTestDumper(l *logger.Logger) *topKDumper {
+ return &topKDumper{miss: newTopK(bydbqlTopKSize), slow:
newTopK(bydbqlTopKSize), l: l}
+}
+
+func TestBydbQLQuery_TracksCacheMiss(t *testing.T) {
+ svc := newTestBydbQLService()
+ svc.dumper = newTestDumper(nil)
+ // A cacheable query misses on first sight; the miss is observed before
Bind runs
+ // (Bind then fails on missing params, but the miss was already
recorded).
+ _, _ = svc.Query(context.Background(), &bydbqlv1.QueryRequest{
+ Query: "SELECT * FROM STREAM sw IN default WHERE service_id =
?",
+ })
+ assert.NotEmpty(t, svc.dumper.miss.snapshot(), "a cacheable miss must
be tracked")
+}
+
+func TestBydbQLQuery_TracksSlowQuery(t *testing.T) {
+ svc := newTestBydbQLService()
+ svc.slowThreshold = time.Nanosecond // any query exceeds it
+ svc.dumper = newTestDumper(nil)
+ _, _ = svc.Query(context.Background(), &bydbqlv1.QueryRequest{
+ Query: "SELECT * FROM STREAM sw IN default WHERE service_id =
?",
+ })
+ assert.NotEmpty(t, svc.dumper.slow.snapshot(), "a query over the
threshold must be tracked")
+}
+
+func TestBydbQLDumpTopK(t *testing.T) {
+ d := newTestDumper(logger.GetLogger("test-bydbql"))
+ d.miss.observe("q-miss", 0)
+ d.miss.observe("q-miss", 0) // count>=2 so it survives the cold-start
filter
+ d.slow.observe("q-slow", time.Millisecond)
+ d.dump() // must not panic; the cumulative trackers keep their entries
+ assert.NotEmpty(t, d.miss.snapshot())
+ assert.NotEmpty(t, d.slow.snapshot())
+}
+
+func TestFormatTopKFiltersColdStartMisses(t *testing.T) {
+ entries := []topKSlot{
+ {key: "thrashing", count: 5},
+ {key: "cold-start", count: 1},
+ }
+ lines := formatTopK(entries, minReparseMisses, func(s topKSlot) string
{ return s.key })
+ assert.Equal(t, []string{"thrashing"}, lines, "count==1 cold-start
misses are filtered out")
+}
diff --git a/banyand/liaison/grpc/metrics.go b/banyand/liaison/grpc/metrics.go
index e97e35ce7..710b3e39e 100644
--- a/banyand/liaison/grpc/metrics.go
+++ b/banyand/liaison/grpc/metrics.go
@@ -63,6 +63,15 @@ type metrics struct {
schemaBarrierLaggards meter.Counter
schemaStatusNotApplied meter.Counter
schemaStatusExpired meter.Counter
+
+ // BydbQL prepared-statement cache. bydbqlPreparedCacheTotal counts
lookups by
+ // `result` ("hit"/"miss"); the gauges track the live hit ratio and the
cache's
+ // current entry count and approximate byte size.
+ bydbqlPreparedCacheTotal meter.Counter
+ bydbqlPreparedCacheHitRatio meter.Gauge
+ bydbqlPreparedCacheCount meter.Gauge
+ bydbqlPreparedCacheBytes meter.Gauge
+ bydbqlSlowQueryTotal meter.Counter
}
func newMetrics(factory observability.Factory) *metrics {
@@ -93,6 +102,11 @@ func newMetrics(factory observability.Factory) *metrics {
schemaBarrierLaggards:
factory.NewCounter("schema_barrier_laggard_nodes_total", "barrier", "role",
"node"),
schemaStatusNotApplied:
factory.NewCounter("schema_status_schema_not_applied_total", "rpc", "group",
"reason"),
schemaStatusExpired:
factory.NewCounter("schema_status_expired_schema_total", "rpc", "group"),
+ bydbqlPreparedCacheTotal:
factory.NewCounter("bydbql_prepared_cache_total", "result"),
+ bydbqlPreparedCacheHitRatio:
factory.NewGauge("bydbql_prepared_cache_hit_ratio"),
+ bydbqlPreparedCacheCount:
factory.NewGauge("bydbql_prepared_cache_count"),
+ bydbqlPreparedCacheBytes:
factory.NewGauge("bydbql_prepared_cache_bytes"),
+ bydbqlSlowQueryTotal:
factory.NewCounter("bydbql_slow_query_total"),
}
}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index fe731c87e..3c5a347b8 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -137,6 +137,10 @@ type server struct {
queryAccessLogRecorders []queryAccessLogRecorder
maxRecvMsgSize run.Bytes
grpcBufferMemoryRatio float64
+ bydbqlSlowThreshold time.Duration
+ bydbqlTopKLogInterval time.Duration
+ bydbqlCacheSize int
+ bydbqlCacheMaxBytes int
port uint32
tls bool
enableIngestionAccessLog bool
@@ -279,7 +283,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client,
broadcaster queue.Clie
)
}
s.accessLogRecorders = []accessLogRecorder{streamSVC, measureSVC,
traceSVC, s.propertyServer}
- s.queryAccessLogRecorders = []queryAccessLogRecorder{streamSVC,
measureSVC, traceSVC, s.propertyServer}
+ s.queryAccessLogRecorders = []queryAccessLogRecorder{streamSVC,
measureSVC, traceSVC, s.propertyServer, bydbQLSVC}
return s
}
@@ -364,6 +368,11 @@ func (s *server) PreRun(ctx context.Context) error {
s.measureSVC.metrics = metrics
s.traceSVC.metrics = metrics
s.bydbQLSVC.metrics = metrics
+ s.bydbQLSVC.cache = newPreparedCache(s.bydbqlCacheSize,
s.bydbqlCacheMaxBytes, metrics)
+ s.bydbQLSVC.slowThreshold = s.bydbqlSlowThreshold
+ // The dump goroutine lives for the server's lifetime and is stopped by
Close(),
+ // so it is rooted at a background context rather than PreRun's setup
context.
+ s.bydbQLSVC.dumper = newTopKDumper(s.bydbqlTopKLogInterval,
s.bydbQLSVC.l) //nolint:contextcheck
s.propertyServer.metrics = metrics
if s.barrierSVC != nil {
s.barrierSVC.metrics = metrics
@@ -449,6 +458,14 @@ func (s *server) FlagSet() *run.FlagSet {
fs.DurationVar(&s.traceSVC.maxWaitDuration,
"trace-metadata-cache-wait-duration", 0,
"the maximum duration to wait for metadata cache to load (for
testing purposes)")
fs.IntVar(&s.propertyServer.repairQueueCount,
"property-repair-queue-count", 128, "the number of queues for property repair")
+ fs.IntVar(&s.bydbqlCacheSize, "bydbql-prepared-cache-size", 2000,
+ "max number of prepared BydbQL statements cached on the query
path; 0 disables the cache")
+ fs.IntVar(&s.bydbqlCacheMaxBytes, "bydbql-prepared-cache-max-bytes",
10*1024*1024,
+ "max total estimated size (bytes) of the cached BydbQL prepared
statements; 0 removes the byte bound")
+ fs.DurationVar(&s.bydbqlSlowThreshold, "bydbql-slow-query-threshold",
time.Second,
+ "end-to-end latency above which a BydbQL query is counted as
slow; 0 disables slow-query tracking")
+ fs.DurationVar(&s.bydbqlTopKLogInterval, "bydbql-topk-log-interval",
5*time.Minute,
+ "how often to log the top BydbQL cache-miss and slow queries; 0
disables the top-K log")
s.grpcBufferMemoryRatio = 0.1
fs.Float64Var(&s.grpcBufferMemoryRatio, "grpc-buffer-memory-ratio", 0.1,
"ratio of memory limit to use for gRPC buffer size calculation
(0.0 < ratio <= 1.0)")
@@ -717,6 +734,11 @@ func (s *server) GracefulStop() {
stopped := make(chan struct{})
go func() {
s.ser.GracefulStop()
+ // The top-K dumper is independent of the access log, so stop
its goroutine
+ // unconditionally (nil-safe when the feature is disabled).
+ if s.bydbQLSVC != nil {
+ s.bydbQLSVC.dumper.close()
+ }
if s.enableIngestionAccessLog {
for _, alr := range s.accessLogRecorders {
_ = alr.Close()
diff --git a/banyand/liaison/grpc/topk.go b/banyand/liaison/grpc/topk.go
new file mode 100644
index 000000000..f6ef17773
--- /dev/null
+++ b/banyand/liaison/grpc/topk.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "math"
+ "sort"
+ "sync"
+ "time"
+)
+
+// bydbqlTopKSize is the number of hot entries each top-K tracker keeps. It is
sized
+// well above the expected number of distinct parameterized query templates so
the
+// Space-Saving eviction (and its count over-estimate) effectively never
triggers for
+// a normal workload, keeping the reported counts exact while still bounding
memory.
+const bydbqlTopKSize = 128
+
+// topKSlot is one tracked query and its accumulated statistics.
+type topKSlot struct {
+ key string
+ count uint64
+ maxDur time.Duration
+}
+
+// topK is a bounded approximate heavy-hitters tracker (Space-Saving): it
keeps at
+// most k entries and, when full, evicts the least-frequent one, letting the
new key
+// inherit that entry's count + 1 so a fresh key gets a fair chance instead of
being
+// evicted again immediately. With k modest (128) the min scan and the
snapshot sort are
+// cheap, and observing an existing key needs no reordering at all.
+type topK struct {
+ slots map[string]*topKSlot
+ k int
+ mu sync.Mutex
+}
+
+func newTopK(k int) *topK {
+ if k < 1 {
+ k = 1
+ }
+ return &topK{slots: make(map[string]*topKSlot, k), k: k}
+}
+
+// observe records one occurrence of key; dur is the query latency (0 when
latency is
+// not tracked, e.g. the cache-miss queue). maxDur keeps the largest latency
seen.
+func (t *topK) observe(key string, dur time.Duration) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if s, ok := t.slots[key]; ok {
+ s.count++
+ if dur > s.maxDur {
+ s.maxDur = dur
+ }
+ return
+ }
+ if len(t.slots) < t.k {
+ t.slots[key] = &topKSlot{key: key, count: 1, maxDur: dur}
+ return
+ }
+ // Full: evict the least-frequent entry and let the new key inherit its
count.
+ minKey := ""
+ minCount := uint64(math.MaxUint64)
+ for k, s := range t.slots {
+ if s.count < minCount {
+ minCount, minKey = s.count, k
+ }
+ }
+ delete(t.slots, minKey)
+ t.slots[key] = &topKSlot{key: key, count: minCount + 1, maxDur: dur}
+}
+
+// snapshot returns the tracked entries ranked by frequency: (count desc,
maxDur desc,
+// key asc). The tracker is cumulative, so each dump reflects the hottest
queries since
+// process start. The full tie-break makes the order deterministic across
dumps.
+func (t *topK) snapshot() []topKSlot {
+ out := t.copyOut()
+ sort.Slice(out, func(i, j int) bool { return lessByCount(out[i],
out[j]) })
+ return out
+}
+
+// snapshotByLatency returns the tracked entries ranked by peak latency:
(maxDur desc,
+// count desc, key asc), so a rarely-but-catastrophically slow query is not
buried under
+// frequently-mildly-slow ones.
+func (t *topK) snapshotByLatency() []topKSlot {
+ out := t.copyOut()
+ sort.Slice(out, func(i, j int) bool { return lessByLatency(out[i],
out[j]) })
+ return out
+}
+
+func (t *topK) copyOut() []topKSlot {
+ t.mu.Lock()
+ out := make([]topKSlot, 0, len(t.slots))
+ for _, s := range t.slots {
+ out = append(out, *s)
+ }
+ t.mu.Unlock()
+ return out
+}
+
+func lessByCount(a, b topKSlot) bool {
+ if a.count != b.count {
+ return a.count > b.count
+ }
+ if a.maxDur != b.maxDur {
+ return a.maxDur > b.maxDur
+ }
+ return a.key < b.key
+}
+
+func lessByLatency(a, b topKSlot) bool {
+ if a.maxDur != b.maxDur {
+ return a.maxDur > b.maxDur
+ }
+ if a.count != b.count {
+ return a.count > b.count
+ }
+ return a.key < b.key
+}
diff --git a/banyand/liaison/grpc/topk_test.go
b/banyand/liaison/grpc/topk_test.go
new file mode 100644
index 000000000..db864ee03
--- /dev/null
+++ b/banyand/liaison/grpc/topk_test.go
@@ -0,0 +1,124 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func topKByKey(slots []topKSlot) map[string]topKSlot {
+ m := make(map[string]topKSlot, len(slots))
+ for _, s := range slots {
+ m[s.key] = s
+ }
+ return m
+}
+
+func TestTopKCountsAndMaxDur(t *testing.T) {
+ tk := newTopK(4)
+ tk.observe("a", 5*time.Millisecond)
+ tk.observe("a", 8*time.Millisecond) // largest, observed in the middle
+ tk.observe("a", 3*time.Millisecond) // smaller and last: maxDur must
stay 8ms, not 3ms
+ tk.observe("b", time.Millisecond)
+
+ byKey := topKByKey(tk.snapshot())
+ assert.Equal(t, uint64(3), byKey["a"].count)
+ assert.Equal(t, 8*time.Millisecond, byKey["a"].maxDur, "maxDur keeps
the largest, not the last")
+ assert.Equal(t, uint64(1), byKey["b"].count)
+}
+
+func TestTopKEvictsMinAndInheritsCount(t *testing.T) {
+ tk := newTopK(2)
+ tk.observe("a", 0)
+ tk.observe("a", 0)
+ tk.observe("a", 0) // a.count = 3
+ tk.observe("b", 0) // b.count = 1; now full {a:3, b:1}
+
+ // A new key evicts the least-frequent (b) and inherits its count + 1,
so it is
+ // not immediately evicted again (Space-Saving) — this is what lets new
keys in.
+ tk.observe("c", 0)
+
+ byKey := topKByKey(tk.snapshot())
+ assert.Len(t, byKey, 2)
+ assert.Equal(t, uint64(3), byKey["a"].count, "the frequent key
survives")
+ assert.Equal(t, uint64(2), byKey["c"].count, "the new key inherits
evicted count + 1")
+ _, hasB := byKey["b"]
+ assert.False(t, hasB, "the least-frequent key was evicted")
+}
+
+func TestTopKSnapshotSortedAndCumulative(t *testing.T) {
+ tk := newTopK(4)
+ tk.observe("hi", 0)
+ tk.observe("hi", 0)
+ tk.observe("lo", 0)
+
+ snap := tk.snapshot()
+ assert.Equal(t, "hi", snap[0].key, "snapshot is sorted by count
descending")
+ assert.Equal(t, "lo", snap[1].key)
+ // The tracker is cumulative: a snapshot does not clear it.
+ assert.Len(t, tk.snapshot(), 2)
+}
+
+func TestTopKSnapshotByLatency(t *testing.T) {
+ tk := newTopK(bydbqlTopKSize)
+ tk.observe("frequent", time.Millisecond) // high count, low latency
+ tk.observe("frequent", time.Millisecond)
+ tk.observe("frequent", time.Millisecond)
+ tk.observe("rare-but-slow", time.Second) // count 1, catastrophic
latency
+
+ assert.Equal(t, "frequent", tk.snapshot()[0].key, "snapshot ranks by
count")
+ assert.Equal(t, "rare-but-slow", tk.snapshotByLatency()[0].key,
+ "snapshotByLatency surfaces the catastrophic outlier first")
+}
+
+func TestTopKDeterministicTieBreak(t *testing.T) {
+ // Equal counts and durations must order by key, not by map iteration.
+ a := newTopK(bydbqlTopKSize)
+ b := newTopK(bydbqlTopKSize)
+ for _, k := range []string{"c", "a", "b"} {
+ a.observe(k, 0)
+ }
+ for _, k := range []string{"b", "c", "a"} {
+ b.observe(k, 0)
+ }
+ assert.Equal(t, a.snapshot(), b.snapshot(), "same entries yield the
same order regardless of insertion")
+ snap := a.snapshot()
+ assert.Equal(t, []string{"a", "b", "c"}, []string{snap[0].key,
snap[1].key, snap[2].key})
+}
+
+func TestTopKConcurrentObserve(t *testing.T) {
+ tk := newTopK(bydbqlTopKSize)
+ const workers, iters = 16, 500
+ var wg sync.WaitGroup
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ for i := 0; i < iters; i++ {
+ tk.observe(fmt.Sprintf("q%d", (n*iters+i)%50),
time.Duration(i)*time.Microsecond)
+ }
+ }(w)
+ }
+ wg.Wait()
+ assert.LessOrEqual(t, len(tk.snapshot()), bydbqlTopKSize)
+}
diff --git a/docs/operation/configuration.md b/docs/operation/configuration.md
index 599b34c07..59bd5f636 100644
--- a/docs/operation/configuration.md
+++ b/docs/operation/configuration.md
@@ -89,6 +89,33 @@ The following flags are used to configure the timeout of
data sending from liais
- `--measure-write-timeout duration`: Measure write timeout (default: 1m).
- `--trace-write-timeout duration`: Trace write timeout (default: 1m).
+The following flags tune the BydbQL prepared-statement cache on the query
path. The cache stores parsed statements keyed by query text so repeated,
parameterized (`?`) queries skip re-parsing; literal queries without
placeholders are never cached. It is bounded by both an entry count and the
estimated in-memory size of the cached statements, evicting least-recently-used
entries when either bound is exceeded. Effectiveness is observable via the
`bydbql_prepared_cache_*` metrics (a `hit`/` [...]
+
+- `--bydbql-prepared-cache-size int`: Max number of prepared BydbQL statements
cached on the query path; `0` disables the cache (default: 2000).
+- `--bydbql-prepared-cache-max-bytes int`: Max total estimated size (in bytes)
of the cached prepared statements; `0` removes the byte bound (default:
10485760, i.e. 10MiB).
+
+These flags surface the queries behind cache misses and slow responses without
exposing high-cardinality query text as metric labels: Prometheus gets only two
counters (`bydbql_prepared_cache_total{result="miss"}` and
`bydbql_slow_query_total`), while the specific hot queries are logged.
+
+- `--bydbql-slow-query-threshold duration`: End-to-end latency above which a
BydbQL query is counted as slow (increments `bydbql_slow_query_total`) and
tracked in the slow-query top-K; `0` disables slow-query tracking (default:
`1s`).
+- `--bydbql-topk-log-interval duration`: How often to log the hottest
cache-miss and slow queries. Counts are cumulative since process start. The
cache-miss list only shows templates re-parsed at least twice (`count>=2`):
every template misses once on its cold-start lookup, so a `count==1` entry is
benign and is filtered out — only repeatedly evicted-and-re-parsed (thrashing)
templates are surfaced. The slow-query list is ranked by peak latency
(`max_latency`) so a rarely-but-catastrophi [...]
+
+#### Diagnosing an ineffective BydbQL cache
+
+A cache hit costs about 25 ns and 0 allocations, so the cache is effectively
free when it works. When it does not, every request re-parses the query — for a
wide trace/log query that is roughly **220 µs and ~147 KB / ~2,100 allocations
per request** (about four orders of magnitude more CPU and heap churn than a
hit). Watch these signals; several going abnormal together points at an
ineffective cache:
+
+- **`bydbql_prepared_cache_hit_ratio` drops toward 0** and the **`miss` rate
of `bydbql_prepared_cache_total{result="miss"}` climbs** — the primary
indicator.
+- **BydbQL query latency rises** (the liaison gRPC `query` latency series)
because the parse cost is added back to every request.
+- **Process CPU and Go GC pressure rise** — each miss allocates a fresh parse
tree, so the allocation rate, GC frequency, GC pause, and GC CPU fraction all
increase.
+- **`bydbql_prepared_cache_count` / `bydbql_prepared_cache_bytes` sit pinned
at the cap while the miss rate stays high** — the set of distinct query
templates exceeds the cache capacity and entries are constantly evicted.
+- **The `bypass` rate of `bydbql_prepared_cache_total{result="bypass"}` is
high** — many queries are literal (contain no `?`) and are never cached.
Bypasses are excluded from the hit ratio.
+
+To find the exact queries behind a high miss/bypass rate, enable the query
access log (`--enable-query-access-log`). Each BydbQL entry is tagged with its
cache outcome via the log's service field: `bydbql-hit`, `bydbql-miss`
(cacheable but not in the cache), and `bydbql-bypass` (literal, never cached).
Filtering the `bydbql-query-*` log for `bydbql-miss` / `bydbql-bypass` yields
exactly the queries that did not hit the cache, together with their full text.
+
+Remedies:
+
+- **High miss rate with the cache pinned at capacity** → the working set of
distinct query templates is larger than the cache. Raise
`--bydbql-prepared-cache-size` (and `--bydbql-prepared-cache-max-bytes` if the
byte bound binds first) until the hit ratio recovers.
+- **High bypass rate (near-empty cache)** → queries are not parameterized.
Literal queries (no `?`) are never cached, and inlining values (e.g. `id = 'a'
OR id = 'b' …`) explodes the template count. Use the `bydbql-bypass` access-log
entries to find them, then parameterize the varying values with `?`, and
express value sets as `IN (?)` bound to a `str_array`/`int_array` so a query is
one reusable template regardless of the number of values.
+
### TLS
If you want to enable TLS for the communication between the client and
liaison/standalone, you can use the following flags:
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..d917648a4
--- /dev/null
+++ b/pkg/bydbql/prepared.go
@@ -0,0 +1,289 @@
+// 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"
+ "reflect"
+
+ 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
+}
+
+// NumPlaceholders reports how many `?` placeholders the statement carries. A
zero
+// count means a literal query with no reusable parameters.
+func (ps *PreparedStatement) NumPlaceholders() int {
+ return len(ps.specs)
+}
+
+// EstimatedSize returns an approximate in-memory byte footprint of the
prepared
+// statement — its parsed grammar template and placeholder specs. It is a
+// heuristic for cache byte-accounting (a reflection walk of the object graph),
+// not an exact measurement, and is meant to be called off the hot path.
+func (ps *PreparedStatement) EstimatedSize() int {
+ return deepSize(reflect.ValueOf(ps))
+}
+
+// deepSize sums the bytes reachable from v: the inline size of each pointed-to
+// value plus the out-of-line bytes of strings and slice backing arrays. Inline
+// struct/array fields are already covered by their container's size, so only
+// their referenced memory is added. It is shaped for the grammar, an acyclic
tree
+// of concrete structs, pointers, slices, strings, and scalars.
+func deepSize(v reflect.Value) int {
+ switch v.Kind() {
+ case reflect.Pointer:
+ if v.IsNil() {
+ return 0
+ }
+ elem := v.Elem()
+ return int(elem.Type().Size()) + deepSize(elem)
+ case reflect.Struct:
+ total := 0
+ for i := 0; i < v.NumField(); i++ {
+ total += deepSize(v.Field(i))
+ }
+ return total
+ case reflect.Slice:
+ if v.IsNil() {
+ return 0
+ }
+ total := v.Cap() * int(v.Type().Elem().Size())
+ for i := 0; i < v.Len(); i++ {
+ total += deepSize(v.Index(i))
+ }
+ return total
+ case reflect.String:
+ return v.Len()
+ default:
+ return 0
+ }
+}
+
+// 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 == nil || 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..0a82bd35c
--- /dev/null
+++ b/pkg/bydbql/prepared_internal_test.go
@@ -0,0 +1,335 @@
+// 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 (
+ "context"
+ "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("empty value: got %v", err)
+ }
+ // A nil parameter element must produce a clean error, not a panic: the
proto
+ // getter is nil-safe, so p.GetValue() returns nil and Bind reports
"has no value".
+ if _, err = ps.Bind([]*modelv1.TagValue{nil}); err == nil ||
!strings.Contains(err.Error(), "has no value") {
+ t.Errorf("nil element: got %v", err)
+ }
+}
+
+func TestTransformBoundRejectsNil(t *testing.T) {
+ var transformer Transformer
+ if _, err := transformer.TransformBound(context.Background(), nil); err
== nil {
+ t.Fatal("TransformBound(nil) must return an error, not panic")
+ }
+}
+
+func TestTransformBoundRejectsMismatchedOverlay(t *testing.T) {
+ ps, err := Prepare("SELECT * FROM STREAM sw IN default WHERE service_id
= ?")
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ var transformer Transformer
+ // An overlay shorter than the placeholder count would otherwise panic
on an
+ // out-of-range ParamIndex; TransformBound must reject it up front.
+ bq := &BoundQuery{stmt: ps, values: nil}
+ if _, err := transformer.TransformBound(context.Background(), bq); err
== nil {
+ t.Fatal("TransformBound must reject an overlay that does not
match the placeholder count")
+ }
+}
+
+// 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)
+ }
+ }
+ })
+}
+
+func TestNumPlaceholders(t *testing.T) {
+ cases := map[string]int{
+ "SELECT * FROM STREAM sw IN default":
0,
+ "SELECT * FROM STREAM sw IN default WHERE service_id = ?":
1,
+ "SELECT * FROM STREAM sw IN default TIME > ? WHERE a = ? LIMIT
?": 3,
+ "SELECT * FROM STREAM sw IN default WHERE a IN (?, ?) AND b
MATCH(?)": 3,
+ }
+ for query, want := range cases {
+ ps, err := Prepare(query)
+ if err != nil {
+ t.Fatalf("Prepare %q: %v", query, err)
+ }
+ if got := ps.NumPlaceholders(); got != want {
+ t.Errorf("NumPlaceholders(%q) = %d, want %d", query,
got, want)
+ }
+ }
+}
+
+func TestEstimatedSize(t *testing.T) {
+ ps, err := Prepare("SELECT * FROM STREAM sw IN default WHERE service_id
= ?")
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ if sz := ps.EstimatedSize(); sz <= 0 {
+ t.Fatalf("EstimatedSize = %d, want > 0", sz)
+ }
+ // The same query yields the same footprint.
+ ps2, err := Prepare("SELECT * FROM STREAM sw IN default WHERE
service_id = ?")
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ if ps.EstimatedSize() != ps2.EstimatedSize() {
+ t.Errorf("EstimatedSize not deterministic: %d vs %d",
ps.EstimatedSize(), ps2.EstimatedSize())
+ }
+ // A statement with more grammar nodes retains more memory.
+ simple, err := Prepare("SELECT * FROM STREAM sw IN default")
+ if err != nil {
+ t.Fatalf("Prepare simple: %v", err)
+ }
+ rich, err := Prepare("SELECT * FROM STREAM sw IN default WHERE a = ?
AND b IN (?, ?) AND c MATCH(?) LIMIT ? OFFSET ?")
+ if err != nil {
+ t.Fatalf("Prepare rich: %v", err)
+ }
+ if rich.EstimatedSize() <= simple.EstimatedSize() {
+ t.Errorf("rich size %d not larger than simple %d",
rich.EstimatedSize(), simple.EstimatedSize())
+ }
+}
+
+// TestDeepSizeBranches drives deepSize through real prepared statements, which
+// exercise all of its branches: nil and non-nil pointers (absent vs present
+// optional clauses), structs (grammar nodes), nil and non-nil slices (empty vs
+// populated IN lists and the groups list), strings (identifiers and
literals), and
+// scalar defaults (LIMIT ints, bool flags).
+func TestDeepSizeBranches(t *testing.T) {
+ sizeOf := func(query string) int {
+ ps, err := Prepare(query)
+ if err != nil {
+ t.Fatalf("Prepare %q: %v", query, err)
+ }
+ return ps.EstimatedSize()
+ }
+
+ // A minimal query already covers structs, absent/present pointers, the
groups
+ // slice, strings, and scalar fields; its footprint must be positive.
+ base := sizeOf("SELECT * FROM STREAM sw IN default")
+ if base <= 0 {
+ t.Fatalf("base size = %d, want > 0", base)
+ }
+ // A longer string literal adds out-of-line string bytes (String
branch).
+ shortStr := sizeOf("SELECT * FROM STREAM sw IN default WHERE service_id
= 'x'")
+ longStr := sizeOf("SELECT * FROM STREAM sw IN default WHERE service_id
= 'xxxxxxxxxxxxxxxxxxxxxxxx'")
+ if longStr <= shortStr {
+ t.Errorf("longer string literal did not grow size: %d <= %d",
longStr, shortStr)
+ }
+ // A longer IN list adds slice-backing and value-node bytes
(Slice/Pointer).
+ oneElem := sizeOf("SELECT * FROM STREAM sw IN default WHERE service_id
IN (?)")
+ manyElem := sizeOf("SELECT * FROM STREAM sw IN default WHERE service_id
IN (?, ?, ?, ?, ?)")
+ if manyElem <= oneElem {
+ t.Errorf("longer IN list did not grow size: %d <= %d",
manyElem, oneElem)
+ }
+ // Adding optional clauses turns nil pointers non-nil and adds int/bool
scalars.
+ withClauses := sizeOf("SELECT * FROM STREAM sw IN default TIME > ?
WHERE a = ? LIMIT ? OFFSET ?")
+ if withClauses <= base {
+ t.Errorf("added clauses did not grow size: %d <= %d",
withClauses, base)
+ }
+}
diff --git a/pkg/bydbql/transformer.go b/pkg/bydbql/transformer.go
index d1460b85e..2167a9283 100644
--- a/pkg/bydbql/transformer.go
+++ b/pkg/bydbql/transformer.go
@@ -99,18 +99,107 @@ 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) {
+ if bq == nil || bq.stmt == nil {
+ return nil, errors.New("nil bound query; construct it with
PreparedStatement.Bind")
+ }
+ // The template's ParamIndex values index into bq.values, so a
mismatched
+ // overlay would panic on an out-of-range read; fail fast instead.
+ if len(bq.values) != len(bq.stmt.specs) {
+ return nil, fmt.Errorf("bound query has %d value(s) but the
statement has %d placeholder(s); construct it with PreparedStatement.Bind",
+ len(bq.values), len(bq.stmt.specs))
+ }
+ 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 +208,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 +222,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 +240,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 +262,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 +308,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 +319,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 +348,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 +377,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 +418,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 +429,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 +441,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 +480,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 +520,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 +531,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 +588,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 +597,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 +628,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 +639,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 +652,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 +684,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 +694,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 +717,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 +733,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 +743,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 +843,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 +878,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 +889,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 +906,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 +922,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 +941,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 +957,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 +976,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 +1016,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 +1037,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 +1062,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 +1108,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 +1116,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 +1134,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 +1155,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 +1170,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 +1195,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 +1227,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 +1243,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 +1268,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 +1277,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 +1291,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 +1310,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 +1359,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 +1379,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 +1427,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 +1450,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 +1462,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 +1482,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 +1516,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 +1576,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 +1587,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 +1617,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 +1632,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 +1651,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 +1674,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 +1691,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 +1708,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 +1723,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 +1739,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 +1764,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 +1780,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 +1805,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 +1820,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 +1846,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
}