This code does not handle the situation where @“and” is used inside the @“and” slice:
const breaks = Filter{
.@"and" = &.{
.{ .prefix = "prefix" },
.{ .@"and" = &.{ .{ .tag = "tag" }, .{ .object_size_less_than = 15 } } },
},
};
try breaks.jsonStringify(jws); // Silently ignores .tag and .object_size_less_than
Possible solutions:
1. Encode the structure in the type system
This introduces complexity and makes the memory layout worse (two discriminated unions):
const SingleFilter = union(enum) {
prefix: []const u8,
tag: []const u8,
object_size_greater_than: usize,
object_size_less_than: usize,
};
const Filter = union(enum) {
single: SingleFilter,
@"and": []const SingleFilter,
};
2. Encode the and as the base state of the filter:
This is I think the correct approach because it removes complexity, and a single filter is just an @“and” filter of length one. This does not then work with @“or” if you want to introduce that later.
const SingleFilter = union(enum) {
prefix: []const u8,
tag: []const u8,
object_size_greater_than: usize,
object_size_less_than: usize,
};
const Filter = struct {
items: []const SingleFilter,
};
const single_filter = Filter{ .items = &.{ .{ .prefix = "prefix" } }};
3. Make the recursion top-level:
To allow for nested and structures (that your type allows), you can just remove the helper.
pub fn jsonStringify(self: *const Filter, jws: anytype) !void {
try jws.beginObject();
switch (self.*) {
.prefix => |val| {
try jws.objectField("Prefix");
try jws.write(val);
},
.tag => |val| {
try jws.objectField("Tag");
try jws.write(val);
},
.object_size_greater_than => |int| {
try jws.objectField("ObjectSizeGreaterThan");
try jws.write(int);
},
.object_size_less_than => |int| {
try jws.objectField("ObjectSizeLessThan");
try jws.write(int);
},
.@"and" => |val| {
try jws.objectField("And");
try jws.beginArray();
for (val) |filter| {
try filter.printStringify(jws); // Now the nested .@"and" structure gets serialized as a nested and structure.
}
try jws.endArray();
},
else => try self.printJsonField(jws),
}
try jws.endObject();
}
4. Disallow nested .@“and” structures, and introduce type invariance:
- The else clause should not be
{} but unreachable:
fn printJsonField(self: Filter, jws: anytype) !void {
switch (self) {
.prefix => |val| {
try jws.objectField("Prefix");
try jws.write(val);
},
.tag => |val| {
try jws.objectField("Tag");
try jws.write(val);
},
.object_size_greater_than => |int| {
try jws.objectField("ObjectSizeGreaterThan");
try jws.write(int);
},
.object_size_less_than => |int| {
try jws.objectField("ObjectSizeLessThan");
try jws.write(int);
},
else => unreachable, // or return error.NestedAndFilter
}
}
- Introduce
assert() to enforce the type invariance:
const std = @import("std");
const assert = std.debug.assert;
const Filter = union(enum) {
// ... fields as before
pub fn jsonStringify(self: *const Filter, jws: anytype) !void {
try jws.beginObject();
switch (self.*) {
.@"and" => |val| {
try jws.objectField("Filter");
try jws.beginObject();
for (val) |filter| {
assert(filter != .@"and"); // Make sure the type holds invariance
try filter.printJsonField(jws);
}
try jws.endObject();
},
else => try self.printJsonField(jws),
}
try jws.endObject();
}
};
You would pair the assert() with unreachable, (for type invariance), or return error if you want the Filter state to be a valid state handled as a runtime error if you try to serialize it.
You would also put these asserts in other methods your type provides (where applicable). It’s a bit less robust but it is the minimum you need to change if you want to keep your mental model and layout.
If you only ever need @“and”, I would go with option 2. If you plan to add @“or”, I would go with option 4. If you want to allow nested @“and” structures, option 3 is the only solution for the serialization.