diff --git a/src/main/api.zig b/src/main/api.zig index 6053435..abfe8aa 100644 --- a/src/main/api.zig +++ b/src/main/api.zig @@ -324,5 +324,10 @@ fn ApiConn(comptime DbConn: type) type { .created_at = note.created_at, }; } + + pub fn queryCommunities(self: *Self, args: services.communities.QueryArgs) ![]services.communities.Community { + if (!self.isAdmin()) return error.PermissionDenied; + return services.communities.query(&self.db, args, self.arena.allocator()); + } }; } diff --git a/src/main/api/communities.zig b/src/main/api/communities.zig index 069e023..209f185 100644 --- a/src/main/api/communities.zig +++ b/src/main/api/communities.zig @@ -8,6 +8,7 @@ const DbError = @import("../db.zig").ExecError; const getRandom = @import("../api.zig").getRandom; const Uuid = util.Uuid; +const DateTime = util.DateTime; const CreateError = error{ InvalidOrigin, @@ -32,8 +33,14 @@ pub const Community = struct { name: []const u8, scheme: Scheme, + created_at: DateTime, }; +fn freeCommunity(alloc: std.mem.Allocator, c: Community) void { + alloc.free(c.host); + alloc.free(c.name); +} + pub fn create(db: anytype, origin: []const u8, name: ?[]const u8) CreateError!Community { const scheme_len = firstIndexOf(origin, ':') orelse return error.InvalidOrigin; const scheme_str = origin[0..scheme_len]; @@ -66,6 +73,7 @@ pub fn create(db: anytype, origin: []const u8, name: ?[]const u8) CreateError!Co .host = host, .name = name orelse host, .scheme = scheme, + .created_at = DateTime.now(), }; if ((try db.execRow(&.{Uuid}, "SELECT id FROM community WHERE host = ?", .{host}, null)) != null) { @@ -86,7 +94,7 @@ fn firstIndexOf(str: []const u8, ch: u8) ?usize { } pub fn getByHost(db: anytype, host: []const u8, alloc: std.mem.Allocator) !Community { - const result = (try db.execRow(&.{ Uuid, ?Uuid, []const u8, []const u8, Scheme }, "SELECT id, owner_id, host, name, scheme FROM community WHERE host = ?", .{host}, alloc)) orelse return error.NotFound; + const result = (try db.execRow(&.{ Uuid, ?Uuid, []const u8, []const u8, Scheme, DateTime }, "SELECT id, owner_id, host, name, scheme, created_at FROM community WHERE host = ?", .{host}, alloc)) orelse return error.NotFound; return Community{ .id = result[0], @@ -94,9 +102,170 @@ pub fn getByHost(db: anytype, host: []const u8, alloc: std.mem.Allocator) !Commu .host = result[2], .name = result[3], .scheme = result[4], + .created_at = result[5], }; } pub fn transferOwnership(db: anytype, community_id: Uuid, new_owner: Uuid) !void { _ = try db.execRow(&.{i64}, "UPDATE community SET owner_id = ? WHERE id = ?", .{ new_owner, community_id }, null); } + +pub const QueryArgs = struct { + pub const OrderBy = enum { + name, + host, + created_at, + }; + + // Max items to fetch + max_items: usize = 20, + + // Selection filters + owner_id: ?Uuid = null, // searches for communities owned by this user + like: ?[]const u8 = null, // searches for communities with host or name LIKE '%?%' + created_before: ?DateTime = null, + created_after: ?DateTime = null, + + // Ordering parameter + order_by: OrderBy = .created_at, + direction: enum { + ascending, + descending, + } = .ascending, + + // Page start parameter + // This struct is a reference to the last value scanned + // If prev is present, then prev.order_val must have the same tag as order_by + // "prev" here refers to it being the previous value returned. It may be that + // prev refers to the item directly after the results you are about to recieve, + // if you are querying the previous page. + prev: ?struct { + id: Uuid, + order_val: union(OrderBy) { + name: []const u8, + host: []const u8, + created_at: DateTime, + }, + } = null, + + // What direction to scan the page window + // If "forward", then "prev" is interpreted as the item directly before the items + // to query, in the direction of "direction" above. If "backward", then the opposite + page_direction: enum { + forward, + backward, + } = .forward, +}; + +const Builder = struct { + array: std.ArrayList(u8), + where_clauses_appended: usize = 0, + + pub fn init(alloc: std.mem.Allocator) Builder { + return Builder{ .array = std.ArrayList(u8).init(alloc) }; + } + + pub fn deinit(self: *const Builder) void { + self.array.deinit(); + } + + pub fn andWhere(self: *Builder, clause: []const u8) !void { + if (self.where_clauses_appended == 0) { + try self.array.appendSlice("WHERE "); + } else { + try self.array.appendSlice(" AND "); + } + + try self.array.appendSlice(clause); + self.where_clauses_appended += 1; + } +}; + +const max_max_items = 100; +pub fn query(db: anytype, args: QueryArgs, alloc: std.mem.Allocator) ![]Community { + var builder = Builder.init(alloc); + defer builder.deinit(); + + try builder.array.appendSlice( + \\SELECT id, owner_id, host, name, scheme, created_at + \\FROM community + \\ + ); + const max_items = if (args.max_items > max_max_items) max_max_items else args.max_items; + + if (args.owner_id != null) try builder.andWhere("owner_id = $1"); + if (args.like != null) try builder.andWhere("(host LIKE ('%' + $2 + '%') OR name LIKE ('%' + $2 + '%'))"); + if (args.created_before != null) try builder.andWhere("created_at < $3"); + if (args.created_after != null) try builder.andWhere("created_at > $4"); + if (args.prev) |prev| { + if (prev.order_val != args.order_by) return error.PageArgMismatch; + + try builder.andWhere(switch (args.order_by) { + .name => "(name, id)", + .host => "(host, id)", + .created_at => "(created_at, id)", + }); + _ = try builder.array.appendSlice(switch (args.direction) { + .ascending => switch (args.page_direction) { + .forward => " > ", + .backward => " < ", + }, + .descending => switch (args.page_direction) { + .forward => " < ", + .backward => " > ", + }, + }); + + _ = try builder.array.appendSlice("($5, $6)"); + } + _ = try builder.array.appendSlice("\nORDER BY "); + _ = try builder.array.appendSlice(@tagName(args.order_by)); + _ = try builder.array.appendSlice(", id "); + _ = try builder.array.appendSlice(switch (args.direction) { + .ascending => "ASC", + .descending => "DESC", + }); + + _ = try builder.array.appendSlice("\nLIMIT $7"); + + const query_args = .{ + args.owner_id, + args.like, + args.created_before, + args.created_after, + if (args.prev) |prev| prev.order_val else null, + if (args.prev) |prev| prev.id else null, + max_items, + }; + + var results = try db.exec( + &.{ Uuid, ?Uuid, []const u8, []const u8, Scheme, DateTime }, + builder.array.items, + query_args, + ); + defer results.finish(); + + const result_buf = try alloc.alloc(Community, args.max_items); + errdefer alloc.free(result_buf); + + var count: usize = 0; + errdefer for (result_buf[0..count]) |c| freeCommunity(alloc, c); + + for (result_buf) |*c| { + const row = results.row(alloc) orelse break; + c.* = .{ + .id = row[0], + .owner_id = row[1], + .host = row[2], + .name = row[3], + .scheme = row[4], + .created_at = row[5], + }; + + count += 1; + } + + if (results.err) |err| return err; + + return result_buf[0..count]; +}