Skip to content

Commit 79f1d5c

Browse files
committed
add index function to stdlib
`index(container, key)` index is used to index into a map via a string key or an array via an integer index. e.g. `index(map, "key")` map: `map[string]any{"key": "value"}` -> `"value"` `index(["first", "second"], 1)` -> `"second"`
1 parent 482155c commit 79f1d5c

File tree

3 files changed

+139
-35
lines changed

3 files changed

+139
-35
lines changed

README.md

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ domain matches /example\.com$/
2121
```
2222

2323
When evaluated against:
24+
2425
- `map[string]any{"domain": "example.com"}` → returns **true**
2526
- `map[string]any{"domain": "qpoint.io"}` → returns **false**
2627

@@ -44,13 +45,13 @@ import "github.com/qpoint-io/rulekit"
4445

4546
r, err := rule.Parse(`domain matches /example\.com$/ and port == 8080`)
4647
if err != nil { /* ... */ }
47-
48+
4849
// define input data
4950
input := rulekit.KV{
5051
"domain": "example.com",
5152
"port": 8080,
5253
}
53-
54+
5455
// evaluate the rule
5556
result := r.Eval(&rulekit.Ctx{KV: inputData})
5657

@@ -75,49 +76,50 @@ When a rule is evaluated, it returns a `Result` struct containing:
7576
- `EvaluatedRule`: The sub-rule that determined the returned value. Useful for debugging and understanding which part of a complex rule caused the result.
7677

7778
The Result also provides additional helper methods:
79+
7880
- `Pass()`: Returns true if the rule returns true/a non-zero value with no errors
7981
- `Fail()`: Returns true if the rule returns false/a zero value with no errors
8082
- `Ok()`: Returns true if the rule executed with no error
8183

8284
## Supported Operators
8385

84-
| Operator | Alias | Description |
85-
|----------|--------------|-------------|
86-
| `or` | `\|\|` | Logical OR |
87-
| `and` | `&&` | Logical AND |
88-
| `not` | `!` | Logical NOT |
89-
| `()` | | Parentheses for grouping |
90-
| `==` | `eq` | Equal to |
91-
| `!=` | `ne` | Not equal to |
92-
| `>` | `gt` | Greater than |
93-
| `>=` | `ge` | Greater than or equal to |
94-
| `<` | `lt` | Less than |
95-
| `<=` | `le` | Less than or equal to |
96-
| `contains` | | Check if a value contains another value |
97-
| `in` | | Check if a value is contained within an array or an IP within a CIDR |
98-
| `matches` | | Match against a regular expression |
86+
| Operator | Alias | Description |
87+
| ---------- | ------ | -------------------------------------------------------------------- |
88+
| `or` | `\|\|` | Logical OR |
89+
| `and` | `&&` | Logical AND |
90+
| `not` | `!` | Logical NOT |
91+
| `()` | | Parentheses for grouping |
92+
| `==` | `eq` | Equal to |
93+
| `!=` | `ne` | Not equal to |
94+
| `>` | `gt` | Greater than |
95+
| `>=` | `ge` | Greater than or equal to |
96+
| `<` | `lt` | Less than |
97+
| `<=` | `le` | Less than or equal to |
98+
| `contains` | | Check if a value contains another value |
99+
| `in` | | Check if a value is contained within an array or an IP within a CIDR |
100+
| `matches` | | Match against a regular expression |
99101

100102
## Supported Types
101103

102104
### Basic values
103105

104-
| Type | Used As | Example | Description |
105-
|------|---------|---------|-------------|
106-
| **bool** | VALUE, FIELD | `true` | Valid values: `true`, `false` |
107-
| **number** | VALUE, FIELD | `8080` | Integer or float. Parsed as either int64 or uint64 if out of range for int64, or float64 if float. |
108-
| **string** | VALUE, FIELD | `"domain.com"` | A double-quoted string. Quotes may be escaped with a backslash: `"a string \"with\" quotes"`. Any quoted value is parsed as a string. |
109-
| **IP address** | VALUE, FIELD | `192.168.1.1`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff` | An IPv4, IPv6, or an IPv6 dual address. Maps to Go type: `net.IP` |
110-
| **CIDR** | VALUE | `192.168.1.0/24`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff/64` | An IPv4 or IPv6 CIDR block. Maps to Go type: `*net.IPNet` |
111-
| **Hexadecimal string** | VALUE, FIELD | `12:34:56:78:ab` (MAC address), `504f5354` (hex string "POST") | A hexadecimal string, optionally separated by colons. |
112-
| **Regex** | VALUE | `/example\.com$/` | A Go-style regular expression. Must be surrounded by forward slashes. May not be quoted with double quotes (otherwise it will be parsed as a string). Maps to Go type: `*regexp.Regexp` |
106+
| Type | Used As | Example | Description |
107+
| ---------------------- | ------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108+
| **bool** | VALUE, FIELD | `true` | Valid values: `true`, `false` |
109+
| **number** | VALUE, FIELD | `8080` | Integer or float. Parsed as either int64 or uint64 if out of range for int64, or float64 if float. |
110+
| **string** | VALUE, FIELD | `"domain.com"` | A double-quoted string. Quotes may be escaped with a backslash: `"a string \"with\" quotes"`. Any quoted value is parsed as a string. |
111+
| **IP address** | VALUE, FIELD | `192.168.1.1`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff` | An IPv4, IPv6, or an IPv6 dual address. Maps to Go type: `net.IP` |
112+
| **CIDR** | VALUE | `192.168.1.0/24`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff/64` | An IPv4 or IPv6 CIDR block. Maps to Go type: `*net.IPNet` |
113+
| **Hexadecimal string** | VALUE, FIELD | `12:34:56:78:ab` (MAC address), `504f5354` (hex string "POST") | A hexadecimal string, optionally separated by colons. |
114+
| **Regex** | VALUE | `/example\.com$/` | A Go-style regular expression. Must be surrounded by forward slashes. May not be quoted with double quotes (otherwise it will be parsed as a string). Maps to Go type: `*regexp.Regexp` |
113115

114116
### Constructs
115117

116-
| Type | Used As | Example | Description |
117-
|------|---------|---------|-------------|
118-
| **Array** | VALUE | `[1, "string", true]` | An array of mixed value types. Can be used with most operators including `in` and `contains`. |
119-
| **Function** | VALUE | `starts_with(url, "https://")` | A function call with optional arguments. Can be built-in or custom. |
120-
| **Macro** | VALUE | `isValidRequest()` | A zero-argument function that encapsulates a predefined rule. |
118+
| Type | Used As | Example | Description |
119+
| ------------ | ------- | ------------------------------ | --------------------------------------------------------------------------------------------- |
120+
| **Array** | VALUE | `[1, "string", true]` | An array of mixed value types. Can be used with most operators including `in` and `contains`. |
121+
| **Function** | VALUE | `starts_with(url, "https://")` | A function call with optional arguments. Can be built-in or custom. |
122+
| **Macro** | VALUE | `isValidRequest()` | A zero-argument function that encapsulates a predefined rule. |
121123

122124
## Macros
123125

@@ -152,9 +154,10 @@ Functions can be called inside rules and used as value objects. Functions may ac
152154

153155
Rulekit comes with a built-in standard library of functions:
154156

155-
| Function | Description | Example |
156-
|----------|-------------|---------|
157-
| `starts_with(value, prefix)` | Checks if a value starts with the given prefix. Works with strings, numbers, and other types by converting them to strings. | `starts_with(url, "https://")` |
157+
| Function | Description | Example |
158+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
159+
| `starts_with(value, prefix)` | Checks if a value starts with the given prefix. Works with strings, numbers, and other types by converting them to strings. | `starts_with(url, "https://")` |
160+
| `index(container, key)` | Indexes into a map or slice | `index(["one", "two"], 0)` -> `"one"` |
158161

159162
### Custom Functions
160163

@@ -211,4 +214,4 @@ if rule.Pass() {
211214
<source media="(prefers-color-scheme: dark)" srcset="./readme_assets/qpoint-open.svg">
212215
<source media="(prefers-color-scheme: light)" srcset="./readme_assets/qpoint-open-light.svg">
213216
<img alt="Image showing \"Qpoint ❤ OpenSource\"" src="./readme_assets/qpoint-open-light.svg">
214-
</picture>
217+
</picture>

functions_stdlib.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,56 @@ var StdlibFuncs = map[string]*Function{
2626
}
2727
},
2828
},
29+
30+
// index(container, key)
31+
//
32+
// index is used to index into a map via a string key or an array via an integer index.
33+
//
34+
// e.g. index(map, "key")
35+
// map: map[string]any{"key": "value"} -> "value"
36+
//
37+
// index(["first", "second"], 1) -> "second"
38+
"index": {
39+
Args: []FunctionArg{
40+
{Name: "container"},
41+
{Name: "key"},
42+
},
43+
Eval: func(args map[string]any) Result {
44+
container, err := IndexFuncArg[any](args, "container")
45+
if err != nil {
46+
return Result{Error: err}
47+
}
48+
49+
switch c := container.(type) {
50+
case KV:
51+
key, err := IndexFuncArg[string](args, "key")
52+
if err != nil {
53+
return Result{Error: err}
54+
}
55+
56+
val, ok := IndexKV(c, key)
57+
if !ok {
58+
return Result{Error: fmt.Errorf("key %q not found", key)}
59+
}
60+
61+
return Result{Value: val}
62+
63+
case []any:
64+
key, err := IndexFuncArg[int64](args, "key")
65+
if err != nil {
66+
return Result{Error: err}
67+
}
68+
69+
if key < 0 || int(key) >= len(c) {
70+
return Result{Error: fmt.Errorf("index %d out of bounds", key)}
71+
}
72+
73+
return Result{
74+
Value: c[key],
75+
}
76+
}
77+
78+
return Result{Error: fmt.Errorf("container must be a map or array")}
79+
},
80+
},
2981
}

functions_stdlib_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,52 @@ starts_with(arg1)
3939
^
4040
function "starts_with" expects 2 arguments, got 1`)
4141
}
42+
43+
func TestFn_Index(t *testing.T) {
44+
// happy path - map
45+
assertRulep(t,
46+
`index(map, "key")`,
47+
kv{"map": KV{"key": "value"}},
48+
).Value("value")
49+
50+
// happy path - array
51+
assertRulep(t, `index([1, 2, 3], 0)`, nil).Value(int64(1))
52+
53+
// happy path - nested map
54+
assertRulep(t,
55+
`index(map, "key.nested")`,
56+
kv{"map": KV{"key": KV{"nested": "value"}}},
57+
).Value("value")
58+
assertRulep(t,
59+
`index(index(map, "key"), "nested")`,
60+
kv{"map": KV{"key": KV{"nested": "value"}}},
61+
).Value("value")
62+
63+
// int key with map
64+
assertRulep(t,
65+
`index(map, 123)`,
66+
kv{"map": KV{"key": "value"}},
67+
).ErrorString(`arg key: expected string, got int64`)
68+
69+
// string key with array
70+
assertRulep(t,
71+
`index([1, 2, 3], "test")`,
72+
kv{"map": []any{1, 2, 3}},
73+
).ErrorString(`arg key: expected int64, got string`)
74+
75+
// out of bounds key with array
76+
assertRulep(t,
77+
`index([1, 2, 3], 10)`,
78+
kv{"map": []any{1, 2, 3}},
79+
).ErrorString(`index 10 out of bounds`)
80+
assertRulep(t,
81+
`index([1, 2, 3], -3)`,
82+
kv{"map": []any{1, 2, 3}},
83+
).ErrorString(`index -3 out of bounds`)
84+
85+
// invalid container type
86+
assertRulep(t,
87+
`index(map, "test")`,
88+
kv{"map": 123},
89+
).ErrorString(`container must be a map or array`)
90+
}

0 commit comments

Comments
 (0)