diff --git a/src/server.zig b/src/server.zig index 4bfa863..5f0ac3b 100644 --- a/src/server.zig +++ b/src/server.zig @@ -4061,3 +4061,161 @@ test "server table_aggregate HAVING narrows groups before LIMIT/OFFSET windows t var client = try client_mod.Client.connect(allocator, server.boundAddress()); defer client.close(); + const specs = [_]aggregatemod.AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "total", .kind = .sum }, + }; + const paged = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 1, &.{}, "sum_total gt i:100", ""); + defer protocol.freeAggGroupRows(allocator, paged); + try std.testing.expectEqual(@as(usize, 1), paged.len); + try std.testing.expectEqualStrings("warsaw", paged[0].group.text); + + const pagedOffset = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 1, 1, &.{}, "sum_total gt i:100", ""); + defer protocol.freeAggGroupRows(allocator, pagedOffset); + try std.testing.expectEqual(@as(usize, 1), pagedOffset.len); + try std.testing.expectEqualStrings("gdansk", pagedOffset[0].group.text); +} + +test "server table_aggregate HAVING on the global (no groupField) path excludes the single row without erroring, over real TCP" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + try seedAggregateOrders(allocator, &db); // global: count=3 sum=390 + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + const specs = [_]aggregatemod.AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "total", .kind = .sum }, + }; + + const passing = try client.tableAggregateGlobal(allocator, "orders", &specs, "sum_total gt i:100", ""); + defer protocol.freeAggValues(allocator, passing); + try std.testing.expectEqual(@as(usize, 2), passing.len); + try std.testing.expectEqual(@as(i64, 390), passing[1].int); + + const excluded = try client.tableAggregateGlobal(allocator, "orders", &specs, "sum_total gt i:100000", ""); + defer protocol.freeAggValues(allocator, excluded); + try std.testing.expectEqual(@as(usize, 0), excluded.len); +} + +test "server table_aggregate reports a malformed HAVING expr as bad_request, not a crash" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + try seedAggregateOrders(allocator, &db); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + const specs = [_]aggregatemod.AggSpec{.{ .field = "", .kind = .count }}; + const err = client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "count bogus i:1", ""); + try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err); +} + +test "server table_aggregate count_distinct collapses repeated field values, locally and globally, over real TCP" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + { + var orders = try querymod.Table.open(&db, allocator, "orders"); + defer orders.deinit(); + // Both warsaw rows share the same region on purpose - the whole + // point of this test is that COUNT(DISTINCT region) collapses them + // to 1, unlike plain COUNT which would report 2. + try orders.put(.{ .int = 1 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "region", .value = .{ .text = "mazovia" } }, + }); + try orders.put(.{ .int = 2 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "region", .value = .{ .text = "mazovia" } }, + }); + try orders.put(.{ .int = 3 }, &.{ + .{ .name = "city", .value = .{ .text = "krakow" } }, + .{ .name = "region", .value = .{ .text = "malopolska" } }, + }); + } + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + const specs = [_]aggregatemod.AggSpec{ + .{ .field = "", .kind = .count }, + .{ .field = "region", .kind = .count_distinct }, + }; + const groups = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", ""); + defer protocol.freeAggGroupRows(allocator, groups); + try std.testing.expectEqual(@as(usize, 2), groups.len); + try std.testing.expectEqualStrings("warsaw", groups[0].group.text); + try std.testing.expectEqual(@as(i64, 2), groups[0].values[0].int); // COUNT(*) + try std.testing.expectEqual(@as(i64, 1), groups[0].values[1].int); // COUNT(DISTINCT region) + try std.testing.expectEqualStrings("krakow", groups[1].group.text); + try std.testing.expectEqual(@as(i64, 1), groups[1].values[0].int); + try std.testing.expectEqual(@as(i64, 1), groups[1].values[1].int); + + const globalSpecs = [_]aggregatemod.AggSpec{.{ .field = "region", .kind = .count_distinct }}; + const global = try client.tableAggregateGlobal(allocator, "orders", &globalSpecs, "", ""); + defer protocol.freeAggValues(allocator, global); + try std.testing.expectEqual(@as(i64, 2), global[0].int); // mazovia + malopolska +} + +test "server table_aggregate reports a non-numeric sum field as bad_request, not a crash" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(path); + + var db = try testDb(allocator, path); + defer db.close(); + try seedAggregateOrders(allocator, &db); + + const address = try std.net.Address.parseIp("127.0.0.1", 0); + var server = try Server.init(allocator, &db, address); + try server.start(); + defer server.stop(); + + const client_mod = @import("client.zig"); + var client = try client_mod.Client.connect(allocator, server.boundAddress()); + defer client.close(); + + const specs = [_]aggregatemod.AggSpec{.{ .field = "city", .kind = .sum }}; + const err = client.tableAggregateGlobal(allocator, "orders", &specs, "", ""); + try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err); +} +