activity: select-only multi-client filter
the freetype client field is gone. the picker is a select-only menu of known clients with multi-select, chips for the active set, and a 32-client cap shared with the server. the api accepts a comma-separated client list and filters any-of with bound parameters. the trigger carets come from phosphor icons, newly adopted. bundle budget rises to 900000 bytes for the picker and the icon dependency.
This commit is contained in:
@@ -674,7 +674,10 @@ pub const QueryFilter = struct {
|
||||
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
|
||||
/// does and what a domain search wants.
|
||||
domain_substring: ?[]const u8 = null,
|
||||
client: ?[]const u8 = null,
|
||||
/// Exact client addresses, matched any-of. Empty means no client filter;
|
||||
/// one address is the common case and reads the same as the old single
|
||||
/// filter did. Never more than `max_clients`.
|
||||
clients: []const []const u8 = &.{},
|
||||
blocked: ?bool = null,
|
||||
since: ?i64 = null,
|
||||
until: ?i64 = null,
|
||||
@@ -684,6 +687,12 @@ pub const QueryFilter = struct {
|
||||
/// that forgets cannot ask this connection for the whole table.
|
||||
pub const max_limit: u32 = 1000;
|
||||
|
||||
/// How many addresses one client filter may name. The statement is assembled
|
||||
/// into a fixed buffer, so this is a hard bound rather than a preference: a
|
||||
/// household picking more than this from a list is not a case worth widening
|
||||
/// the buffer for.
|
||||
pub const max_clients: usize = 32;
|
||||
|
||||
const select_head =
|
||||
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
||||
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
|
||||
@@ -697,7 +706,10 @@ const like_escape = '\\';
|
||||
|
||||
const where_before = " q.id < ?";
|
||||
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
|
||||
const where_client = " q.client_ip = ?";
|
||||
const where_client_head = " q.client_ip IN (";
|
||||
const where_client_tail = ")";
|
||||
/// `?` per address with a comma between, at the widest the cap allows.
|
||||
const where_client_max = where_client_head.len + 2 * max_clients + where_client_tail.len;
|
||||
const where_blocked = " q.blocked = ?";
|
||||
const where_since = " q.timestamp >= ?";
|
||||
const where_until = " q.timestamp < ?";
|
||||
@@ -716,7 +728,7 @@ const Sql = struct {
|
||||
/// `where_keyword` is longer than `and_keyword` and is used at most once,
|
||||
/// so counting six of it bounds every reachable combination.
|
||||
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
|
||||
where_before.len + where_domain.len + where_client.len +
|
||||
where_before.len + where_domain.len + where_client_max +
|
||||
where_blocked.len + where_since.len + where_until.len;
|
||||
|
||||
buf: [capacity]u8 = undefined,
|
||||
@@ -734,6 +746,19 @@ const Sql = struct {
|
||||
self.put(fragment);
|
||||
}
|
||||
|
||||
/// One placeholder per address. The addresses themselves are bound, like
|
||||
/// every other value; only their count reaches this buffer.
|
||||
fn clientPredicate(self: *Sql, count: usize) void {
|
||||
self.put(if (self.has_where) and_keyword else where_keyword);
|
||||
self.has_where = true;
|
||||
self.put(where_client_head);
|
||||
for (0..count) |i| {
|
||||
if (i > 0) self.put(",");
|
||||
self.put("?");
|
||||
}
|
||||
self.put(where_client_tail);
|
||||
}
|
||||
|
||||
fn text(self: *const Sql) []const u8 {
|
||||
return self.buf[0..self.len];
|
||||
}
|
||||
@@ -743,11 +768,16 @@ const Sql = struct {
|
||||
/// `arena`, including the list's own storage, so the caller frees the whole
|
||||
/// result by resetting the arena — there is nothing to unwind on failure.
|
||||
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
|
||||
// The statement buffer is sized for the cap, so a longer list would be a
|
||||
// buffer overrun rather than a slow query. The handler rejects it first;
|
||||
// this is the wall behind that, for a caller that skips the handler.
|
||||
if (filter.clients.len > max_clients) return error.Misuse;
|
||||
|
||||
var sql: Sql = .{};
|
||||
sql.put(select_head);
|
||||
if (filter.before != null) sql.predicate(where_before);
|
||||
if (filter.domain_substring != null) sql.predicate(where_domain);
|
||||
if (filter.client != null) sql.predicate(where_client);
|
||||
if (filter.clients.len > 0) sql.clientPredicate(filter.clients.len);
|
||||
if (filter.blocked != null) sql.predicate(where_blocked);
|
||||
if (filter.since != null) sql.predicate(where_since);
|
||||
if (filter.until != null) sql.predicate(where_until);
|
||||
@@ -765,9 +795,9 @@ pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db
|
||||
idx += 1;
|
||||
try stmt.bindText(idx, try likePattern(arena, v));
|
||||
}
|
||||
if (filter.client) |v| {
|
||||
for (filter.clients) |client| {
|
||||
idx += 1;
|
||||
try stmt.bindText(idx, v);
|
||||
try stmt.bindText(idx, client);
|
||||
}
|
||||
if (filter.blocked) |v| {
|
||||
idx += 1;
|
||||
@@ -2031,11 +2061,11 @@ test "each filter narrows the result on its own" {
|
||||
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
|
||||
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
|
||||
|
||||
const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
|
||||
const by_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.20"} });
|
||||
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
|
||||
|
||||
// An exact match, not a prefix: the seeded clients share the first octets.
|
||||
const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
|
||||
const no_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2"} });
|
||||
try testing.expectEqual(@as(usize, 0), no_client.items.len);
|
||||
|
||||
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
|
||||
@@ -2049,7 +2079,7 @@ test "each filter narrows the result on its own" {
|
||||
.limit = 10,
|
||||
.before = 3,
|
||||
.domain_substring = "ads",
|
||||
.client = "192.0.2.20",
|
||||
.clients = &.{"192.0.2.20"},
|
||||
.blocked = true,
|
||||
.since = 200,
|
||||
.until = 300,
|
||||
@@ -2125,18 +2155,84 @@ test "the built SQL never carries a filter value and fits its buffer" {
|
||||
sql.put(select_head);
|
||||
sql.predicate(where_before);
|
||||
sql.predicate(where_domain);
|
||||
sql.predicate(where_client);
|
||||
sql.clientPredicate(max_clients);
|
||||
sql.predicate(where_blocked);
|
||||
sql.predicate(where_since);
|
||||
sql.predicate(where_until);
|
||||
sql.put(select_tail);
|
||||
|
||||
// Every predicate present is the longest reachable statement.
|
||||
// Every predicate present, with the client list at its cap, is the longest
|
||||
// reachable statement — which is what the buffer is sized against.
|
||||
try testing.expect(sql.len <= Sql.capacity);
|
||||
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
|
||||
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
|
||||
// Six filters plus the LIMIT, each a bare parameter.
|
||||
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
|
||||
// Five scalar filters plus the LIMIT, plus one per address, each a bare
|
||||
// parameter: no value is ever spelled into the statement.
|
||||
try testing.expectEqual(@as(usize, 6 + max_clients), std.mem.count(u8, sql.text(), "?"));
|
||||
// The select list has commas of its own, so the client list is measured as
|
||||
// the difference against the same statement without it.
|
||||
var without: Sql = .{};
|
||||
without.put(select_head);
|
||||
without.predicate(where_before);
|
||||
without.predicate(where_domain);
|
||||
without.predicate(where_blocked);
|
||||
without.predicate(where_since);
|
||||
without.predicate(where_until);
|
||||
without.put(select_tail);
|
||||
try testing.expectEqual(
|
||||
@as(usize, max_clients - 1),
|
||||
std.mem.count(u8, sql.text(), ",") - std.mem.count(u8, without.text(), ","),
|
||||
);
|
||||
}
|
||||
|
||||
test "one client reads as an exact match and several read as any-of" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var second = plainRow(200, "b.example");
|
||||
second.client_ip = "192.0.2.11";
|
||||
var third = plainRow(300, "c.example");
|
||||
third.client_ip = "192.0.2.12";
|
||||
try seed(&database, &.{ plainRow(100, "a.example"), second, third });
|
||||
|
||||
var buf: [8]i64 = undefined;
|
||||
|
||||
const one = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.11"} });
|
||||
try testing.expectEqualSlices(i64, &.{2}, ids(one.items, &buf));
|
||||
|
||||
// Newest-first, so the higher id leads however the addresses are ordered.
|
||||
const two = try selectQueries(&database, arena, .{ .clients = &.{ "192.0.2.12", "192.0.2.10" } });
|
||||
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(two.items, &buf));
|
||||
|
||||
// An address nothing was logged from narrows to nothing rather than being
|
||||
// ignored, which is the difference between a filter and a suggestion.
|
||||
const absent = try selectQueries(&database, arena, .{ .clients = &.{"198.51.100.1"} });
|
||||
try testing.expectEqual(@as(usize, 0), absent.items.len);
|
||||
|
||||
// No addresses at all is no client filter.
|
||||
const none = try selectQueries(&database, arena, .{});
|
||||
try testing.expectEqual(@as(usize, 3), none.items.len);
|
||||
}
|
||||
|
||||
test "a client list past the cap is refused rather than truncated" {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var too_many: [max_clients + 1][]const u8 = undefined;
|
||||
for (&too_many) |*slot| slot.* = "192.0.2.10";
|
||||
|
||||
// Dropping the overflow would narrow the filter silently, which answers a
|
||||
// question the caller did not ask; the statement buffer could not hold it
|
||||
// either way.
|
||||
try testing.expectError(error.Misuse, selectQueries(&database, arena, .{ .clients = &too_many }));
|
||||
}
|
||||
|
||||
test "likePattern wraps the needle and neutralises every metacharacter" {
|
||||
|
||||
Reference in New Issue
Block a user