From 675d7d7f6c91cb6d9ec95865fabc2320a7872b63 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Thu, 1 Oct 2026 12:09:47 +0000 Subject: [PATCH] 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) --- .../specs/job-array-one-based-index/tasks.md | 3 +- .../apiserver/controllers/jobmatch.go | 283 +++++++++++ .../apiserver/controllers/jobmatch_test.go | 459 ++++++++++++++++++ 3 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apiserver/controllers/jobmatch.go create mode 100644 src/frontend/apiserver/controllers/jobmatch_test.go diff --git a/.kiro/specs/job-array-one-based-index/tasks.md b/.kiro/specs/job-array-one-based-index/tasks.md index 80fbb912..cb98cc04 100644 --- a/.kiro/specs/job-array-one-based-index/tasks.md +++ b/.kiro/specs/job-array-one-based-index/tasks.md @@ -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. diff --git a/src/frontend/apiserver/controllers/jobmatch.go b/src/frontend/apiserver/controllers/jobmatch.go new file mode 100644 index 00000000..402223f8 --- /dev/null +++ b/src/frontend/apiserver/controllers/jobmatch.go @@ -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 +} diff --git a/src/frontend/apiserver/controllers/jobmatch_test.go b/src/frontend/apiserver/controllers/jobmatch_test.go new file mode 100644 index 00000000..71b569a0 --- /dev/null +++ b/src/frontend/apiserver/controllers/jobmatch_test.go @@ -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) + } + }) + } +}