//! `settings`. //! //! The row type is `model.SettingPair`, the same type `model.toSettings` and //! `model.fromSettings` speak, so the scalar sections cross the storage boundary //! without a second shape. //! //! The import path is list / insert / deleteAll / count; `putSetting` is Phase //! 8's single-key write. `settings` has no row ids — the key is the identity — //! so it gains no by-id surface. 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; /// Both strings of every pair are heap copies owned by `gpa`. pub fn listSettings(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.SettingPair) { return crud.listRows( model.SettingPair, database, gpa, "SELECT key, value FROM settings ORDER BY key", readSetting, ); } fn readSetting(stmt: *db.Stmt, gpa: Allocator) db.Error!model.SettingPair { const key = try stmt.columnTextAlloc(gpa, 0); errdefer gpa.free(key); const value = try stmt.columnTextAlloc(gpa, 1); errdefer gpa.free(value); return .{ .key = key, .value = value }; } /// Only for lists `listSettings` produced. `model.toSettings` builds pairs whose /// `key` is a comptime string and must never be freed; that list is the caller's /// to release, field by field. pub fn freeSettings(gpa: Allocator, items: []const model.SettingPair) void { crud.freeRows(model.SettingPair, gpa, items); } pub fn insertSetting(database: *db.Db, item: model.SettingPair, ctx: InsertContext) db.Error!void { _ = ctx; var stmt = try database.prepare("INSERT INTO settings (key, value) VALUES (?1, ?2)"); defer stmt.deinit(); try stmt.bindText(1, item.key); try stmt.bindText(2, item.value); try stmt.exec(); } pub fn deleteAllSettings(database: *db.Db) db.Error!void { return database.exec("DELETE FROM settings;"); } pub fn countSettings(database: *db.Db) db.Error!i64 { return database.queryInt("SELECT count(*) FROM settings"); } // --------------------------------------------------------------------------- // REST surface (milestone 8) // --------------------------------------------------------------------------- const put_setting_sql = \\INSERT INTO settings (key, value) VALUES (?1, ?2) \\ON CONFLICT(key) DO UPDATE SET value = excluded.value ; /// Writes one key, whether or not it is already stored. /// /// `PUT /api/settings` is a partial update over a table whose rows the import /// path writes once and never revisits, so a plain `INSERT` would fail on every /// key the config already carries and a plain `UPDATE` would drop every key it /// does not. The conflict target is `settings.key`, the table's PRIMARY KEY. /// /// No constraint can fire: the table has one key column and one `NOT NULL` /// value, and both are bound. pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error!void { var stmt = try database.prepare(put_setting_sql); defer stmt.deinit(); try stmt.bindText(1, key); try stmt.bindText(2, value); try stmt.exec(); } // --------------------------------------------------------------------------- // 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 seedSettings(database: *db.Db) !void { const ctx: InsertContext = .{}; try insertSetting(database, .{ .key = "web.port", .value = "8080" }, ctx); try insertSetting(database, .{ .key = "dns.port", .value = "53" }, ctx); // An apostrophe proves the value is bound, not concatenated into the SQL. try insertSetting(database, .{ .key = "logging.file_path", .value = "/var/log/o'brien.log" }, ctx); } test "settings round-trip in ascending key order" { var database = try openMigrated(); defer database.close(); try seedSettings(&database); var items = try listSettings(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeSettings(testing.allocator, items.items); try testing.expectEqual(@as(usize, 3), items.items.len); try testing.expectEqualStrings("dns.port", items.items[0].key); try testing.expectEqualStrings("53", items.items[0].value); try testing.expectEqualStrings("logging.file_path", items.items[1].key); try testing.expectEqualStrings("/var/log/o'brien.log", items.items[1].value); try testing.expectEqualStrings("web.port", items.items[2].key); try testing.expectEqualStrings("8080", items.items[2].value); } test "a value holding an apostrophe survives the round trip" { var database = try openMigrated(); defer database.close(); const ctx: InsertContext = .{}; const value = "he said 'hello'; DROP TABLE settings;--"; try insertSetting(&database, .{ .key = "web.password_hash", .value = value }, ctx); var items = try listSettings(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeSettings(testing.allocator, items.items); try testing.expectEqual(@as(usize, 1), items.items.len); try testing.expectEqualStrings(value, items.items[0].value); try testing.expectEqual(@as(i64, 1), try countSettings(&database)); } test "deleteAllSettings empties the table and countSettings reflects it" { var database = try openMigrated(); defer database.close(); try seedSettings(&database); try testing.expectEqual(@as(i64, 3), try countSettings(&database)); try deleteAllSettings(&database); try testing.expectEqual(@as(i64, 0), try countSettings(&database)); } fn listSettingsUnderFailure(gpa: Allocator) !void { var database = try openMigrated(); defer database.close(); try seedSettings(&database); var items = try listSettings(&database, gpa); defer items.deinit(gpa); defer freeSettings(gpa, items.items); } test "listSettings is leak-safe under allocation failure" { try testing.checkAllAllocationFailures(testing.allocator, listSettingsUnderFailure, .{}); } // --- REST surface ---------------------------------------------------------- test "putSetting writes a key that is absent and overwrites one that is present" { var database = try openMigrated(); defer database.close(); try seedSettings(&database); try putSetting(&database, "web.port", "9090"); try putSetting(&database, "cache.max_entries", "20000"); var items = try listSettings(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeSettings(testing.allocator, items.items); try testing.expectEqual(@as(usize, 4), items.items.len); try testing.expectEqualStrings("cache.max_entries", items.items[0].key); try testing.expectEqualStrings("20000", items.items[0].value); try testing.expectEqualStrings("web.port", items.items[3].key); try testing.expectEqualStrings("9090", items.items[3].value); // The keys it did not name are untouched. try testing.expectEqualStrings("dns.port", items.items[1].key); try testing.expectEqualStrings("53", items.items[1].value); } test "putSetting is idempotent and leaves the row count alone" { var database = try openMigrated(); defer database.close(); try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA"); try putSetting(&database, "web.password_hash", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA"); try testing.expectEqual(@as(i64, 1), try countSettings(&database)); var items = try listSettings(&database, testing.allocator); defer items.deinit(testing.allocator); defer freeSettings(testing.allocator, items.items); try testing.expectEqualStrings("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA", items.items[0].value); }