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>
This commit is contained in:
Rongsong Shen 2026-10-01 12:09:47 +00:00
parent 2a8490bc92
commit 675d7d7f6c
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
3 changed files with 744 additions and 1 deletions

View file

@ -147,7 +147,7 @@
- _Boundary: ApiSel_
- _Depends: 5.1_
- [ ] 7.2 Build selection predicates
- [x] 7.2 Build selection predicates
- Turn each filter entry containing a bracketed value into one composed predicate (values OR'ed within the entry, entries AND'ed with each other and with plain conditions), converting the whole entry and only that entry
- Honour the operator: `eq`/`in` (and `between` on job identities) select, `notin` excludes, name pattern operators reuse today's condition semantics on the name part, `ne` and comparison/null operators keep today's "operator doesn't support" error, operators outside the field's table keep today's error
- Match identity elements only among array-marked rows, match empty names under exact operators to no row, and normalise written bare keys to decimal on both the plain and converted paths
@ -247,3 +247,4 @@
- 5.4: handler fixture in `controllers/jobs_handler_test.go` (`newHandlerFixture`, `seedJob`/`seedOrdinaryJob`/`seedArrayElements`/`seedQueue`, `submit`, `controlByID`, `controlByFilter`, `dispatchedIDs`, `submissions`); submit bodies must be client-style JSON maps (marshalled `models.JobSubmitRequest` breaks on zero CustomTime). Handlers are registered without `RequirePermission`; in-handler RBAC/queue/status checks still run.
- 6.1: the design's "saturate oversized numbers" rule alone loses the order of two oversized numbers; `ParseSubmitIndexExpr` compares leading-zero-stripped digit strings for 2.6. Use the same approach for the selection parsers' reversed-range check (7.1, 8.1) if they saturate. Rule tags are strings "2.1".."2.8" on `*ArrayExprError`.
- 7.1: `ParseNameSelection` on a value without `[`/`]` returns `SelBare` with `Name` = the raw untrimmed value (`Raw` is trimmed); 7.2 must match bare names on `Name` and must not treat a bare result as validated. Rule tags on `*SelError` are "5.16", "5.14", "5.17", "5.8", "5.7", "5.5"; decimal keys above uint64 are 5.14.
- 7.2: `BuildSelectionPredicates(filters []utils.FilterCondition) (plain []utils.FilterCondition, preds []func(utils.OrderMap) bool, err error)` in `controllers/jobmatch.go`; parser rejections come back as `*SelError`, other rejections with today's texts. Accepted consequence of the one-rule decision: bare `job_id` values under identity operators now go through `ParseIdentitySelection`, so `job_id:eq:+5` / `-1` (previously accepted) are rejected under 5.16. For 7.3: keep the `array_stats` bracket rejection before the builder, and run the builder in the validate-all-first pass before the `exec_hosts` store lookup.

View file

