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:
parent
2a8490bc92
commit
675d7d7f6c
3 changed files with 744 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
283
src/frontend/apiserver/controllers/jobmatch.go
Normal file
283
src/frontend/apiserver/controllers/jobmatch.go
Normal 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
|
||||
}
|
||||
459
src/frontend/apiserver/controllers/jobmatch_test.go
Normal file
459
src/frontend/apiserver/controllers/jobmatch_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue