Skip to content

Commit 083b42a

Browse files
committed
feat(eval): iterate object keys / values via ref source
Extends iterItems to handle JSON objects. New optional 'kind' field on some / every (default 'keys') selects between iterating member keys (yielded as Value.string) and member values. Implementation uses an ItemIter union so we never allocate a projected slice. This avoids threading an allocator through every helper. Deferred to follow-up PRs: - 'kind: pairs' yielding [key, value] arrays needs an arena allocator handle in iterItems; add when the first allocating builtin (lower / upper) lands. - 'in' shorthand desugar; pure parser-side change, no eval impact. Release build grows by ~3KB (50K -> 53K).
1 parent d30243d commit 083b42a

2 files changed

Lines changed: 118 additions & 16 deletions

File tree

src/ast.zig

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,28 @@ pub const Expr = union(enum) {
4848
right: *const Expr,
4949
};
5050

51-
/// Iteration over an array or set. The evaluator binds each
52-
/// element under `var_name` and runs `body`. `some` and `every`
53-
/// share this shape; only the body combination differs.
51+
/// Iteration over an array, set, or object. The evaluator binds
52+
/// each element under `var_name` and runs `body`. `some` and
53+
/// `every` share this shape; only the body combination differs.
54+
///
55+
/// `kind` only matters when the source resolves to a JSON object;
56+
/// for arrays and sets it is ignored.
5457
pub const Iter = struct {
5558
var_name: []const u8,
5659
source: *const Expr,
5760
body: *const Expr,
61+
kind: IterKind = .keys,
62+
};
63+
64+
pub const IterKind = enum {
65+
keys,
66+
values,
67+
68+
fn fromString(s: []const u8) ?IterKind {
69+
if (std.mem.eql(u8, s, "keys")) return .keys;
70+
if (std.mem.eql(u8, s, "values")) return .values;
71+
return null;
72+
}
5873
};
5974

6075
/// Call to a builtin function. The evaluator looks `name` up in
@@ -200,10 +215,15 @@ pub fn buildExpr(allocator: std.mem.Allocator, node: Value) !*Expr {
200215
const var_name = try requireString(obj, "var");
201216
const source_v = try requireField(obj, "source");
202217
const body_v = try requireField(obj, "body");
218+
const kind: Expr.IterKind = if (json.lookupMember(obj, "kind")) |k_v| blk: {
219+
if (k_v != .string) return error.InvalidKind;
220+
break :blk Expr.IterKind.fromString(k_v.string) orelse return error.UnknownKind;
221+
} else .keys;
203222
const iter = Expr.Iter{
204223
.var_name = var_name,
205224
.source = try buildExpr(allocator, source_v),
206225
.body = try buildExpr(allocator, body_v),
226+
.kind = kind,
207227
};
208228
expr.* = if (std.mem.eql(u8, t, "some"))
209229
.{ .some = iter }

src/eval.zig

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,52 +165,89 @@ fn evalCompare(
165165
};
166166
}
167167

