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

hanahmily pushed a commit to branch vectorized-query
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git

commit fedf8cf43a9ba59088d71e75ed407b09f51e50dc
Author: Hongtao Gao <[email protected]>
AuthorDate: Fri May 15 10:04:56 2026 +0000

    feat(query/vectorized/measure/plan): G9c boundary-error parity (nil 
TimeRange, projection, empty result)
---
 pkg/query/vectorized/measure/plan/dispatch.go      | 107 +++++++++------
 pkg/query/vectorized/measure/plan/dispatch_test.go | 152 +++++++++++++++------
 test/cases/measure/data/input/empty_result.ql      |  21 +++
 test/cases/measure/data/input/empty_result.yaml    |  32 +++++
 .../data/input/project_non_existent_field.ql       |  19 +++
 .../data/input/project_non_existent_field.yaml     |  25 ++++
 test/cases/measure/measure.go                      |   2 +
 7 files changed, 277 insertions(+), 81 deletions(-)

diff --git a/pkg/query/vectorized/measure/plan/dispatch.go 
b/pkg/query/vectorized/measure/plan/dispatch.go
index 3ada53ef1..baa870a2b 100644
--- a/pkg/query/vectorized/measure/plan/dispatch.go
+++ b/pkg/query/vectorized/measure/plan/dispatch.go
@@ -22,6 +22,8 @@ import (
        "fmt"
        "sync/atomic"
 
+       "github.com/pkg/errors"
+
        commonv1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
        databasev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
        measurev1 
"github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
@@ -146,9 +148,16 @@ func Dispatch(
                        return nil, "", false, nil
                }
        }
-       if req.GetTimeRange() == nil {
-               return nil, "", false, nil
-       }
+       // G9c #9: a nil TimeRange is NOT a fall-through. The row path does not
+       // reject it — parseFields feeds 
criteria.GetTimeRange().GetBegin().AsTime()
+       // into the index scan, and a nil *timestamppb.Timestamp resolves to the
+       // Unix epoch (1970-01-01T00:00:00Z). The query then runs over the
+       // degenerate [epoch, epoch] window and yields an empty result. The
+       // scan.Params.TimeRange construction below mirrors that exactly
+       // (req.GetTimeRange().GetBegin().AsTime() == epoch when TimeRange is
+       // nil), so dispatch produces the row path's canonical empty response
+       // directly instead of borrowing it.
+
        // Defensive nil guards on the runtime context. These should not fire
        // in production paths — buildMeasureContext populates all of them —
        // but a defensive fallthrough is safer than a nil dereference.
@@ -156,13 +165,14 @@ func Dispatch(
                return nil, "", false, nil
        }
 
