549 lines
21 KiB
Zig
549 lines
21 KiB
Zig
//! `blocklist_sources`.
|
|
//!
|
|
//! Only the four configuration columns are read and written. `last_updated`,
|
|
//! `domain_count`, `wildcard_count`, `skipped_regex_count` and `checksum` are
|
|
//! facts a running server produces; an insert leaves them at their column
|
|
//! defaults so two exports taken minutes apart stay identical.
|
|
//!
|
|
//! The import path is list / insert / deleteAll / count; the runtime columns and
|
|
//! Phase 8's REST surface follow it, both keyed by row id.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const db = @import("../db.zig");
|
|
const migrations = @import("../migrations.zig");
|
|
const model = @import("../../config/model.zig");
|
|
const context = @import("context.zig");
|
|
const crud = @import("crud.zig");
|
|
|
|
const InsertContext = context.InsertContext;
|
|
|
|
const list_sql =
|
|
\\SELECT url, name, enabled, is_suggested FROM blocklist_sources ORDER BY url
|
|
;
|
|
|
|
/// Every string in the result is a heap copy owned by `gpa`.
|
|
pub fn listBlocklistSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.BlocklistSource) {
|
|
return crud.listRows(model.BlocklistSource, database, gpa, list_sql, readBlocklistSource);
|
|
}
|
|
|
|
fn readBlocklistSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.BlocklistSource {
|
|
const url = try stmt.columnTextAlloc(gpa, 0);
|
|
errdefer gpa.free(url);
|
|
const name = try stmt.columnTextAlloc(gpa, 1);
|
|
errdefer gpa.free(name);
|
|
return .{
|
|
.url = url,
|
|
.name = name,
|
|
.enabled = stmt.columnBool(2),
|
|
.is_suggested = stmt.columnBool(3),
|
|
};
|
|
}
|
|
|
|
pub fn freeBlocklistSources(gpa: Allocator, items: []const model.BlocklistSource) void {
|
|
crud.freeRows(model.BlocklistSource, gpa, items);
|
|
}
|
|
|
|
const insert_sql =
|
|
\\INSERT INTO blocklist_sources (url, name, enabled, is_suggested) VALUES (?1, ?2, ?3, ?4)
|
|
;
|
|
|
|
pub fn insertBlocklistSource(database: *db.Db, item: model.BlocklistSource, ctx: InsertContext) db.Error!void {
|
|
_ = ctx;
|
|
var stmt = try database.prepare(insert_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.url);
|
|
try stmt.bindText(2, item.name);
|
|
try stmt.bindBool(3, item.enabled);
|
|
try stmt.bindBool(4, item.is_suggested);
|
|
try stmt.exec();
|
|
}
|
|
|
|
pub fn deleteAllBlocklistSources(database: *db.Db) db.Error!void {
|
|
return database.exec("DELETE FROM blocklist_sources;");
|
|
}
|
|
|
|
pub fn countBlocklistSources(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM blocklist_sources");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// runtime columns (milestone 5 S8.1)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The blocklist manager needs the row id (the compiled files are named after
|
|
// it) and the counters the refresh writes. Neither belongs in `model`: an
|
|
// export carries configuration, and these are facts a running server produces.
|
|
// Both functions below are additive; no export path reads them.
|
|
|
|
pub const SourceRow = struct {
|
|
id: i64,
|
|
url: []const u8,
|
|
name: []const u8,
|
|
enabled: bool,
|
|
/// Defaulted because the blocklist manager builds `SourceRow` values from
|
|
/// the refresh columns alone; the REST layer is what reads this one.
|
|
is_suggested: bool = false,
|
|
last_updated: ?i64,
|
|
domain_count: i64,
|
|
wildcard_count: i64,
|
|
skipped_regex_count: i64,
|
|
checksum: ?[]const u8,
|
|
};
|
|
|
|
pub const SourceStats = struct {
|
|
last_updated: i64,
|
|
domain_count: i64,
|
|
wildcard_count: i64,
|
|
skipped_regex_count: i64,
|
|
/// Lowercase hex sha256 over the `.list` body followed by the `.wild` body.
|
|
checksum: []const u8,
|
|
};
|
|
|
|
const row_columns_sql =
|
|
\\SELECT id, url, name, enabled, last_updated,
|
|
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
|
|
\\ is_suggested
|
|
\\ FROM blocklist_sources
|
|
;
|
|
|
|
const list_rows_sql = row_columns_sql ++ " ORDER BY url";
|
|
|
|
/// Every source with its row id and its runtime columns, in the same `url`
|
|
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
|
|
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
|
|
pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(SourceRow) {
|
|
return crud.listRows(SourceRow, database, gpa, list_rows_sql, readSourceRow);
|
|
}
|
|
|
|
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
|
|
const url = try stmt.columnTextAlloc(gpa, 1);
|
|
errdefer gpa.free(url);
|
|
const name = try stmt.columnTextAlloc(gpa, 2);
|
|
errdefer gpa.free(name);
|
|
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
|
|
errdefer if (checksum) |value| gpa.free(value);
|
|
return .{
|
|
.id = stmt.columnInt(0),
|
|
.url = url,
|
|
.name = name,
|
|
.enabled = stmt.columnBool(3),
|
|
.is_suggested = stmt.columnBool(9),
|
|
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
|
|
.domain_count = stmt.columnInt(5),
|
|
.wildcard_count = stmt.columnInt(6),
|
|
.skipped_regex_count = stmt.columnInt(7),
|
|
.checksum = checksum,
|
|
};
|
|
}
|
|
|
|
/// Frees `url`, `name` and the `checksum` payload when it is not null.
|
|
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
|
|
crud.freeRow(SourceRow, gpa, row);
|
|
}
|
|
|
|
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
|
|
crud.freeRows(SourceRow, gpa, items);
|
|
}
|
|
|
|
const update_stats_sql =
|
|
\\UPDATE blocklist_sources
|
|
\\ SET last_updated = ?2, domain_count = ?3, wildcard_count = ?4,
|
|
\\ skipped_regex_count = ?5, checksum = ?6
|
|
\\ WHERE id = ?1
|
|
;
|
|
|
|
/// Writes the runtime columns for one source after a compile. The configuration
|
|
/// columns (`url`, `name`, `enabled`, `is_suggested`) are untouched.
|
|
pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error!void {
|
|
var stmt = try database.prepare(update_stats_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindInt(2, stats.last_updated);
|
|
try stmt.bindInt(3, stats.domain_count);
|
|
try stmt.bindInt(4, stats.wildcard_count);
|
|
try stmt.bindInt(5, stats.skipped_regex_count);
|
|
try stmt.bindText(6, stats.checksum);
|
|
try stmt.exec();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST surface (milestone 8)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `/api/blocklists` is this table. The read shape is `SourceRow` above — the
|
|
// UI wants the counters next to the configuration — and the write shape is
|
|
// `model.BlocklistSource`, whose four fields are the four columns an operator
|
|
// may set.
|
|
|
|
pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow {
|
|
var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
if (!try stmt.step()) return null;
|
|
return try readSourceRow(&stmt, gpa);
|
|
}
|
|
|
|
/// The runtime columns stay at their defaults, so a source added through the
|
|
/// API looks exactly like an imported one until the first refresh.
|
|
///
|
|
/// `error.Constraint`: `blocklist_sources.url` is UNIQUE.
|
|
pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 {
|
|
var stmt = try database.prepare(insert_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.url);
|
|
try stmt.bindText(2, item.name);
|
|
try stmt.bindBool(3, item.enabled);
|
|
try stmt.bindBool(4, item.is_suggested);
|
|
try stmt.exec();
|
|
return database.lastInsertRowid();
|
|
}
|
|
|
|
const update_source_sql =
|
|
\\UPDATE blocklist_sources
|
|
\\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5
|
|
\\ WHERE id = ?1
|
|
;
|
|
|
|
/// Writes the four configuration columns. The runtime columns are the refresh
|
|
/// path's and stay as they are — even when `url` changes, because the next
|
|
/// refresh compares checksums and replaces them anyway.
|
|
///
|
|
/// `error.NotFound`: no source holds `id`. `error.Constraint`:
|
|
/// `blocklist_sources.url` is UNIQUE.
|
|
pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void {
|
|
var stmt = try database.prepare(update_source_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindText(2, item.url);
|
|
try stmt.bindText(3, item.name);
|
|
try stmt.bindBool(4, item.enabled);
|
|
try stmt.bindBool(5, item.is_suggested);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
/// `error.NotFound`: no source holds `id`. `group_sources` references it
|
|
/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no
|
|
/// constraint can fire.
|
|
pub fn deleteSource(database: *db.Db, id: i64) db.Error!void {
|
|
var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
fn openMigrated() !db.Db {
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
return database;
|
|
}
|
|
|
|
fn seedSources(database: *db.Db) !void {
|
|
const ctx: InsertContext = .{};
|
|
try insertBlocklistSource(database, .{
|
|
.url = "https://c.example/list.txt",
|
|
.name = "C list",
|
|
}, ctx);
|
|
try insertBlocklistSource(database, .{
|
|
.url = "https://a.example/list.txt",
|
|
.name = "A list",
|
|
.enabled = false,
|
|
}, ctx);
|
|
try insertBlocklistSource(database, .{
|
|
.url = "https://b.example/list.txt",
|
|
.name = "B list",
|
|
.is_suggested = true,
|
|
}, ctx);
|
|
}
|
|
|
|
test "blocklist_sources round-trip in url order" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
var items = try listBlocklistSources(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeBlocklistSources(testing.allocator, items.items);
|
|
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].url);
|
|
try testing.expectEqualStrings("A list", items.items[0].name);
|
|
try testing.expect(!items.items[0].enabled);
|
|
try testing.expect(!items.items[0].is_suggested);
|
|
try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].url);
|
|
try testing.expectEqualStrings("B list", items.items[1].name);
|
|
try testing.expect(items.items[1].enabled);
|
|
try testing.expect(items.items[1].is_suggested);
|
|
try testing.expectEqualStrings("https://c.example/list.txt", items.items[2].url);
|
|
try testing.expectEqualStrings("C list", items.items[2].name);
|
|
try testing.expect(items.items[2].enabled);
|
|
try testing.expect(!items.items[2].is_suggested);
|
|
}
|
|
|
|
test "insertBlocklistSource leaves the runtime columns at their defaults" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
try testing.expectEqual(
|
|
@as(i64, 3),
|
|
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE last_updated IS NULL"),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(i64, 3),
|
|
try database.queryInt("SELECT count(*) FROM blocklist_sources WHERE checksum IS NULL"),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(i64, 0),
|
|
try database.queryInt("SELECT sum(domain_count + wildcard_count + skipped_regex_count) FROM blocklist_sources"),
|
|
);
|
|
}
|
|
|
|
test "deleteAllBlocklistSources empties the table and countBlocklistSources reflects it" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countBlocklistSources(&database));
|
|
try deleteAllBlocklistSources(&database);
|
|
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
|
|
}
|
|
|
|
fn listBlocklistSourcesUnderFailure(gpa: Allocator) !void {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
var items = try listBlocklistSources(&database, gpa);
|
|
defer items.deinit(gpa);
|
|
defer freeBlocklistSources(gpa, items.items);
|
|
}
|
|
|
|
test "listBlocklistSources is leak-safe under allocation failure" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, listBlocklistSourcesUnderFailure, .{});
|
|
}
|
|
|
|
test "listSourceRows returns row ids and the runtime columns in url order" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
var rows = try listSourceRows(&database, testing.allocator);
|
|
defer rows.deinit(testing.allocator);
|
|
defer freeSourceRows(testing.allocator, rows.items);
|
|
|
|
try testing.expectEqual(@as(usize, 3), rows.items.len);
|
|
try testing.expectEqualStrings("https://a.example/list.txt", rows.items[0].url);
|
|
try testing.expectEqualStrings("https://b.example/list.txt", rows.items[1].url);
|
|
try testing.expectEqualStrings("https://c.example/list.txt", rows.items[2].url);
|
|
try testing.expectEqualStrings("A list", rows.items[0].name);
|
|
try testing.expect(!rows.items[0].enabled);
|
|
try testing.expect(rows.items[1].enabled);
|
|
|
|
for (rows.items) |row| {
|
|
try testing.expect(row.id > 0);
|
|
try testing.expectEqual(@as(?i64, null), row.last_updated);
|
|
try testing.expectEqual(@as(?[]const u8, null), row.checksum);
|
|
try testing.expectEqual(@as(i64, 0), row.domain_count);
|
|
try testing.expectEqual(@as(i64, 0), row.wildcard_count);
|
|
try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
|
|
}
|
|
}
|
|
|
|
test "updateSourceStats writes the runtime columns of one source only" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
var before = try listSourceRows(&database, testing.allocator);
|
|
defer before.deinit(testing.allocator);
|
|
defer freeSourceRows(testing.allocator, before.items);
|
|
|
|
const target = before.items[1];
|
|
try updateSourceStats(&database, target.id, .{
|
|
.last_updated = 1_700_000_000,
|
|
.domain_count = 4321,
|
|
.wildcard_count = 21,
|
|
.skipped_regex_count = 7,
|
|
.checksum = "a" ** 64,
|
|
});
|
|
|
|
var after = try listSourceRows(&database, testing.allocator);
|
|
defer after.deinit(testing.allocator);
|
|
defer freeSourceRows(testing.allocator, after.items);
|
|
|
|
const updated = after.items[1];
|
|
try testing.expectEqual(target.id, updated.id);
|
|
try testing.expectEqualStrings("https://b.example/list.txt", updated.url);
|
|
try testing.expectEqualStrings("B list", updated.name);
|
|
try testing.expect(updated.enabled);
|
|
try testing.expectEqual(@as(?i64, 1_700_000_000), updated.last_updated);
|
|
try testing.expectEqual(@as(i64, 4321), updated.domain_count);
|
|
try testing.expectEqual(@as(i64, 21), updated.wildcard_count);
|
|
try testing.expectEqual(@as(i64, 7), updated.skipped_regex_count);
|
|
try testing.expectEqualStrings("a" ** 64, updated.checksum.?);
|
|
|
|
// The two untouched rows kept their defaults.
|
|
try testing.expectEqual(@as(?i64, null), after.items[0].last_updated);
|
|
try testing.expectEqual(@as(?[]const u8, null), after.items[2].checksum);
|
|
}
|
|
|
|
fn listSourceRowsUnderFailure(gpa: Allocator) !void {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
try updateSourceStats(&database, 1, .{
|
|
.last_updated = 1,
|
|
.domain_count = 2,
|
|
.wildcard_count = 3,
|
|
.skipped_regex_count = 4,
|
|
.checksum = "b" ** 64,
|
|
});
|
|
|
|
var rows = try listSourceRows(&database, gpa);
|
|
defer rows.deinit(gpa);
|
|
defer freeSourceRows(gpa, rows.items);
|
|
|
|
// `checksum` is the one allocated optional in this directory. Without a
|
|
// non-null one in the result the injection never reaches its allocation and
|
|
// this test stops covering the shape it exists for. Row id 1 sorts last:
|
|
// `seedSources` inserts `c.example` first and the list orders by url.
|
|
try testing.expect(rows.items[2].checksum != null);
|
|
}
|
|
|
|
test "listSourceRows is leak-safe under allocation failure" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
|
|
}
|
|
|
|
// --- REST surface ----------------------------------------------------------
|
|
|
|
test "a blocklist source round-trips through insert, get, list, update and delete" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
const id = try insertSourceRow(&database, .{
|
|
.url = "https://lists.example/hosts.txt",
|
|
.name = "Example",
|
|
.is_suggested = true,
|
|
});
|
|
|
|
const fetched = (try getSource(&database, testing.allocator, id)).?;
|
|
defer freeSourceRow(testing.allocator, fetched);
|
|
try testing.expectEqual(id, fetched.id);
|
|
try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url);
|
|
try testing.expectEqualStrings("Example", fetched.name);
|
|
try testing.expect(fetched.enabled);
|
|
try testing.expect(fetched.is_suggested);
|
|
try testing.expectEqual(@as(?i64, null), fetched.last_updated);
|
|
|
|
try updateSource(&database, id, .{
|
|
.url = "https://lists.example/hosts.txt",
|
|
.name = "Example list",
|
|
.enabled = false,
|
|
});
|
|
const updated = (try getSource(&database, testing.allocator, id)).?;
|
|
defer freeSourceRow(testing.allocator, updated);
|
|
try testing.expectEqualStrings("Example list", updated.name);
|
|
try testing.expect(!updated.enabled);
|
|
try testing.expect(!updated.is_suggested);
|
|
|
|
try deleteSource(&database, id);
|
|
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id));
|
|
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
|
|
}
|
|
|
|
test "updateSource leaves the runtime columns where the refresh path left them" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
|
try updateSourceStats(&database, id, .{
|
|
.last_updated = 1_700_000_000,
|
|
.domain_count = 12,
|
|
.wildcard_count = 3,
|
|
.skipped_regex_count = 1,
|
|
.checksum = "c" ** 64,
|
|
});
|
|
|
|
try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
|
|
|
const row = (try getSource(&database, testing.allocator, id)).?;
|
|
defer freeSourceRow(testing.allocator, row);
|
|
try testing.expectEqualStrings("https://lists.example/b.txt", row.url);
|
|
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
|
|
try testing.expectEqual(@as(i64, 12), row.domain_count);
|
|
try testing.expectEqualStrings("c" ** 64, row.checksum.?);
|
|
}
|
|
|
|
test "source update and delete report NotFound for an id no row holds" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
try testing.expectError(
|
|
error.NotFound,
|
|
updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }),
|
|
);
|
|
try testing.expectError(error.NotFound, deleteSource(&database, 404));
|
|
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404));
|
|
}
|
|
|
|
test "a duplicate source url surfaces as error.Constraint on insert and on update" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
_ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
|
const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
|
|
|
try testing.expectError(
|
|
error.Constraint,
|
|
insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }),
|
|
);
|
|
try testing.expectError(
|
|
error.Constraint,
|
|
updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }),
|
|
);
|
|
try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database));
|
|
}
|
|
|
|
test "deleting a source drops it from every group assignment" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
|
|
const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
|
|
try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);");
|
|
|
|
try deleteSource(&database, id);
|
|
try testing.expectEqual(@as(i64, 1), try countGroupSources(&database));
|
|
try testing.expectEqual(
|
|
keep,
|
|
try database.queryInt("SELECT source_id FROM group_sources"),
|
|
);
|
|
}
|
|
|
|
fn countGroupSources(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM group_sources");
|
|
}
|
|
|
|
fn sourceRowUnderFailure(gpa: Allocator) !void {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try seedSources(&database);
|
|
|
|
const one = (try getSource(&database, gpa, 1)).?;
|
|
defer freeSourceRow(gpa, one);
|
|
}
|
|
|
|
test "getSource is leak-safe under allocation failure" {
|
|
try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{});
|
|
}
|