myxce/src/frontend/apiserver/controllers/jobmatch_test.go
Rongsong Shen 675d7d7f6c
feat(job-array-one-based-index): selection predicate builder (task 7.2)
BuildSelectionPredicates converts each job_id/job_name filter entry
containing a bracketed value into one row predicate (values OR'ed,
entries AND'ed with each other and the plain conditions), removing only
that entry. Operators are honoured instead of dropped (bug-1898):
eq/in/between select, notin excludes, name pattern operators reuse
today's condition semantics on the name part, and ne / comparison /
null operators keep today's rejection. Element selections match only
array elements in range; bare identities match the whole array and any
ordinary job; an empty name under exact operators matches nothing.
Bare written keys are normalised to decimal on every path. Not yet
wired into the handlers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:09:47 +00:00

459 lines
16 KiB
Go

package controllers
import (
"errors"
"sort"
"strconv"
"strings"
"testing"
"apiserver/backend"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
)
// Pure tests for the selection predicate builder (job-array-one-based-index
// task 7.2, design "ApiSel and ApiMatch" -> Matching). Each case builds the
// predicates from parsed filters and evaluates them, together with the
// remaining plain conditions, through store.ExecuteQuery over rows shaped
// like store.Jobs (store.StructToOrderMap of models.JobInfo).
// Written identities used below. Array keys have a sequence that is a
// multiple of 32768 (array-capable); mOrd is an ordinary job.
const (
mX = "3-5-1700000000-65536" // array "foo", elements 1, 2, 3
mY = "3-5-1700000000-98304" // array "foobar", elements 1, 2
mZ = "3-5-1700000000-196608" // unnamed array (job_name ""), element 1
mW = "3-5-1700000000-163840" // pre-upgrade array "old", elements 0, 1
mP = "3-5-1700000000-131072" // ordinary job "foo" on an array-capable key
mOrd = "3-5-1700000000-7" // ordinary job "plain"
)
func mKey(t *testing.T, written string) uint64 {
t.Helper()
k, err := backend.ParseHumanJobIDStrict(written)
if err != nil {
t.Fatalf("ParseHumanJobIDStrict(%q): %v", written, err)
}
return k
}
func mDec(t *testing.T, written string) string {
return strconv.FormatUint(mKey(t, written), 10)
}
func mElemRow(key uint64, name string, k int) utils.OrderMap {
elem := backend.MakeElementID(key, k)
return store.StructToOrderMap(models.JobInfo{
Unique_JobID: int64(elem),
JobID: int64(backend.ExtractArrayBaseID(elem)),
ArrayIdx: backend.ExtractArrayIdx(elem),
JobName: name,
})
}
func mOrdRow(id uint64, name string) utils.OrderMap {
return store.StructToOrderMap(models.JobInfo{
Unique_JobID: int64(id),
JobID: int64(id),
ArrayIdx: 0,
JobName: name,
})
}
// mRows returns the fixture rows and a label for every unique_job_id.
func mRows(t *testing.T) ([]utils.OrderMap, map[int64]string) {
t.Helper()
labels := map[int64]string{}
var rows []utils.OrderMap
addElem := func(label, written, name string, k int) {
r := mElemRow(mKey(t, written), name, k)
rows = append(rows, r)
labels[r.Data["unique_job_id"].(int64)] = label
}
addOrd := func(label, written, name string) {
r := mOrdRow(mKey(t, written), name)
rows = append(rows, r)
labels[r.Data["unique_job_id"].(int64)] = label
}
for k := 1; k <= 3; k++ {
addElem("X"+strconv.Itoa(k), mX, "foo", k)
}
addElem("Y1", mY, "foobar", 1)
addElem("Y2", mY, "foobar", 2)
addElem("Z1", mZ, "", 1)
addElem("W0", mW, "old", 0)
addElem("W1", mW, "old", 1)
addOrd("P", mP, "foo")
addOrd("O", mOrd, "plain")
return rows, labels
}
var mAll = []string{"O", "P", "W0", "W1", "X1", "X2", "X3", "Y1", "Y2", "Z1"}
func mWithout(skip ...string) []string {
out := []string{}
for _, l := range mAll {
drop := false
for _, s := range skip {
if l == s {
drop = true
}
}
if !drop {
out = append(out, l)
}
}
return out
}
// mRun builds the predicates for filters and evaluates them with the
// remaining plain conditions. It returns the sorted labels of the matching
// rows and the remaining plain filters.
func mRun(t *testing.T, filters []utils.FilterCondition) ([]string, []utils.FilterCondition, error) {
t.Helper()
rows, labels := mRows(t)
plain, preds, err := BuildSelectionPredicates(filters)
if err != nil {
return nil, plain, err
}
conds, err := utils.BuildFilterConditions(plain, &models.JobInfo{})
if err != nil {
t.Fatalf("BuildFilterConditions(%v): %v", plain, err)
}
got, _ := store.ExecuteQuery(rows, &utils.QueryOptions{
Conditions: conds,
Predicates: preds,
Limit: 1000,
}, nil)
out := []string{}
for _, r := range got {
out = append(out, labels[r.Data["unique_job_id"].(int64)])
}
sort.Strings(out)
return out, plain, nil
}
func selF(field, op, value string) utils.FilterCondition {
return utils.FilterCondition{Field: field, Operator: op, Value: value}
}
func TestBuildSelectionPredicates_Matching(t *testing.T) {
x, y, w, p := mX, mY, mW, mP
cases := []struct {
name string
filters func(t *testing.T) []utils.FilterCondition
want []string
}{
// identity selections: eq / in / between select, notin excludes
{"job_id eq single element (bug-1898)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "eq", x+"[2]")}
},
[]string{"X2"}},
{"job_id in range",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "in", "("+x+"[1-2])")}
},
[]string{"X1", "X2"}},
{"job_id in range on decimal key",
func(t *testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "in", "("+mDec(t, x)+"[2-3])")}
},
[]string{"X2", "X3"}},
{"job_id between with bracketed values is evaluated like in",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "between", "("+x+"[1],"+x+"[3])")}
},
[]string{"X1", "X3"}},
{"job_id notin excludes the element only",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "notin", "("+x+"[1])")}
},
mWithout("X1")},
{"job_id notin with bare and bracketed values",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "notin", "("+y+","+x+"[1])")}
},
mWithout("X1", "Y1", "Y2")},
{"element 0 of an ordinary job's id lists nothing (5.11)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "eq", p+"[0]")}
},
[]string{}},
{"pre-upgrade element 0 selected alone (8.2)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "eq", w+"[0]")}
},
[]string{"W0"}},
{"pre-upgrade element 0 range form",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "eq", w+"[0-0]")}
},
[]string{"W0"}},
{"bare array id inside a converted entry selects the whole array (5.6)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "in", "("+x+","+y+"[1])")}
},
[]string{"X1", "X2", "X3", "Y1"}},
{"bare id inside a converted entry matches an ordinary job (5.6)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "in", "("+p+","+y+"[1])")}
},
[]string{"P", "Y1"}},
{"mixed decimal ordinary id and element (job_id:in:(100,X[1]))",
func(t *testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "in", "("+mDec(t, mOrd)+","+x+"[1])")}
},
[]string{"O", "X1"}},
{"one-element selection behaves as an array (7.8)",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_id", "eq", mZ+"[1]")}
},
[]string{"Z1"}},
// name selections
{"job_name eq element of named arrays only",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "eq", "foo[2]")}
},
[]string{"X2"}},
{"job_name eq never matches an ordinary job of that name",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "eq", "foo[0-3]")}
},
[]string{"X1", "X2", "X3"}},
{"job_name in ORs selections",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "in", "(foo[1],foobar[2])")}
},
[]string{"X1", "Y2"}},
{"job_name in with a bare name keeps the plain match",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "in", "(plain, foobar[2])")}
},
[]string{"O", "Y2"}},
{"job_name notin excludes whole selections",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "notin", "(foo[1])")}
},
mWithout("X1")},
{"empty name under eq matches no unnamed element",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "eq", "[1]")}
},
[]string{}},
{"empty name under in matches nothing",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "in", "([1])")}
},
[]string{}},
{"empty name under notin excludes nothing",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "notin", "([1])")}
},
mAll},
{"empty name under contains matches every element 1, unnamed included",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "contains", "[1]")}
},
[]string{"W1", "X1", "Y1", "Z1"}},
{"startwith uses today's substring semantics on the name part",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "startwith", "foo[1]")}
},
[]string{"X1", "Y1"}},
{"contains is case-insensitive like today",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "contains", "FOO[2]")}
},
[]string{"X2", "Y2"}},
{"endwith on the name part",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "endwith", "bar[1-2]")}
},
[]string{"Y1", "Y2"}},
{"notcontains negates the name part only",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{selF("job_name", "notcontains", "foo[1]")}
},
[]string{"W1", "Z1"}},
// shape: entries AND'ed with each other and with plain conditions
{"identity and name selections intersect",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{
selF("job_id", "in", "("+x+"[1-2],"+y+"[1])"),
selF("job_name", "eq", "foobar[1-2]"),
}
},
[]string{"Y1"}},
{"another operator entry on the same field stays a plain condition",
func(t *testing.T) []utils.FilterCondition {
return []utils.FilterCondition{
selF("job_id", "in", "("+x+"[1-3])"),
selF("job_id", "ne", mDec(t, x)),
}
},
[]string{}},
{"plain condition on another field is AND'ed",
func(*testing.T) []utils.FilterCondition {
return []utils.FilterCondition{
selF("job_id", "in", "("+x+"[1-3])"),
selF("array_idx", "gte", "2"),
}
},
[]string{"X2", "X3"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _, err := mRun(t, tc.filters(t))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("matched %v, want %v", got, tc.want)
}
})
}
}
// The same written-form bare key matches the same job alone (plain path,
// normalised to decimal) and mixed with a bracketed value (converted path).
func TestBuildSelectionPredicates_BareKeyBothPaths(t *testing.T) {
for _, tc := range []struct {
name string
filters []utils.FilterCondition
want []string
}{
{"eq alone", []utils.FilterCondition{selF("job_id", "eq", mOrd)}, []string{"O"}},
{"in alone", []utils.FilterCondition{selF("job_id", "in", "("+mOrd+")")}, []string{"O"}},
{"in mixed with a bracketed value", []utils.FilterCondition{selF("job_id", "in", "("+mOrd+","+mX+"[1])")}, []string{"O", "X1"}},
{"array id alone", []utils.FilterCondition{selF("job_id", "eq", mY)}, []string{"Y1", "Y2"}},
{"array id mixed", []utils.FilterCondition{selF("job_id", "in", "("+mY+","+mX+"[1])")}, []string{"X1", "Y1", "Y2"}},
{"notin alone", []utils.FilterCondition{selF("job_id", "notin", "("+mOrd+")")}, mWithout("O")},
{"ne alone", []utils.FilterCondition{selF("job_id", "ne", mOrd)}, mWithout("O")},
} {
t.Run(tc.name, func(t *testing.T) {
got, _, err := mRun(t, tc.filters)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("matched %v, want %v", got, tc.want)
}
})
}
}
func TestBuildSelectionPredicates_PlainFilters(t *testing.T) {
dec := mDec(t, mOrd)
in := []utils.FilterCondition{
selF("job_id", "eq", mOrd),
selF("job_id", "in", "("+mOrd+", "+mDec(t, mX)+")"),
selF("job_id", "gt", "5"),
selF("job_name", "eq", "plain"),
selF("queue_name", "eq", "normal"),
}
orig := append([]utils.FilterCondition(nil), in...)
plain, preds, err := BuildSelectionPredicates(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(preds) != 0 {
t.Fatalf("got %d predicates for plain filters, want 0", len(preds))
}
want := []utils.FilterCondition{
selF("job_id", "eq", dec),
selF("job_id", "in", "("+dec+","+mDec(t, mX)+")"),
selF("job_id", "gt", "5"),
selF("job_name", "eq", "plain"),
selF("queue_name", "eq", "normal"),
}
if len(plain) != len(want) {
t.Fatalf("plain = %v, want %v", plain, want)
}
for i := range want {
if plain[i] != want[i] {
t.Errorf("plain[%d] = %+v, want %+v", i, plain[i], want[i])
}
}
for i := range orig {
if in[i] != orig[i] {
t.Errorf("input filter %d modified: %+v, was %+v", i, in[i], orig[i])
}
}
}
func TestBuildSelectionPredicates_RemovesOnlyConvertedEntry(t *testing.T) {
in := []utils.FilterCondition{
selF("job_id", "in", "("+mX+"[1])"),
selF("job_id", "ne", "100"),
selF("job_name", "contains", "fo"),
}
plain, preds, err := BuildSelectionPredicates(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(preds) != 1 {
t.Fatalf("got %d predicates, want 1", len(preds))
}
want := []utils.FilterCondition{selF("job_id", "ne", "100"), selF("job_name", "contains", "fo")}
if len(plain) != 2 || plain[0] != want[0] || plain[1] != want[1] {
t.Fatalf("plain = %v, want %v", plain, want)
}
}
func TestBuildSelectionPredicates_Errors(t *testing.T) {
elemDec := strconv.FormatUint(backend.MakeElementID(mKey(t, mX), 2), 10)
for _, tc := range []struct {
name string
filter utils.FilterCondition
rule string // *SelError rule, or "" for a plain error
msgPart string
}{
{"job_id ne bracketed", selF("job_id", "ne", mX+"[1]"), "", "operator doesn't support"},
{"job_id lt bracketed", selF("job_id", "lt", mX+"[1]"), "", "operator doesn't support"},
{"job_id lte bracketed", selF("job_id", "lte", mX+"[1]"), "", "operator doesn't support"},
{"job_id gt bracketed", selF("job_id", "gt", mX+"[1]"), "", "operator doesn't support"},
{"job_id gte bracketed", selF("job_id", "gte", mX+"[1]"), "", "operator doesn't support"},
{"job_id null bracketed", selF("job_id", "null", mX+"[1]"), "", "operator doesn't support"},
{"job_id notnull bracketed", selF("job_id", "notnull", mX+"[1]"), "", "operator doesn't support"},
{"job_id contains bracketed", selF("job_id", "contains", mX+"[1]"), "", "unsupported operator"},
{"job_name ne bracketed", selF("job_name", "ne", "foo[1]"), "", "operator doesn't support"},
{"job_name null bracketed", selF("job_name", "null", "foo[1]"), "", "operator doesn't support"},
{"job_name notnull bracketed", selF("job_name", "notnull", "foo[1]"), "", "operator doesn't support"},
{"job_name between bracketed", selF("job_name", "between", "(a[1],b[1])"), "", "unsupported operator"},
{"multiple values under eq", selF("job_id", "eq", "("+mX+"[1],"+mY+"[1])"), "", "operator doesn't allow to use multiple values"},
{"element number too large", selF("job_id", "eq", mX+"[40000]"), "5.7", "40000"},
{"reversed name range", selF("job_name", "eq", "foo[3-1]"), "5.5", "foo[3-1]"},
{"bad name syntax", selF("job_name", "in", "(foo bar[1])"), "5.16", "foo bar[1]"},
{"not array-capable", selF("job_id", "eq", "3-5-1700000000-5[1]"), "5.8", "3-5-1700000000-5"},
{"bare value in converted entry checked", selF("job_id", "in", "(abc,"+mX+"[1])"), "5.16", "abc"},
{"plain marked decimal without index (5.17)", selF("job_id", "eq", elemDec), "5.17", elemDec},
{"plain in with marked decimal", selF("job_id", "in", "(1,"+elemDec+")"), "5.17", elemDec},
{"plain field too wide (5.14)", selF("job_id", "eq", "64-5-1700000000-7"), "5.14", "64-5-1700000000-7"},
} {
t.Run(tc.name, func(t *testing.T) {
_, preds, err := BuildSelectionPredicates([]utils.FilterCondition{tc.filter})
if err == nil {
t.Fatalf("expected an error, got %d predicates", len(preds))
}
var se *SelError
if errors.As(err, &se) {
if tc.rule == "" {
t.Fatalf("got selection error %q (rule %s), want plain error containing %q", se.Msg, se.Rule, tc.msgPart)
}
if se.Rule != tc.rule {
t.Fatalf("rule = %s (%q), want %s", se.Rule, se.Msg, tc.rule)
}
} else if tc.rule != "" {
t.Fatalf("got plain error %q, want selection error rule %s", err, tc.rule)
}
if !strings.Contains(err.Error(), tc.msgPart) {
t.Fatalf("error %q does not contain %q", err, tc.msgPart)
}
})
}
}