-       // Projection validation. The row path's Analyze rejects unknown
-       // projection names via ValidateProjectionTags / 
ValidateProjectionFields
-       // and surfaces a descriptive error. Dispatch falls through so the
-       // row path produces that canonical error (test fixtures with
-       // WantErr=true depend on it).
-       if !projectionsExistInSchema(req, measureSchema) {
-               return nil, "", false, nil
+       // G9c #11: projection validation. The row path's Analyze rejects
+       // unknown projection names via ValidateProjectionTags /
+       // ValidateProjectionFields and surfaces a descriptive error
+       // (test fixtures with WantErr=true assert it). Dispatch reproduces
+       // that canonical error byte-for-byte and returns handled=true so the
+       // caller surfaces it rather than falling through.
+       if projErr := validateProjectionParity(req, logicalSchema, 
measureSchema); projErr != nil {
+               return nil, "", true, projErr
        }
 
        // Hidden-tag detection: criteria may reference tags that are NOT in
@@ -250,10 +260,13 @@ func Dispatch(
                return nil, "", true, fmt.Errorf("vec dispatch: query measure: 
%w", queryErr)
        }
        if result == nil {
-               // Match the row path's typed-nil handling: an empty query 
result
-               // flows through the row iterator as a no-op. Falling back lets
-               // that machinery surface the empty response unchanged.
-               return nil, "", false, nil
+               // G9c #13: a typed-nil result is the row path's canonical empty
+               // response. The row iterator (resultMIterator{result: nil}) 
reports
+               // Next()==false immediately and Close()==nil, so the client
+               // observes an empty []*measurev1.InternalDataPoint. Emit the 
same
+               // empty MIterator directly with handled=true instead of 
borrowing
+               // the row machinery.
+               return emptyMIterator{}, p.String(), true, nil
        }
 
        pool := vectorized.NewBatchPool(scan.BatchSchema, cfg.BatchSize)
@@ -329,54 +342,64 @@ func projectedNames(tp *modelv1.TagProjection) 
map[string]struct{} {
        return out
 }
 
-// projectionsExistInSchema returns false if any tag (in any requested tag
-// family) or field name in the request's projection is absent from the
-// Measure schema. Callers use the result as an eligibility gate: missing
-// names route through the row path, which surfaces a descriptive error
-// via logical_measure.Analyze.
-func projectionsExistInSchema(req *measurev1.QueryRequest, m 
*databasev1.Measure) bool {
+// validateProjectionParity reproduces, byte-for-byte, the projection
+// errors the row path's logical_measure.Analyze raises so dispatch can
+// surface the canonical WantErr=true message directly instead of falling
+// through. It mirrors the row path exactly:
+//
+//   - Tags are validated before fields (measure_analyzer.go:110-119).
+//   - Tag projection is checked only when non-empty; each projected tag
+//     (families in order, tags in order) is looked up schema-wide via the
+//     TagSpec registry — the logical.Schema equivalent of CommonSchema's
+//     TagSpecMap. The first miss returns errors.Wrap(ErrTagNotDefined,
+//     tagName), identical to CommonSchema.ValidateProjectionTags
+//     (schema.go:175): "<tagName>: tag is not defined".
+//   - Field projection is checked only when non-empty; the first name
+//     absent from the Measure schema's fields returns errors.Errorf(
+//     "field %s not found in schema", field), identical to
+//     measure.schema.ValidateProjectionFields (measure/schema.go:77).
+//
+// A nil error means every projected name resolves, so dispatch proceeds.
+func validateProjectionParity(req *measurev1.QueryRequest, logicalSchema 
logical.Schema, m *databasev1.Measure) error {
        if tp := req.GetTagProjection(); tp != nil {
                for _, reqFamily := range tp.GetTagFamilies() {
-                       schemaFamily := findSchemaTagFamily(m, 
reqFamily.GetName())
-                       if schemaFamily == nil {
-                               return false
-                       }
-                       known := make(map[string]struct{}, 
len(schemaFamily.GetTags()))
-                       for _, ts := range schemaFamily.GetTags() {
-                               known[ts.GetName()] = struct{}{}
-                       }
                        for _, name := range reqFamily.GetTags() {
-                               if _, ok := known[name]; !ok {
-                                       return false
+                               if logicalSchema.FindTagSpecByName(name) == nil 
{
+                                       return 
errors.Wrap(logical.ErrTagNotDefined, name)
                                }
                        }
                }
        }
        if fp := req.GetFieldProjection(); fp != nil && len(fp.GetNames()) > 0 {
+               // The row path's m.fieldMap is built from md.GetFields() in
+               // logical_measure.BuildSchema, so the Measure schema's field 
set
+               // is the authoritative lookup the row path's
+               // ValidateProjectionFields consults.
                known := make(map[string]struct{}, len(m.GetFields()))
                for _, fs := range m.GetFields() {
                        known[fs.GetName()] = struct{}{}
                }
                for _, name := range fp.GetNames() {
                        if _, ok := known[name]; !ok {
-                               return false
+                               return errors.Errorf("field %s not found in 
schema", name)
                        }
                }
        }
-       return true
-}
-
-// findSchemaTagFamily returns the schema-defined tag family with the
-// given name, or nil if no such family exists.
-func findSchemaTagFamily(m *databasev1.Measure, name string) 
*databasev1.TagFamilySpec {
-       for _, tf := range m.GetTagFamilies() {
-               if tf.GetName() == name {
-                       return tf
-               }
-       }
        return nil
 }
 
+// emptyMIterator is the vec equivalent of the row path's
+// resultMIterator{result: nil}: Next reports no rows, Current is never
+// reached, and Close is a no-op error. Dispatch returns it for the
+// canonical empty response (G9c #13).
+type emptyMIterator struct{}
+
+func (emptyMIterator) Next() bool { return false }
+
+func (emptyMIterator) Current() []*measurev1.InternalDataPoint { return nil }
+
+func (emptyMIterator) Close() error { return nil }
+
 // aggProjectionCoverage reports whether the request's GroupBy keys and
 // Agg field are all present in the request's projections. Required by
 // the dispatch eligibility gate: the BatchAggregation operator locates
diff --git a/pkg/query/vectorized/measure/plan/dispatch_test.go 
b/pkg/query/vectorized/measure/plan/dispatch_test.go
index d83a29191..08f9dcb6c 100644
--- a/pkg/query/vectorized/measure/plan/dispatch_test.go
+++ b/pkg/query/vectorized/measure/plan/dispatch_test.go
@@ -246,11 +246,12 @@ func 
TestDispatch_OrderBy_UnknownIndexRule_BubblesUpError(t *testing.T) {
        }
 }
 
-// TestDispatch_UnknownTagProjection_FallsThrough covers the parity gap
-// for WantErr=true fixtures: the row path rejects unknown tags via
-// ValidateProjectionTags and returns a descriptive error. Dispatch
-// falls through so the row path surfaces that canonical error.
-func TestDispatch_UnknownTagProjection_FallsThrough(t *testing.T) {
+// TestDispatch_UnknownTagProjection_SurfacesCanonicalError covers G9c
+// #11: the row path rejects unknown tags via ValidateProjectionTags with
+// errors.Wrap(ErrTagNotDefined, tagName). Dispatch reproduces that exact
+// message and returns handled=true so the caller surfaces it (the
+// WantErr=true fixtures depend on it) instead of borrowing the row path.
+func TestDispatch_UnknownTagProjection_SurfacesCanonicalError(t *testing.T) {
        measureSchema := testMeasureSchema()
        // nolint:staticcheck // SA1019 — row-path BuildSchema is the only 
schema builder until G8 replaces it.
        logicalSchema, schemaErr := logicalmeasure.BuildSchema(measureSchema, 
nil)
@@ -266,20 +267,27 @@ func TestDispatch_UnknownTagProjection_FallsThrough(t 
*testing.T) {
        }}
        _, _, handled, err := Dispatch(context.Background(),
                req, metadata, measureSchema, logicalSchema, ec, 
dispatchCfg(true))
-       if err != nil {
-               t.Fatalf("unknown tag fallthrough must not error: %v", err)
+       if err == nil {
+               t.Fatal("unknown tag in projection must surface the canonical 
row-path error")
        }
-       if handled {
-               t.Fatal("unknown tag in projection must fall through (row path 
returns WantErr)")
+       // Byte-identical to logical.CommonSchema.ValidateProjectionTags
+       // (pkg/query/logical/schema.go:175): errors.Wrap(ErrTagNotDefined, 
"ghost").
+       const wantMsg = "ghost: tag is not defined"
+       if err.Error() != wantMsg {
+               t.Fatalf("error message parity: want %q, got %q", wantMsg, 
err.Error())
+       }
+       if !handled {
+               t.Fatal("projection error must report handled=true so caller 
surfaces it (no row-path retry)")
        }
        if ec.called {
                t.Fatal("ec.Query must not be invoked when projection is 
invalid")
        }
 }
 
-// TestDispatch_UnknownFieldProjection_FallsThrough is the field-side
-// counterpart of UnknownTagProjection.
-func TestDispatch_UnknownFieldProjection_FallsThrough(t *testing.T) {
+// TestDispatch_UnknownFieldProjection_SurfacesCanonicalError is the
+// field-side counterpart: byte-identical to
+// measure.schema.ValidateProjectionFields (measure/schema.go:77).
+func TestDispatch_UnknownFieldProjection_SurfacesCanonicalError(t *testing.T) {
        measureSchema := testMeasureSchema()
        // nolint:staticcheck // SA1019 — row-path BuildSchema is the only 
schema builder until G8 replaces it.
        logicalSchema, schemaErr := logicalmeasure.BuildSchema(measureSchema, 
nil)
@@ -293,29 +301,84 @@ func TestDispatch_UnknownFieldProjection_FallsThrough(t 
*testing.T) {
        req.FieldProjection = &measurev1.QueryRequest_FieldProjection{Names: 
[]string{"ghost"}}
        _, _, handled, err := Dispatch(context.Background(),
                req, metadata, measureSchema, logicalSchema, ec, 
dispatchCfg(true))
-       if err != nil {
-               t.Fatalf("unknown field fallthrough must not error: %v", err)
+       if err == nil {
+               t.Fatal("unknown field in projection must surface the canonical 
row-path error")
        }
-       if handled {
-               t.Fatal("unknown field in projection must fall through (row 
path returns WantErr)")
+       const wantMsg = "field ghost not found in schema"
+       if err.Error() != wantMsg {
+               t.Fatalf("error message parity: want %q, got %q", wantMsg, 
err.Error())
+       }
+       if !handled {
+               t.Fatal("projection error must report handled=true so caller 
surfaces it (no row-path retry)")
        }
        if ec.called {
                t.Fatal("ec.Query must not be invoked when projection is 
invalid")
        }
 }
 
-// TestDispatch_NoTimeRange_FallsThrough covers the bounded-window
-// requirement.
-func TestDispatch_NoTimeRange_FallsThrough(t *testing.T) {
+// TestDispatch_TagValidatedBeforeField mirrors the row path's ordering
+// (measure_analyzer.go validates tags before fields): when both
+// projections are unknown, the tag error wins.
+func TestDispatch_TagValidatedBeforeField(t *testing.T) {
+       measureSchema := testMeasureSchema()
+       // nolint:staticcheck // SA1019 — row-path BuildSchema is the only 
schema builder until G8 replaces it.
+       logicalSchema, schemaErr := logicalmeasure.BuildSchema(measureSchema, 
nil)
+       if schemaErr != nil {
+               t.Fatalf("BuildSchema: %v", schemaErr)
+       }
+       metadata := &commonv1.Metadata{Name: "demo", Group: "default"}
+       ec := &fakeEC{}
+
+       req := bareReq()
+       req.TagProjection = &modelv1.TagProjection{TagFamilies: 
[]*modelv1.TagProjection_TagFamily{
+               {Name: "default", Tags: []string{"ghost"}},
+       }}
+       req.FieldProjection = &measurev1.QueryRequest_FieldProjection{Names: 
[]string{"phantom"}}
+       _, _, _, err := Dispatch(context.Background(),
+               req, metadata, measureSchema, logicalSchema, ec, 
dispatchCfg(true))
+       if err == nil || err.Error() != "ghost: tag is not defined" {
+               t.Fatalf("tag error must take precedence over field error; got 
%v", err)
+       }
+}
+
+// TestDispatch_NoTimeRange_EmptyResultParity covers G9c #9: a nil
+// TimeRange is NOT rejected by the row path. parseFields feeds
+// criteria.GetTimeRange().GetBegin().AsTime() (nil → Unix epoch) into the
+// scan, the query runs over [epoch, epoch], and the client observes an
+// empty response. Dispatch reproduces that exact behavior directly:
+// ec.Query is invoked (over the epoch window) and the canonical empty
+// MIterator is emitted with handled=true.
+func TestDispatch_NoTimeRange_EmptyResultParity(t *testing.T) {
+       measureSchema := testMeasureSchema()
+       // nolint:staticcheck // SA1019 — row-path BuildSchema is the only 
schema builder until G8 replaces it.
+       logicalSchema, schemaErr := logicalmeasure.BuildSchema(measureSchema, 
nil)
+       if schemaErr != nil {
+               t.Fatalf("BuildSchema: %v", schemaErr)
+       }
+       metadata := &commonv1.Metadata{Name: "demo", Group: "default"}
+       ec := &fakeEC{wantResult: nil, wantErr: nil}
+
        req := bareReq()
        req.TimeRange = nil
-       _, _, handled, err := Dispatch(context.Background(),
-               req, nil, nil, nil, nil, dispatchCfg(true))
+       iter, _, handled, err := Dispatch(context.Background(),
+               req, metadata, measureSchema, logicalSchema, ec, 
dispatchCfg(true))
        if err != nil {
-               t.Fatalf("no-TimeRange fallthrough must not error: %v", err)
+               t.Fatalf("nil TimeRange must not error (row path does not 
reject it): %v", err)
        }
-       if handled {
-               t.Fatal("missing TimeRange must fall through")
+       if !handled {
+               t.Fatal("nil TimeRange must be handled (row path produces an 
empty result, not a fall-through)")
+       }
+       if !ec.called {
+               t.Fatal("ec.Query must be invoked over the epoch window 
(row-path parity)")
+       }
+       if iter == nil {
+               t.Fatal("expected an empty MIterator, got nil")
+       }
+       if iter.Next() {
+               t.Fatal("empty-result MIterator must report Next()==false")
+       }
+       if closeErr := iter.Close(); closeErr != nil {
+               t.Fatalf("empty-result MIterator Close must be nil (row-path 
parity): %v", closeErr)
        }
 }
 
@@ -348,13 +411,15 @@ func (f *fakeEC) Query(_ context.Context, opts 
model.MeasureQueryOptions) (model
        return f.wantResult, f.wantErr
 }
 
-// TestDispatch_EmptyResult_FallsThrough exercises the full eligibility
-// path: an eligible request reaches ec.Query, ec returns (nil, nil)
-// (empty range), Dispatch reports fallthrough so the row path can surface
-// the empty response. This also confirms the index.Query construction
-// and Analyze invocation complete without error against a real
-// logical.Schema.
-func TestDispatch_EmptyResult_FallsThrough(t *testing.T) {
+// TestDispatch_EmptyResult_CanonicalEmptyIterator covers G9c #13: an
+// eligible request reaches ec.Query, ec returns (nil, nil) (the row
+// path's typed-nil empty result). Dispatch emits the canonical empty
+// MIterator (Next()==false, Close()==nil) with handled=true — the same
+// empty []*measurev1.InternalDataPoint the row iterator
+// (resultMIterator{result: nil}) would surface. This also confirms the
+// index.Query construction and Analyze invocation complete without error
+// against a real logical.Schema.
+func TestDispatch_EmptyResult_CanonicalEmptyIterator(t *testing.T) {
        measureSchema := testMeasureSchema()
        // nolint:staticcheck // SA1019 — row-path BuildSchema is the only 
schema builder until G8 replaces it.
        logicalSchema, schemaErr := logicalmeasure.BuildSchema(measureSchema, 
nil)
@@ -364,19 +429,25 @@ func TestDispatch_EmptyResult_FallsThrough(t *testing.T) {
        metadata := &commonv1.Metadata{Name: "demo", Group: "default"}
        ec := &fakeEC{wantResult: nil, wantErr: nil}
 
-       iter, planStr, handled, err := Dispatch(context.Background(),
+       iter, _, handled, err := Dispatch(context.Background(),
                bareReq(), metadata, measureSchema, logicalSchema, ec, 
dispatchCfg(true))
        if err != nil {
                t.Fatalf("dispatch must not error on empty result: %v", err)
        }
-       if handled {
-               t.Fatal("empty result must fall through to row path")
+       if !handled {
+               t.Fatal("empty result must be handled (canonical empty 
response, not a fall-through)")
        }
-       if iter != nil || planStr != "" {
-               t.Fatalf("expect zero outputs on fallthrough, got iter=%v 
planStr=%q", iter, planStr)
+       if iter == nil {
+               t.Fatal("expected an empty MIterator, got nil")
+       }
+       if iter.Next() {
+               t.Fatal("empty-result MIterator must report Next()==false")
+       }
+       if closeErr := iter.Close(); closeErr != nil {
+               t.Fatalf("empty-result MIterator Close must be nil (row-path 
parity): %v", closeErr)
        }
        if !ec.called {
-               t.Fatal("ec.Query must be invoked before fallthrough decision")
+               t.Fatal("ec.Query must be invoked before the empty-result 
decision")
        }
        if ec.lastOpts.Name != "demo" {
                t.Fatalf("opts.Name: want demo, got %q", ec.lastOpts.Name)
@@ -395,8 +466,11 @@ func TestDispatch_Counters_TrackFellThroughCalls(t 
*testing.T) {
        startHandled := HandledCount()
        startFellThrough := FellThroughCount()
 
-       // Three fallthroughs of distinct shapes, each tripping a different
-       // gate so the counter is exercised across the eligibility branches.
+       // Three fallthroughs of distinct shapes. topReq trips the Top gate;
+       // the other two trip the PERMANENT nil-runtime-context guard (all-nil
+       // schema/ec/metadata). Note: post-G9c a nil TimeRange is no longer a
+       // fall-through on its own — noTimeReq falls through here only because
+       // the runtime context is nil.
        topReq := bareReq()
        topReq.Top = &measurev1.QueryRequest_Top{Number: 5, FieldName: "value"}
        noTimeReq := bareReq()
diff --git a/test/cases/measure/data/input/empty_result.ql 
b/test/cases/measure/data/input/empty_result.ql
new file mode 100644
index 000000000..1ba070c8c
--- /dev/null
+++ b/test/cases/measure/data/input/empty_result.ql
@@ -0,0 +1,21 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+SELECT id, total::field, value::field FROM MEASURE service_cpm_minute IN 
sw_metric
+TIME > '-15m'
+WHERE id = 'no_such_id_xyz'
diff --git a/test/cases/measure/data/input/empty_result.yaml 
b/test/cases/measure/data/input/empty_result.yaml
new file mode 100644
index 000000000..48b91206e
--- /dev/null
+++ b/test/cases/measure/data/input/empty_result.yaml
@@ -0,0 +1,32 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: "service_cpm_minute"
+groups: ["sw_metric"]
+tagProjection:
+  tagFamilies:
+    - name: "default"
+      tags: ["id"]
+fieldProjection:
+  names: ["total", "value"]
+criteria:
+  condition:
+    name: "id"
+    op: "BINARY_OP_EQ"
+    value:
+      str:
+        value: "no_such_id_xyz"
diff --git a/test/cases/measure/data/input/project_non_existent_field.ql 
b/test/cases/measure/data/input/project_non_existent_field.ql
new file mode 100644
index 000000000..0a567dc5f
--- /dev/null
+++ b/test/cases/measure/data/input/project_non_existent_field.ql
@@ -0,0 +1,19 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+SELECT id, total, nonexistent_field FROM service_cpm_minute IN sw_metric
+TIME > '-15m'
diff --git a/test/cases/measure/data/input/project_non_existent_field.yaml 
b/test/cases/measure/data/input/project_non_existent_field.yaml
new file mode 100644
index 000000000..64c7da9e4
--- /dev/null
+++ b/test/cases/measure/data/input/project_non_existent_field.yaml
@@ -0,0 +1,25 @@
+# Licensed to Apache Software Foundation (ASF) under one or more contributor
+# license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright
+# ownership. Apache Software Foundation (ASF) licenses this file to you under
+# the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: "service_cpm_minute"
+groups: ["sw_metric"]
+tagProjection:
+  tagFamilies:
+    - name: "default"
+      tags: ["id"]
+fieldProjection:
+  names: ["total", "nonexistent_field"]
diff --git a/test/cases/measure/measure.go b/test/cases/measure/measure.go
index 81127e221..a240092e4 100644
--- a/test/cases/measure/measure.go
+++ b/test/cases/measure/measure.go
@@ -97,6 +97,7 @@ var measureEntries = []any{
        g.Entry("multi groups: new tag and fields", helpers.Args{Input: 
"multi_group_new_tag_field", Duration: 35 * time.Minute, Offset: -20 * 
time.Minute}),
        g.Entry("filter by non-existent tag", helpers.Args{Input: 
"filter_non_existent_tag", Duration: 25 * time.Minute, Offset: -20 * 
time.Minute, WantErr: true}),
        g.Entry("project non-existent tag", helpers.Args{Input: 
"project_non_existent_tag", Duration: 25 * time.Minute, Offset: -20 * 
time.Minute, WantErr: true}),
+       g.Entry("project non-existent field", helpers.Args{Input: 
"project_non_existent_field", Duration: 25 * time.Minute, Offset: -20 * 
time.Minute, WantErr: true}),
        g.Entry("write mixed", helpers.Args{Input: "write_mixed", Duration: 15 
* time.Minute, Offset: 25 * time.Minute, DisOrder: true}),
        g.Entry("filter by tag with NE", helpers.Args{Input: "tag_filter_ne", 
Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
        g.Entry("filter by tag with NOT IN", helpers.Args{Input: 
"tag_filter_not_in", Duration: 25 * time.Minute, Offset: -20 * time.Minute, 
DisOrder: true}),
@@ -107,6 +108,7 @@ var measureEntries = []any{
        g.Entry("index mode filter by NE", helpers.Args{Input: "index_mode_ne", 
Duration: 25 * time.Minute, Offset: -20 * time.Minute, DisOrder: true}),
        g.Entry("index mode filter by LE on int", helpers.Args{Input: 
"index_mode_le", Duration: 25 * time.Minute, Offset: -20 * time.Minute, 
DisOrder: true}),
        g.Entry("offset beyond results", helpers.Args{Input: "offset_empty", 
Duration: 25 * time.Minute, Offset: -20 * time.Minute, WantEmpty: true}),
+       g.Entry("empty result for unmatched filter", helpers.Args{Input: 
"empty_result", Duration: 25 * time.Minute, Offset: -20 * time.Minute, 
WantEmpty: true}),
 
        // Generated test cases (Layer 1: leaf conditions)
        g.Entry("gen: leaf EQ string", helpers.Args{Input: "gen_leaf_eq_str", 
Want: "gen_leaf_eq_str", Duration: 25 * time.Minute, Offset: -20 * 
time.Minute}),

Reply via email to