168+
/// Iterator handle returned by `iterItems`. Avoids allocating a
169+
/// projected `Value` slice for object iteration; the consumer reads
170+
/// elements through `len()` and `at()`.
171+
const ItemIter = union(enum) {
172+
none,
173+
flat: []const json.Value,
174+
object_keys: []const json.Value.Member,
175+
object_values: []const json.Value.Member,
176+
177+
fn len(self: ItemIter) usize {
178+
return switch (self) {
179+
.none => 0,
180+
.flat => |xs| xs.len,
181+
.object_keys, .object_values => |members| members.len,
182+
};
183+
}
184+
185+
fn at(self: ItemIter, i: usize) json.Value {
186+
return switch (self) {
187+
.none => unreachable,
188+
.flat => |xs| xs[i],
189+
.object_keys => |members| .{ .string = members[i].key },
190+
.object_values => |members| members[i].value,
191+
};
192+
}
193+
};
194+
168195
/// `some x in source: body`. True if the body holds for at least
169-
/// one binding.
196+
/// one binding. A non-iterable source yields `false`.
170197
fn evalSome(
171198
it: ast.Expr.Iter,
172199
input: json.Value,
173200
scope: ?*const Scope,
174201
depth: u32,
175202
) HelperError!bool {
176203
if (depth >= max_eval_depth) return error.EvalTooDeep;
177-
const items = try iterItems(it.source, input, scope, depth + 1) orelse return false;
178-
for (items) |item| {
179-
const child = Scope{ .parent = scope, .name = it.var_name, .bound = item };
204+
const items = try iterItems(it.source, it.kind, input, scope, depth + 1);
205+
if (items == .none) return false;
206+
var i: usize = 0;
207+
while (i < items.len()) : (i += 1) {
208+
const child = Scope{ .parent = scope, .name = it.var_name, .bound = items.at(i) };
180209
if (try evalExprBool(it.body, input, &child, depth + 1)) return true;
181210
}
182211
return false;
183212
}
184213

185-
/// `every x in source: body`. Vacuously true on an empty source.
214+
/// `every x in source: body`. Vacuously true on an empty or
215+
/// non-iterable source.
186216
fn evalEvery(
187217
it: ast.Expr.Iter,
188218
input: json.Value,
189219
scope: ?*const Scope,
190220
depth: u32,
191221
) HelperError!bool {
192222
if (depth >= max_eval_depth) return error.EvalTooDeep;
193-
const items = try iterItems(it.source, input, scope, depth + 1) orelse return true;
194-
for (items) |item| {
195-
const child = Scope{ .parent = scope, .name = it.var_name, .bound = item };
223+
const items = try iterItems(it.source, it.kind, input, scope, depth + 1);
224+
if (items == .none) return true;
225+
var i: usize = 0;
226+
while (i < items.len()) : (i += 1) {
227+
const child = Scope{ .parent = scope, .name = it.var_name, .bound = items.at(i) };
196228
if (!try evalExprBool(it.body, input, &child, depth + 1)) return false;
197229
}
198230
return true;
199231
}
200232

201-
/// Pull iterable items out of `source`. Returns `null` for
233+
/// Resolve `source` to an iterable view. Returns `.none` for
202234
/// non-iterable values; the caller decides the default.
203235
fn iterItems(
204236
source: *const ast.Expr,
237+
kind: ast.Expr.IterKind,
205238
input: json.Value,
206239
scope: ?*const Scope,
207240
depth: u32,
208-
) HelperError!?[]const json.Value {
241+
) HelperError!ItemIter {
209242
const v = try resolveValue(source, input, scope, depth);
210243
return switch (v) {
211-
.array => |xs| xs,
212-
.set => |xs| xs,
213-
else => null,
244+
.array => |xs| .{ .flat = xs },
245+
.set => |xs| .{ .flat = xs },
246+
.object => |members| switch (kind) {
247+
.keys => .{ .object_keys = members },
248+
.values => .{ .object_values = members },
249+
},
250+
else => .none,
214251
};
215252
}
216253

@@ -370,6 +407,51 @@ test "evaluate: unknown builtin denies" {
370407
try testing.expect(!(try run("{}", policy)));
371408
}
372409

410+
test "evaluate: every over object keys" {
411+
const policy =
412+
"{\"type\":\"every\",\"var\":\"k\",\"kind\":\"keys\"," ++
413+
"\"source\":{\"type\":\"ref\",\"path\":[\"input\",\"attrs\"]}," ++
414+
"\"body\":{\"type\":\"neq\"," ++
415+
"\"left\":{\"type\":\"ref\",\"path\":[\"k\"]}," ++
416+
"\"right\":{\"type\":\"value\",\"value\":\"internal\"}}}";
417+
try testing.expect(try run(
418+
"{\"attrs\":{\"team\":\"sre\",\"region\":\"us-east\"}}",
419+
policy,
420+
));
421+
try testing.expect(!(try run(
422+
"{\"attrs\":{\"team\":\"sre\",\"internal\":\"yes\"}}",
423+
policy,
424+
)));
425+
}
426+
427+
test "evaluate: some over object values" {
428+
const policy =
429+
"{\"type\":\"some\",\"var\":\"v\",\"kind\":\"values\"," ++
430+
"\"source\":{\"type\":\"ref\",\"path\":[\"input\",\"flags\"]}," ++
431+
"\"body\":{\"type\":\"eq\"," ++
432+
"\"left\":{\"type\":\"ref\",\"path\":[\"v\"]}," ++
433+
"\"right\":{\"type\":\"value\",\"value\":true}}}";
434+
try testing.expect(try run(
435+
"{\"flags\":{\"a\":false,\"b\":true,\"c\":false}}",
436+
policy,
437+
));
438+
try testing.expect(!(try run(
439+
"{\"flags\":{\"a\":false,\"b\":false}}",
440+
policy,
441+
)));
442+
}
443+
444+
test "evaluate: every over object defaults to keys" {
445+
const policy =
446+
"{\"type\":\"every\",\"var\":\"k\"," ++
447+
"\"source\":{\"type\":\"ref\",\"path\":[\"input\",\"m\"]}," ++
448+
"\"body\":{\"type\":\"neq\"," ++
449+
"\"left\":{\"type\":\"ref\",\"path\":[\"k\"]}," ++
450+
"\"right\":{\"type\":\"value\",\"value\":\"banned\"}}}";
451+
try testing.expect(try run("{\"m\":{\"x\":1,\"y\":2}}", policy));
452+
try testing.expect(!(try run("{\"m\":{\"x\":1,\"banned\":2}}", policy)));
453+
}
454+
373455
test "evaluate: every+some over arrays" {
374456
const policy =
375457
"{\"type\":\"every\",\"var\":\"req\"," ++

0 commit comments

Comments
 (0)