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>
459 lines
16 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|