@ -0,0 +1,283 @@
package controllers
import (
"errors"
"fmt"
"strconv"
"strings"
"apiserver/backend"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
)
// Element-selection matching (job-array-one-based-index, design "ApiSel and
// ApiMatch" -> Matching). BuildSelectionPredicates turns the job_id/job_name
// filter entries that carry a bracketed value into row predicates for
// utils.QueryOptions.Predicates, which store.ExecuteQuery evaluates after the
// plain Conditions and before sorting and pagination (bug-1898).
// identityOperators are the job_id operators whose values are job identities
// (as opposed to numbers: between bounds, lt/lte/gt/gte operands).
var identityOperators = map[string]bool{"eq": true, "in": true, "notin": true, "ne": true}
// multiValueOperators may carry more than one value, as today.
var multiValueOperators = map[string]bool{"in": true, "between": true, "notin": true}
// namePatternOperators evaluate the name part with today's condition
// semantics (contains/startwith/endwith map to a case-insensitive substring
// OpLIKE, notcontains to its negation).
var namePatternOperators = map[string]bool{"contains": true, "notcontains": true, "startwith": true, "endwith": true}
// BuildSelectionPredicates converts job selections in parsed filters.
//
// Every job_id or job_name entry with a '[' or ']' in its value is converted
// as a whole: each of its values becomes a selection (a value without
// brackets becomes a bare selection), the values are OR'ed (none of them for
// notin) into one predicate, and that entry -- only that entry -- is left out
// of the returned plain filters. Predicates of different entries, and the
// plain conditions, are AND'ed by the query pipeline.
//
// Values of a job_id entry with an identity operator (eq, in, notin, ne) that
// has no brackets stay plain, but each goes through ParseIdentitySelection and
// is rewritten to its decimal key, so a written c-a-ts-seq key matches the
// same job as inside a converted entry (5.6).
//
// Rejections: a selection the parser rejects is returned as the *SelError
// (its message unchanged); an operator outside the field's mapping table gets
// that table's "unsupported operator" error; ne, and the comparison and null
// operators, with a bracketed value get today's "operator doesn't support"
// error; more than one value for a single-value operator gets today's
// "operator doesn't allow to use multiple values" error.
//
// The input slice is not modified.
func BuildSelectionPredicates(filters []utils.FilterCondition) ([]utils.FilterCondition, []func(utils.OrderMap) bool, error) {
plain := make([]utils.FilterCondition, 0, len(filters))
var preds []func(utils.OrderMap) bool
for _, fc := range filters {
if fc.Field != "job_id" && fc.Field != "job_name" {
plain = append(plain, fc)
continue
}
op := strings.ToLower(strings.TrimSpace(fc.Operator))
bracketed := strings.ContainsAny(fc.Value, "[]")
if !bracketed {
if fc.Field == "job_id" && identityOperators[op] {
norm, err := normaliseIdentityFilter(fc, op)
if err != nil {
return nil, nil, err
}
fc = norm
}
plain = append(plain, fc)
continue
}
pred, err := buildEntryPredicate(fc, op)
if err != nil {
return nil, nil, err
}
preds = append(preds, pred)
}
return plain, preds, nil
}
// selectionValues splits a filter value into its trimmed values, splitting
// at commas outside brackets (today's extractJobNames), and applies today's
// single-value rule.
func selectionValues(fc utils.FilterCondition, op string) ([]string, error) {
values := extractJobNames(fc.Value)
for i := range values {
values[i] = strings.TrimSpace(values[i])
}
if !multiValueOperators[op] && len(values) > 1 {
return nil, fmt.Errorf("operator doesn't allow to use multiple values: %v", values)
}
return values, nil
}
// normaliseIdentityFilter checks every value of a bracket-less job_id entry
// with an identity operator and rewrites written keys to decimal.
func normaliseIdentityFilter(fc utils.FilterCondition, op string) (utils.FilterCondition, error) {
values, err := selectionValues(fc, op)
if err != nil {
return fc, err
}
if len(values) == 0 {
return fc, nil // nothing to check; the plain path reports it as today
}
keys := make([]string, 0, len(values))
for _, v := range values {
sel, se := ParseIdentitySelection(v)
if se != nil {
return fc, se
}
keys = append(keys, strconv.FormatUint(sel.Key, 10))
}
if op == "in" || op == "notin" {
fc.Value = "(" + strings.Join(keys, ",") + ")"
} else {
fc.Value = keys[0]
}
return fc, nil
}
// buildEntryPredicate converts one bracketed job_id/job_name entry.
func buildEntryPredicate(fc utils.FilterCondition, op string) (func(utils.OrderMap) bool, error) {
table := utils.IntOperatorMappingTable
if fc.Field == "job_name" {
table = utils.StringOperatorMappingTable
}
if _, ok := table[op]; !ok {
return nil, unsupportedOperatorError(fc)
}
values, err := selectionValues(fc, op)
if err != nil {
return nil, err
}
var supported bool
if fc.Field == "job_id" {
supported = op == "eq" || op == "in" || op == "between" || op == "notin"
} else {
supported = op == "eq" || op == "in" || op == "notin" || namePatternOperators[op]
}
if !supported {
return nil, fmt.Errorf("operator doesn't support %v", values)
}
matchers := make([]func(utils.OrderMap) bool, 0, len(values))
for _, v := range values {
var m func(utils.OrderMap) bool
if fc.Field == "job_id" {
sel, se := ParseIdentitySelection(v)
if se != nil {
return nil, se
}
m = identityMatcher(sel)
} else {
sel, se := ParseNameSelection(v)
if se != nil {
return nil, se
}
m, err = nameMatcher(sel, op)
if err != nil {
return nil, err
}
}
matchers = append(matchers, m)
}
anyMatch := func(row utils.OrderMap) bool {
for _, m := range matchers {
if m(row) {
return true
}
}
return false
}
if op == "notin" {
return func(row utils.OrderMap) bool { return !anyMatch(row) }, nil
}
return anyMatch, nil
}
// unsupportedOperatorError returns the field table's existing error for an
// operator it does not map, exactly as a plain value gets it today.
func unsupportedOperatorError(fc utils.FilterCondition) error {
_, err := utils.BuildFilterConditions([]utils.FilterCondition{fc}, &models.JobInfo{})
if err != nil {
return err
}
return errors.New("unsupported operator: " + fc.Operator)
}
// identityMatcher: a bare selection matches every row with that job_id (all
// elements of the array, or the ordinary job; 5.6); an element selection
// matches only array elements of that array within the range (5.2, 5.11,
// 8.2), element 0 included when named explicitly.
func identityMatcher(sel Selection) func(utils.OrderMap) bool {
return func(row utils.OrderMap) bool {
jobID, ok := rowUint64(row, "job_id")
if !ok || jobID != sel.Key {
return false
}
if sel.Form == SelBare {
return true
}
return rowElementInRange(row, sel.Lo, sel.Hi)
}
}
// nameMatcher builds the matcher of one job_name value under op.
func nameMatcher(sel Selection, op string) (func(utils.OrderMap) bool, error) {
if namePatternOperators[op] {
conds, err := utils.BuildFilterConditions([]utils.FilterCondition{{
Field: "job_name", Operator: op, Value: sel.Name,
}}, &models.JobInfo{})
if err != nil {
return nil, err
}
if len(conds) != 1 {
return nil, fmt.Errorf("failed to build job_name condition for %q", sel.Raw)
}
cond := conds[0]
if sel.Form == SelBare {
return func(row utils.OrderMap) bool { return store.MatchesCondition(row, cond) }, nil
}
return func(row utils.OrderMap) bool {
return rowElementInRange(row, sel.Lo, sel.Hi) && store.MatchesCondition(row, cond)
}, nil
}
// exact operators (eq, in, notin)
if sel.Form == SelBare {
// a bracket-less value inside a converted entry keeps the plain match
return func(row utils.OrderMap) bool {
name, ok := row.Data["job_name"].(string)
return ok && name == sel.Name
}, nil
}
if sel.Name == "" {
// unnamed jobs are stored with job_name ""; "[k]" names no job
return func(utils.OrderMap) bool { return false }, nil
}
return func(row utils.OrderMap) bool {
name, ok := row.Data["job_name"].(string)
return ok && name == sel.Name && rowElementInRange(row, sel.Lo, sel.Hi)
}, nil
}
// rowElementInRange reports whether row is an array element (its
// unique_job_id carries the array marking) whose array_idx is in [lo, hi].
func rowElementInRange(row utils.OrderMap, lo, hi int) bool {
uid, ok := rowUint64(row, "unique_job_id")
if !ok || !backend.IsArrayJob(uid) {
return false
}
idx, ok := rowUint64(row, "array_idx")
if !ok {
return false
}
return idx >= uint64(lo) && idx <= uint64(hi)
}
// rowUint64 reads an integer column of a store row as uint64 (bit pattern
// preserved for int64 IDs).
func rowUint64(row utils.OrderMap, col string) (uint64, bool) {
switch v := row.Data[col].(type) {
case int64:
return uint64(v), true
case int:
return uint64(v), true
case int32:
return uint64(v), true
case uint64:
return v, true
}
return 0, false
}

View file

@ -0,0 +1,459 @@
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)
}
})
}
}