// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Query strings, written from structs. //! //! Paperless filters its lists with django-filter, whose parameters are //! named for a field and a lookup joined by double underscores -- //! `title__icontains`, `tags__id__all`. The query structs in this library //! use those names as their field names, so that the Paperless //! documentation, the browsable API and this library all spell a filter the //! same way, and `writeStruct` turns one into a query string without a table //! in between. //! //! A field left `null` is not sent. A list of identifiers is sent as one //! comma-separated value, which is what django-filter's `__in`, `__all` and //! `__none` lookups read. Anything the struct does not name goes in its //! `extra`, as name and value pairs. const std = @import("std"); const Io = std.Io; const testing = std.testing; const uri = @import("uri"); pub const Param = struct { name: []const u8, value: []const u8, }; /// RFC 3986's unreserved characters and nothing else. Narrower than zig-uri's /// `query_value`, which leaves the sub-delimiters alone: Django reads a query /// string as HTML form data, where `+` is a space, so a `+` that is meant to /// be a plus has to be sent as `%2B`. const safe = uri.percent.charSet(.{ .add = uri.percent.unreserved_punct }); /// Writes one `name=value`, preceded by `?` if it is the first and `&` if it /// is not. pub fn writeParam(w: *Io.Writer, first: *bool, name: []const u8, value: []const u8) Io.Writer.Error!void { try w.writeByte(if (first.*) '?' else '&'); first.* = false; try uri.percent.encode(w, name, &safe); try w.writeByte('='); try uri.percent.encode(w, value, &safe); } pub fn writeParams(w: *Io.Writer, first: *bool, params: []const Param) Io.Writer.Error!void { for (params) |param| try writeParam(w, first, param.name, param.value); } /// Writes every non-null field of `value` as a parameter named for it. /// /// What each type becomes: an integer in decimal; a `bool` as `true` or /// `false`, which django-filter's boolean filter reads; a string as it is; /// an enum as its tag name; a slice of integers as one comma-separated /// value. A field named `extra` of type `[]const Param` is written as the /// parameters it holds. pub fn writeStruct(w: *Io.Writer, first: *bool, value: anytype) Io.Writer.Error!void { const T = @TypeOf(value); const info = @typeInfo(T).@"struct"; inline for (info.field_names, info.field_types) |name, FieldType| { const field = @field(value, name); if (comptime std.mem.eql(u8, name, "extra")) { try writeParams(w, first, field); } else if (@typeInfo(FieldType) == .optional) { if (field) |present| try writeValue(w, first, name, present); } else { try writeValue(w, first, name, field); } } } fn writeValue(w: *Io.Writer, first: *bool, name: []const u8, value: anytype) Io.Writer.Error!void { const T = @TypeOf(value); switch (@typeInfo(T)) { .int, .comptime_int => { var buffer: [24]u8 = undefined; try writeParam(w, first, name, std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable); }, .bool => try writeParam(w, first, name, if (value) "true" else "false"), .@"enum" => try writeParam(w, first, name, @tagName(value)), .pointer => |pointer| { if (pointer.child == u8) return writeParam(w, first, name, value); // A list of integers, as one comma-separated value. try w.writeByte(if (first.*) '?' else '&'); first.* = false; try uri.percent.encode(w, name, &safe); try w.writeByte('='); for (value, 0..) |item, i| { if (i > 0) try w.writeAll("%2C"); try w.print("{d}", .{item}); } }, else => @compileError("no query string form for " ++ @typeName(T)), } } test writeStruct { const Query = struct { page: ?u32 = null, title__icontains: ?[]const u8 = null, tags__id__all: ?[]const u64 = null, is_tagged: ?bool = null, ordering: ?[]const u8 = null, extra: []const Param = &.{}, }; var buffer: [256]u8 = undefined; var w: Io.Writer = .fixed(&buffer); var first = true; try writeStruct(&w, &first, Query{ .page = 2, .title__icontains = "a+b & c", .tags__id__all = &.{ 1, 22 }, .is_tagged = false, .extra = &.{.{ .name = "custom_field_query", .value = "[\"x\"]" }}, }); try testing.expectEqualStrings( "?page=2&title__icontains=a%2Bb%20%26%20c&tags__id__all=1%2C22&is_tagged=false" ++ "&custom_field_query=%5B%22x%22%5D", w.buffered(), ); } test "nothing set writes nothing" { var buffer: [16]u8 = undefined; var w: Io.Writer = .fixed(&buffer); var first = true; try writeStruct(&w, &first, struct { page: ?u32 = null }{}); try testing.expectEqualStrings("", w.buffered()); try testing.expect(first); }