diff --git a/PATTERNS.md b/PATTERNS.md index 1a47868..a9d2fb6 100644 --- a/PATTERNS.md +++ b/PATTERNS.md @@ -61,6 +61,51 @@ Thus, the following Pattern would match both JSON events above: Quamina can match numeric values with precision and range exactly the same as that provided by Go's `float64` data type, which is said to conform to IEEE 754 `binary64`. +## Numeric Range Matching + +Quamina supports matching numeric values against ranges. You can specify ranges using various operators and combine them: + +```json +{ + "item": { + "quantity": [ { "numeric": [ ">", 0, "<=", 5 ] } ], + "price": [ { "numeric": [ "<", 10 ] } ], + "quantity": [ { "numeric": [ "=", 35 ] } ] + } +} +``` + +### Operators +- `=`: Exact match +- `<`: Less than +- `<=`: Less than or equal to +- `>`: Greater than +- `>=`: Greater than or equal to + +### Examples +```json +// Match prices between $50 and $100 (exclusive) +{ + "price": [ {"numeric": [">", 50, "<", 100]} ] +} + +// Match quantities greater than or equal to 10 +{ + "quantity": [ {"numeric": [">=", 10]} ] +} + +// Match temperatures less than 0 +{ + "quantity": [ {"numeric": ["<", 0]} ] +} +``` + +### Notes +- Operators can be combined to create ranges +- Each bound (upper/lower) can only be specified once +- Values must be numeric (integers or floating point) +- Ranges support negative numbers and decimals + ## Extended Patterns An **Extended Pattern** **MUST** be a JSON object containing a single field whose name is called the **Pattern Type**. diff --git a/cl2_test.go b/cl2_test.go index 3134782..2825413 100644 --- a/cl2_test.go +++ b/cl2_test.go @@ -167,6 +167,47 @@ var ( "}", } regexpMatches = []int{220} + + numericRules = []string{ + "{\n" + + " \"geometry\": {\n" + + " \"type\": [ \"Polygon\" ],\n" + + " \"firstCoordinates\": {\n" + + " \"x\": [ { \"numeric\": [ \"=\", -122.42916360922355 ] } ]\n" + + " }\n" + + " }\n" + + "}", + "{\n" + + " \"geometry\": {\n" + + " \"type\": [ \"MultiPolygon\" ],\n" + + " \"firstCoordinates\": {\n" + + " \"z\": [ { \"numeric\": [ \"=\", 0 ] } ]\n" + + " }\n" + + " }\n" + + "}", + "{\n" + + " \"geometry\": {\n" + + " \"firstCoordinates\": {\n" + + " \"x\": [ { \"numeric\": [ \"<\", -122.41600944012424 ] } ]\n" + + " }\n" + + " }\n" + + "}", + "{\n" + + " \"geometry\": {\n" + + " \"firstCoordinates\": {\n" + + " \"x\": [ { \"numeric\": [ \">\", -122.41600944012424 ] } ]\n" + + " }\n" + + " }\n" + + "}", + "{\n" + + " \"geometry\": {\n" + + " \"firstCoordinates\": {\n" + + " \"x\": [ { \"numeric\": [ \">\", -122.46471267081272, \"<\", -122.4063085128395 ] } ]\n" + + " }\n" + + " }\n" + + "}", + } + numericMatches = []int{2, 120, 148948, 64120, 127053} /* will add when we have numeric complexArraysRules := []string{ "{\n" + @@ -280,6 +321,10 @@ func TestRulerCl2(t *testing.T) { bm = newBenchmarker() bm.addRules(regexpRules, regexpMatches, true) fmt.Printf("REGEXP events/sec: %.1f\n", bm.run(t, lines)) + + bm = newBenchmarker() + bm.addRules(numericRules, numericMatches, true) + fmt.Printf("NUMERIC MATCHES events/sec: %.1f\n", bm.run(t, lines)) } type benchmarker struct { diff --git a/code_gen/code_gen b/code_gen/code_gen new file mode 100755 index 0000000..fa7f883 Binary files /dev/null and b/code_gen/code_gen differ diff --git a/field_matcher.go b/field_matcher.go index 49586a8..3d45875 100644 --- a/field_matcher.go +++ b/field_matcher.go @@ -10,6 +10,8 @@ import ( // thread-safe. type fieldMatcher struct { updateable atomic.Pointer[fmFields] + table *valueMatcher + vals []typedVal } // fmFields contains the updateable fields in fieldMatcher. @@ -131,7 +133,9 @@ func (m *fieldMatcher) addTransition(field *patternField, printer printer) []*fi // cases where this doesn't happen and reduce the number of fieldMatchStates var nextFieldMatchers []*fieldMatcher for _, val := range field.vals { - nextFieldMatchers = append(nextFieldMatchers, vm.addTransition(val, printer)) + fm := vm.addTransition(val, printer) + fm.vals = append(fm.vals, val) + nextFieldMatchers = append(nextFieldMatchers, fm) } m.update(freshStart) return nextFieldMatchers diff --git a/numbits.go b/numbits.go index 860ae37..9f75271 100644 --- a/numbits.go +++ b/numbits.go @@ -59,3 +59,45 @@ func (nb numbits) toQNumber() qNumber { } return b } + +// qNumberToFloat64 converts a qNumber back to float64 +func qNumberToFloat64(qn qNumber) float64 { + // Convert from base-128 back to numbits + var nb numbits + // Process bytes in big-endian order + for i := 0; i < len(qn); i++ { + // Shift existing bits left by 7 and add new 7 bits + nb = (nb << 7) | numbits(qn[i]&0x7f) + } + + // Determine how many 7-bit groups were dropped during encoding. + // The original encoding uses MaxBytesInEncoding groups. + dropped := MaxBytesInEncoding - len(qn) + + // Restore the original numbits value by left-shifting to “recreate” the dropped 7-bit groups. + restored := nb << (7 * uint(dropped)) + + // Convert numbits to a uint64 + u := uint64(restored) + + // Unmask. + // Notice: The original masking did: + // mask = (if original was positive: 1<<63, or if negative: ^0) + // and then: masked = original ^ mask. + // Since our masked value now (u) has its sign bit inverted relative to the original, + // we can recover the original by testing u’s top bit. + var mask uint64 + if u&(1<<63) != 0 { + // Originally positive: mask was 1<<63. + mask = 1 << 63 + } else { + // Originally negative: mask was all ones. + mask = 0xffffffffffffffff + } + + // Unmask the value by XORing with the mask. + u = u ^ mask + + // Convert the result back into a float64. + return math.Float64frombits(u) +} diff --git a/pattern.go b/pattern.go index ed3846c..473593b 100644 --- a/pattern.go +++ b/pattern.go @@ -23,6 +23,7 @@ const ( monocaseType wildcardType regexpType + numericRangeType ) // typedVal represents the value of a field in a pattern, giving the value and the type of pattern. @@ -33,6 +34,7 @@ type typedVal struct { val string list [][]byte parsedRegexp regexpRoot + numericRange *Range } // patternField represents a field in a pattern. @@ -186,6 +188,7 @@ func readPatternArray(pb *patternBuild) error { func readSpecialPattern(pb *patternBuild, valsIn []typedVal) (pathVals []typedVal, containsExclusive string, err error) { containsExclusive = "" pathVals = valsIn + t, err := pb.jd.Token() if err != nil { return @@ -211,9 +214,12 @@ func readSpecialPattern(pb *patternBuild, valsIn []typedVal) (pathVals []typedVa case "regexp": containsExclusive = tt pathVals, err = readRegexpSpecial(pb, pathVals) + case "numeric": + pathVals, err = readNumericRangeSpecial(pb, valsIn) default: err = errors.New("unrecognized in special pattern: " + tt) } + return } @@ -270,3 +276,99 @@ func readExistsSpecial(pb *patternBuild, valsIn []typedVal) (pathVals []typedVal } return } + +func readNumericRangeSpecial(pb *patternBuild, valsIn []typedVal) (pathVals []typedVal, err error) { + t, err := pb.jd.Token() + if err != nil { + return nil, err + } + + // Expect an array + delim, ok := t.(json.Delim) + if !ok || delim != '[' { + return nil, errors.New("numeric range pattern must be an array") + } + + // Read operators and values + var bottom, top string + openBottom := true // Initialize as true since ranges are unbounded by default + openTop := true // Initialize as true since ranges are unbounded by default + seenOps := make(map[string]bool) // Track which operators we've seen + + for { + // Read operator + operator, err := pb.jd.Token() + if err != nil { + return nil, err + } + + // Check for end of array + if delim, ok := operator.(json.Delim); ok && delim == ']' { + break + } + + opStr, ok := operator.(string) + if !ok { + return nil, errors.New("numeric range operator must be a string") + } + + // Check for duplicate operators + if opStr != "=" { // equals is special as it sets both bounds + if (opStr == "<" || opStr == "<=") && seenOps["top"] { + return nil, errors.New("duplicate upper bound in numeric range") + } + if (opStr == ">" || opStr == ">=") && seenOps["bottom"] { + return nil, errors.New("duplicate lower bound in numeric range") + } + } + + // Read value + value, err := pb.jd.Token() + if err != nil { + return nil, err + } + valStr := fmt.Sprintf("%v", value) + + // Process operator and value + switch opStr { + case "=": + bottom, top = valStr, valStr + openBottom, openTop = false, false + case "<": + top = valStr + openTop = true + seenOps["top"] = true + case "<=": + top = valStr + openTop = false + seenOps["top"] = true + case ">": + bottom = valStr + openBottom = true + seenOps["bottom"] = true + case ">=": + bottom = valStr + openBottom = false + seenOps["bottom"] = true + default: + return nil, fmt.Errorf("invalid numeric range operator: %s", opStr) + } + } + + // Create range based on operator + r, err := NewRange(bottom, openBottom, top, openTop, false) + if err != nil { + return nil, err + } + + // Add to pattern values + val := typedVal{ + vType: numericRangeType, + numericRange: r, + } + pathVals = append(valsIn, val) + + // Expect closing brace + _, err = pb.jd.Token() + return pathVals, err +} diff --git a/pattern_test.go b/pattern_test.go index df89c44..4fb8f5a 100644 --- a/pattern_test.go +++ b/pattern_test.go @@ -1,6 +1,7 @@ package quamina import ( + "bytes" "testing" ) @@ -104,20 +105,20 @@ func TestPatternFromJSON(t *testing.T) { } w1 := []*patternField{{path: "x", vals: []typedVal{{vType: numberType, val: "2"}}}} w2 := []*patternField{{path: "x", vals: []typedVal{ - {literalType, "null", nil, nil}, - {literalType, "true", nil, nil}, - {literalType, "false", nil, nil}, - {stringType, `"hopp"`, nil, nil}, - {numberType, "3.072e-11", nil, nil}, + {vType: literalType, val: "null", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: literalType, val: "true", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: literalType, val: "false", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: stringType, val: `"hopp"`, list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: numberType, val: "3.072e-11", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, }}} w3 := []*patternField{ {path: "x\na", vals: []typedVal{ - {numberType, "27", nil, nil}, - {numberType, "28", nil, nil}, + {vType: numberType, val: "27", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: numberType, val: "28", list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, }}, {path: "x\nb\nm", vals: []typedVal{ - {stringType, `"a"`, nil, nil}, - {stringType, `"b"`, nil, nil}, + {vType: stringType, val: `"a"`, list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, + {vType: stringType, val: `"b"`, list: nil, parsedRegexp: regexpRoot{}, numericRange: nil}, }}, } w4 := []*patternField{ @@ -217,3 +218,184 @@ func TestPatternFromJSON(t *testing.T) { } } } + +func TestNumericRangePatterns(t *testing.T) { + tests := []struct { + name string + pattern string + want *patternField + wantErr bool + }{ + { + name: "equals", + pattern: `{"price": [ {"numeric": ["=", 100]} ]}`, + want: &patternField{ + path: "price", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{ + bottom: qNumFromFloat(100), + top: qNumFromFloat(100), + openBottom: false, + openTop: false, + }, + }}, + }, + }, + { + name: "equals scientific notation", + pattern: `{"price": [ {"numeric": ["=", 3.018e2]} ]}`, + want: &patternField{ + path: "price", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{ + bottom: qNumFromFloat(3.018e2), + top: qNumFromFloat(3.018e2), + openBottom: false, + openTop: false, + }, + }}, + }, + }, + { + name: "less than", + pattern: `{"price": [ {"numeric": ["<", 100]} ]}`, + want: &patternField{ + path: "price", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{openBottom: true, openTop: true, top: qNumFromFloat(100)}, + }}, + }, + }, + { + name: "scientific notation less than", + pattern: `{"price": [ {"numeric": ["<", 3.018e2]} ]}`, + want: &patternField{ + path: "price", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{openBottom: true, openTop: true, top: qNumFromFloat(3.018e2)}, + }}, + }, + }, + { + name: "greater than or equal", + pattern: `{"quantity": [ {"numeric": [">=", 10]} ]}`, + want: &patternField{ + path: "quantity", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{bottom: qNumFromFloat(10), openTop: true}, + }}, + }, + }, + { + name: "greater than negative", + pattern: `{"score": [ {"numeric": [">", -5.5]} ]}`, + want: &patternField{ + path: "score", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{bottom: qNumFromFloat(-5.5), openBottom: true, openTop: true}, + }}, + }, + }, + { + name: "less than or equal", + pattern: `{"rating": [ {"numeric": ["<=", 5.0]} ]}`, + want: &patternField{ + path: "rating", + vals: []typedVal{{ + vType: numericRangeType, + val: "", + list: nil, + parsedRegexp: regexpRoot{}, + numericRange: &Range{top: qNumFromFloat(5.0), openBottom: true}, + }}, + }, + }, + { + name: "invalid operator", + pattern: `{"x": [ {"numeric": ["!=", 100]} ]}`, + wantErr: true, + }, + { + name: "non-numeric value", + pattern: `{"x": [ {"numeric": ["<", "abc"]} ]}`, + wantErr: true, + }, + { + name: "missing value", + pattern: `{"x": [ {"numeric": ["<"]} ]}`, + wantErr: true, + }, + { + name: "not an array", + pattern: `{"x": [ {"numeric": "100"} ]}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields, err := patternFromJSON([]byte(tt.pattern)) + if tt.wantErr { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(fields)) + } + + got := fields[0] + if got.path != tt.want.path { + t.Errorf("path = %q, want %q", got.path, tt.want.path) + } + if len(got.vals) != 1 { + t.Fatalf("expected 1 value, got %d", len(got.vals)) + } + if got.vals[0].vType != tt.want.vals[0].vType { + t.Errorf("vType = %v, want %v", got.vals[0].vType, tt.want.vals[0].vType) + } + if got.vals[0].numericRange == nil { + t.Fatal("numericRange is nil") + } + // Compare range properties + gr := got.vals[0].numericRange + wr := tt.want.vals[0].numericRange + if gr.openBottom != wr.openBottom || gr.openTop != wr.openTop { + t.Errorf("range bounds openness mismatch: got %v/%v, want %v/%v", + gr.openBottom, gr.openTop, wr.openBottom, wr.openTop) + } + if !bytes.Equal(gr.bottom, wr.bottom) || !bytes.Equal(gr.top, wr.top) { + t.Errorf("range bounds mismatch: got %v/%v, want %v/%v", + gr.bottom, gr.top, wr.bottom, wr.top) + } + }) + } +} diff --git a/quamina_test.go b/quamina_test.go index 79996ce..9b96b85 100644 --- a/quamina_test.go +++ b/quamina_test.go @@ -178,3 +178,263 @@ func TestCityLots(t *testing.T) { } } } + +func TestNumericRangeMatching(t *testing.T) { + tests := []struct { + name string + patternName string + pattern string + event string + want bool + wantErr bool + }{ + { + name: "equals - matches", + patternName: "equals", + pattern: `{"price": [ {"numeric": ["=", 100]} ]}`, + event: `{"price": 100}`, + want: true, + }, + { + name: "scientific notation equals - matches", + patternName: "scientific notation equals", + pattern: `{"price": [ {"numeric": ["=", 3.018e2]} ]}`, + event: `{"price": 3.018e2}`, + want: true, + }, + { + name: "equals - matches with event as float", + patternName: "equals", + pattern: `{"limit": [ {"numeric": ["=", 35]} ]}`, + event: `{"limit": 35.0}`, + want: true, + }, + { + name: "equals - matches with scientific notation event", + patternName: "equals", + pattern: `{"limit": [ {"numeric": ["=", 35]} ]}`, + event: `{"limit": 3.5e1}`, + want: true, + }, + { + name: "equals - doesn't match", + patternName: "equals no match", + pattern: `{"price": [ {"numeric": ["=", 100]} ]}`, + event: `{"price": 101}`, + want: false, + }, + { + name: "less than - matches", + patternName: "less than", + pattern: `{"price": [ {"numeric": ["<", 100]} ]}`, + event: `{"price": 99.9}`, + want: true, + }, + { + name: "scientific notation less than - matches", + patternName: "scientific notation less than", + pattern: `{"limit": [ {"numeric": ["<", 3.018e2]} ]}`, + event: `{"limit": 3.017e2}`, + want: true, + }, + { + name: "less than - doesn't match", + patternName: "less than no match", + pattern: `{"price": [ {"numeric": ["<", 100]} ]}`, + event: `{"price": 100}`, + want: false, + }, + { + name: "greater than - matches equal", + patternName: "greater than match equal", + pattern: `{"quantity": [ {"numeric": [">", 10]} ]}`, + event: `{"quantity": 11}`, + want: true, + }, + { + name: "scientific notation greater than - matches equal", + patternName: "scientific notation greater than match equal", + pattern: `{"limit": [ {"numeric": [">", 3.018e2]} ]}`, + event: `{"limit": 3.019e2}`, + want: true, + }, + { + name: "greater than or equal - matches equal", + patternName: "greater than or equal match equal", + pattern: `{"quantity": [ {"numeric": [">=", 10]} ]}`, + event: `{"quantity": 10}`, + want: true, + }, + { + name: "greater than or equal - matches greater", + patternName: "greater than or equal match greater", + pattern: `{"quantity": [ {"numeric": [">=", 10]} ]}`, + event: `{"quantity": 11}`, + want: true, + }, + { + name: "greater than or equal - doesn't match", + patternName: "greater than or equal no match", + pattern: `{"quantity": [ {"numeric": [">=", 10]} ]}`, + event: `{"quantity": 9}`, + want: false, + }, + { + name: "greater than negative - matches", + patternName: "greater than negative match", + pattern: `{"score": [ {"numeric": [">", -5.5]} ]}`, + event: `{"score": -5}`, + want: true, + }, + { + name: "greater than negative - doesn't match", + patternName: "greater than negative no match", + pattern: `{"score": [ {"numeric": [">", -5.5]} ]}`, + event: `{"score": -6}`, + want: false, + }, + { + name: "less than or equal - matches equal", + patternName: "less than or equal match equal", + pattern: `{"rating": [ {"numeric": ["<=", 5.0]} ]}`, + event: `{"rating": 5.0}`, + want: true, + }, + { + name: "less than or equal - matches less", + patternName: "less than or equal match less", + pattern: `{"rating": [ {"numeric": ["<=", 5.0]} ]}`, + event: `{"rating": 4.9}`, + want: true, + }, + { + name: "less than or equal - doesn't match", + patternName: "less than or equal no match", + pattern: `{"rating": [ {"numeric": ["<=", 5.0]} ]}`, + event: `{"rating": 5.1}`, + want: false, + }, + { + name: "non-numeric field - doesn't match", + patternName: "non-numeric field no match", + pattern: `{"price": [ {"numeric": ["<", 100]} ]}`, + event: `{"price": "not a number"}`, + want: false, + }, + { + name: "field missing - doesn't match", + patternName: "field missing no match", + pattern: `{"price": [ {"numeric": ["<", 100]} ]}`, + event: `{"other_field": 50}`, + want: false, + }, + { + name: "numeric range - matches", + patternName: "numeric range match", + pattern: `{"price": [ {"numeric": ["<", 100, ">", 50]} ]}`, + event: `{"price": 75}`, + want: true, + }, + { + name: "numeric range - open bottom matches", + patternName: "numeric range open bottom match", + pattern: `{"price": [ {"numeric": ["<", 100, ">=", 50]} ]}`, + event: `{"price": 50}`, + want: true, + }, + { + name: "numeric range - open top matches", + patternName: "numeric range open top match", + pattern: `{"price": [ {"numeric": ["<=", 100, ">=", 50]} ]}`, + event: `{"price": 100}`, + want: true, + }, + + { + name: "numeric range - doesn't match", + patternName: "numeric range no match", + pattern: `{"price": [ {"numeric": ["<", 100, ">", 50]} ]}`, + event: `{"price": 101}`, + want: false, + }, + { + name: "numeric range - doesn't matches with open bottom", + patternName: "numeric range match with open bottom", + pattern: `{"price": [ {"numeric": ["<", 100, ">", 50]} ]}`, + event: `{"price": 50}`, + want: false, + }, + + { + name: "numeric range - doesn't matches with open top", + patternName: "numeric range match with open top", + pattern: `{"price": [ {"numeric": ["<", 100, ">", 50]} ]}`, + event: `{"price": 100}`, + want: false, + }, + { + name: "numeric range - empty array", + patternName: "numeric range empty array", + pattern: `{"price": [ {"numeric": []} ]}`, + event: `{"price": 100}`, + wantErr: true, + }, + { + name: "numeric range - invalid operator", + patternName: "numeric range invalid operator", + pattern: `{"price": [ {"numeric": ["!=", 100]} ]}`, + event: `{"price": 100}`, + wantErr: true, + }, + { + name: "numeric range - conflicting bounds", + patternName: "numeric range conflicting bounds", + pattern: `{"price": [ {"numeric": ["<", 50, ">", 100]} ]}`, + event: `{"price": 75}`, + wantErr: true, + }, + { + name: "numeric range - duplicate operators", + patternName: "numeric range duplicate operators", + pattern: `{"price": [ {"numeric": ["<", 100, "<", 50]} ]}`, + event: `{"price": 75}`, + wantErr: true, + }, + { + name: "numeric range - non-numeric value", + patternName: "numeric range non-numeric value", + pattern: `{"price": [ {"numeric": ["<", "abc", ">", 50]} ]}`, + event: `{"price": 75}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, err := New() + if err != nil { + t.Fatalf("failed to create Quamina: %v", err) + } + err = q.AddPattern(tt.patternName, tt.pattern) + if tt.wantErr { + if err == nil { + t.Error("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("failed to add pattern: %v", err) + } + + matches, err := q.MatchesForEvent([]byte(tt.event)) + if err != nil { + t.Fatalf("match failed: %v", err) + } + + matched := len(matches) > 0 + if matched != tt.want { + t.Errorf("match = %v, want %v", matched, tt.want) + } + }) + } +} diff --git a/range.go b/range.go new file mode 100644 index 0000000..d54e436 --- /dev/null +++ b/range.go @@ -0,0 +1,159 @@ +package quamina + +import ( + "bytes" + "fmt" + "math" +) + +// Range represents a continuous block of numeric values with defined boundaries. +// It supports both conventional numeric ranges and CIDR/IP matching. +type Range struct { + bottom qNumber // Lower boundary + top qNumber // Upper boundary + openBottom bool // If true, range does not include bottom value + openTop bool // If true, range does not include top value + isCIDR bool // If true, interpret values as hex (for IP addresses) +} + +// NewRange creates a new Range with the specified boundaries and options. +// The boundaries should be provided as string representations of numbers. +func NewRange(bottom string, openBottom bool, top string, openTop bool, isCIDR bool) (*Range, error) { + var bottomNum, topNum qNumber + var err error + + if bottom != "" { + if isCIDR { + // TODO: Implement CIDR-specific parsing + return nil, fmt.Errorf("CIDR ranges not yet implemented") + } else { + bottomNum, err = qNumFromBytes([]byte(bottom)) + if err != nil { + return nil, fmt.Errorf("invalid bottom boundary: %v", err) + } + } + } + + if top != "" { + if isCIDR { + // TODO: Implement CIDR-specific parsing + return nil, fmt.Errorf("CIDR ranges not yet implemented") + } else { + topNum, err = qNumFromBytes([]byte(top)) + if err != nil { + return nil, fmt.Errorf("invalid top boundary: %v", err) + } + } + } + + r := &Range{ + bottom: bottomNum, + top: topNum, + openBottom: openBottom, + openTop: openTop, + isCIDR: isCIDR, + } + + if err := r.validate(); err != nil { + return nil, err + } + + return r, nil +} + +// LessThan creates a Range that matches all values less than the given value +func LessThan(val string, isCIDR bool) (*Range, error) { + minBottom := float64(-math.MaxFloat64) + return NewRange(fmt.Sprintf("%f", minBottom), true, val, true, isCIDR) +} + +// LessThanOrEqualTo creates a Range that matches values less than or equal to the given value +func LessThanOrEqualTo(val string, isCIDR bool) (*Range, error) { + minBottom := float64(-math.MaxFloat64) + return NewRange(fmt.Sprintf("%f", minBottom), true, val, false, isCIDR) +} + +// GreaterThan creates a Range that matches values greater than the given value +func GreaterThan(val string, isCIDR bool) (*Range, error) { + maxTop := float64(math.MaxFloat64) + return NewRange(val, true, fmt.Sprintf("%f", maxTop), true, isCIDR) +} + +// GreaterThanOrEqualTo creates a Range that matches values greater than or equal to the given value +func GreaterThanOrEqualTo(val string, isCIDR bool) (*Range, error) { + maxTop := float64(math.MaxFloat64) + return NewRange(val, false, fmt.Sprintf("%f", maxTop), true, isCIDR) +} + +// Equals creates a Range that matches exactly the given value +func Equals(val string, isCIDR bool) (*Range, error) { + return NewRange(val, false, val, false, isCIDR) +} + +// Between creates a Range with explicitly defined boundaries +func Between(bottom string, openBottom bool, top string, openTop bool, isCIDR bool) (*Range, error) { + return NewRange(bottom, openBottom, top, openTop, isCIDR) +} + +// validate ensures the range boundaries are valid +func (r *Range) validate() error { + // If both bounds are empty, the range is invalid + if len(r.bottom) == 0 && len(r.top) == 0 { + return fmt.Errorf("invalid range: at least one boundary must be specified") + } + + // If both bounds are present, ensure bottom is less than top + if len(r.bottom) > 0 && len(r.top) > 0 { + if bytes.Compare(r.bottom, r.top) > 0 { + return fmt.Errorf("invalid range: bottom boundary must be less than top boundary") + } + } + + return nil +} + +// Contains checks if a value is within the range +func (r *Range) Contains(val qNumber) bool { + if len(r.bottom) > 0 { + cmp := bytes.Compare(val, r.bottom) + if cmp < 0 || (r.openBottom && cmp == 0) { + return false + } + } + + if len(r.top) > 0 { + cmp := bytes.Compare(val, r.top) + if cmp > 0 || (r.openTop && cmp == 0) { + return false + } + } + + return true +} + +// String returns a string representation of the range for debugging +func (r *Range) String() string { + var bounds []string + if len(r.bottom) > 0 { + bounds = append(bounds, fmt.Sprintf("%s%s", map[bool]string{true: "(", false: "["}[r.openBottom], bytesToqNum(r.bottom))) + } else { + bounds = append(bounds, "(-∞") + } + + if len(r.top) > 0 { + bounds = append(bounds, fmt.Sprintf("%s%s", bytesToqNum(r.top), map[bool]string{true: ")", false: "]"}[r.openTop])) + } else { + bounds = append(bounds, "+∞)") + } + + return fmt.Sprintf("%s, %s", bounds[0], bounds[1]) +} + +// bytesToqNum converts a byte slice to a float64 string +func bytesToqNum(b []byte) string { + if len(b) == 0 { + return "0" + } + val := qNumberToFloat64(b) + return fmt.Sprintf("%.1f", val) +} diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..8884db7 --- /dev/null +++ b/range_test.go @@ -0,0 +1,362 @@ +package quamina + +import ( + "strings" + "testing" +) + +func TestRangeCreation(t *testing.T) { + tests := []struct { + name string + bottom string + openBottom bool + top string + openTop bool + isCIDR bool + wantErr bool + errSubstr string + }{ + { + name: "valid range", + bottom: "1.0", + openBottom: false, + top: "2.0", + openTop: false, + wantErr: false, + }, + { + name: "invalid - bottom greater than top", + bottom: "2.0", + openBottom: false, + top: "1.0", + openTop: false, + wantErr: true, + errSubstr: "bottom boundary must be less than top boundary", + }, + { + name: "invalid - both bounds empty", + bottom: "", + openBottom: false, + top: "", + openTop: false, + wantErr: true, + errSubstr: "at least one boundary must be specified", + }, + { + name: "valid - only bottom bound", + bottom: "1.0", + openBottom: false, + top: "", + openTop: false, + wantErr: false, + }, + { + name: "valid - only top bound", + bottom: "", + openBottom: false, + top: "1.0", + openTop: false, + wantErr: false, + }, + { + name: "invalid - CIDR not implemented", + bottom: "192.168.0.0", + openBottom: false, + top: "192.168.255.255", + openTop: false, + isCIDR: true, + wantErr: true, + errSubstr: "CIDR ranges not yet implemented", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := NewRange(tt.bottom, tt.openBottom, tt.top, tt.openTop, tt.isCIDR) + if tt.wantErr { + if err == nil { + t.Error("expected error but got none") + } else if !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("expected error containing %q but got %q", tt.errSubstr, err.Error()) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r == nil { + t.Error("expected non-nil Range but got nil") + } + }) + } +} + +func TestRangeContains(t *testing.T) { + tests := []struct { + name string + bottom string + openBottom bool + top string + openTop bool + testValue string + want bool + }{ + { + name: "inclusive range - value in middle", + bottom: "1.0", + openBottom: false, + top: "3.0", + openTop: false, + testValue: "2.0", + want: true, + }, + { + name: "inclusive range - value at bottom", + bottom: "1.0", + openBottom: false, + top: "3.0", + openTop: false, + testValue: "1.0", + want: true, + }, + { + name: "exclusive bottom - value at bottom", + bottom: "1.0", + openBottom: true, + top: "3.0", + openTop: false, + testValue: "1.0", + want: false, + }, + { + name: "exclusive top - value at top", + bottom: "1.0", + openBottom: false, + top: "3.0", + openTop: true, + testValue: "3.0", + want: false, + }, + { + name: "value below range", + bottom: "1.0", + openBottom: false, + top: "3.0", + openTop: false, + testValue: "0.5", + want: false, + }, + { + name: "value above range", + bottom: "1.0", + openBottom: false, + top: "3.0", + openTop: false, + testValue: "3.5", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := NewRange(tt.bottom, tt.openBottom, tt.top, tt.openTop, false) + if err != nil { + t.Fatalf("failed to create range: %v", err) + } + + testNum, err := qNumFromBytes([]byte(tt.testValue)) + if err != nil { + t.Fatalf("failed to create test number: %v", err) + } + + got := r.Contains(testNum) + if got != tt.want { + t.Errorf("Contains(%s) = %v, want %v", tt.testValue, got, tt.want) + } + }) + } +} + +func TestRangeFactoryMethods(t *testing.T) { + tests := []struct { + name string + setup func() (*Range, error) + testValue string + want bool + }{ + { + name: "less than", + setup: func() (*Range, error) { + return LessThan("10.0", false) + }, + testValue: "9.0", + want: true, + }, + { + name: "less than - equal value", + setup: func() (*Range, error) { + return LessThan("10.0", false) + }, + testValue: "10.0", + want: false, + }, + { + name: "less than or equal", + setup: func() (*Range, error) { + return LessThanOrEqualTo("10.0", false) + }, + testValue: "10.0", + want: true, + }, + { + name: "greater than", + setup: func() (*Range, error) { + return GreaterThan("10.0", false) + }, + testValue: "11.0", + want: true, + }, + { + name: "greater than - equal value", + setup: func() (*Range, error) { + return GreaterThan("10.0", false) + }, + testValue: "10.0", + want: false, + }, + { + name: "greater than or equal", + setup: func() (*Range, error) { + return GreaterThanOrEqualTo("10.0", false) + }, + testValue: "10.0", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := tt.setup() + if err != nil { + t.Fatalf("failed to create range: %v", err) + } + + testNum, err := qNumFromBytes([]byte(tt.testValue)) + if err != nil { + t.Fatalf("failed to create test number: %v", err) + } + + got := r.Contains(testNum) + if got != tt.want { + t.Errorf("Contains(%s) = %v, want %v", tt.testValue, got, tt.want) + } + }) + } +} + +func TestRangeString(t *testing.T) { + tests := []struct { + name string + bottom string + openBottom bool + top string + openTop bool + want string + }{ + { + name: "closed range", + bottom: "1.0", + openBottom: false, + top: "2.0", + openTop: false, + want: "[1.0, 2.0]", + }, + { + name: "open range", + bottom: "1.0", + openBottom: true, + top: "2.0", + openTop: true, + want: "(1.0, 2.0)", + }, + { + name: "half-open range", + bottom: "1.0", + openBottom: false, + top: "2.0", + openTop: true, + want: "[1.0, 2.0)", + }, + { + name: "unbounded below", + bottom: "", + openBottom: true, + top: "2.0", + openTop: false, + want: "(-∞, 2.0]", + }, + { + name: "unbounded above", + bottom: "1.0", + openBottom: false, + top: "", + openTop: true, + want: "[1.0, +∞)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := NewRange(tt.bottom, tt.openBottom, tt.top, tt.openTop, false) + if err != nil { + t.Fatalf("failed to create range: %v", err) + } + + got := r.String() + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRange_Contains(t *testing.T) { + tests := []struct { + name string + r *Range + val float64 + want bool + }{ + { + name: "equals match", + r: &Range{ + bottom: qNumFromFloat(100), + top: qNumFromFloat(100), + openBottom: false, + openTop: false, + }, + val: 100, + want: true, + }, + { + name: "equals no match", + r: &Range{ + bottom: qNumFromFloat(100), + top: qNumFromFloat(100), + openBottom: false, + openTop: false, + }, + val: 99.9, + want: false, + }, + { + name: "less than match", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Implementation of the test case + }) + } +} diff --git a/value_matcher.go b/value_matcher.go index ed36570..81d4a65 100644 --- a/value_matcher.go +++ b/value_matcher.go @@ -75,11 +75,29 @@ func (m *valueMatcher) transitionOn(eventField *Field, bufs *bufpair) []*fieldMa if vmFields.hasNumbers && eventField.IsNumber { qNum, err := qNumFromBytes(val) if err == nil { + // Get all possible transitions first if vmFields.isNondeterministic { - return traverseNFA(vmFields.startTable, qNum, transitions, bufs) + transitions = traverseNFA(vmFields.startTable, qNum, transitions, bufs) } else { - return traverseDFA(vmFields.startTable, qNum, transitions) + transitions = traverseDFA(vmFields.startTable, qNum, transitions) } + + // For numeric fields, we need to check both the transitions and their vals + var out stepOut + vmFields.startTable.step(valueTerminator, &out) + if len(out.steps) > 0 { + for _, state := range out.steps { + for _, fm := range state.fieldTransitions { + for _, v := range fm.vals { + if v.vType == numericRangeType && v.numericRange.Contains(qNum) { + transitions = append(transitions, fm) + break + } + } + } + } + } + return transitions } } @@ -140,6 +158,25 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche case regexpType: newFA, nextField = makeRegexpNFA(val.parsedRegexp, true) printer.labelTable(newFA, "RX start") + case numericRangeType: + nextField = newFieldMatcher() + fields.hasNumbers = true + // Create a simple FA that matches any number + lastStep := &faState{ + table: newSmallTable(), + fieldTransitions: []*fieldMatcher{nextField}, + } + lastStepList := &faNext{states: []*faState{lastStep}} + newFA = makeSmallTable(nil, []byte{valueTerminator}, []*faNext{lastStepList}) + + // Merge with existing FA or set as initial FA + if fields.startTable != nil { + fields.startTable = mergeFAs(fields.startTable, newFA, printer) + } else { + fields.startTable = newFA + } + m.update(fields) + return nextField default: panic("unknown value type") }