diff --git a/src/server.zig b/src/server.zig index 8e3dc52..48ec55a 100644 --- a/src/server.zig +++ b/src/server.zig @@ -3747,3 +3747,157 @@ test "server table_select orders a remote result by a field before windowing it, try std.testing.expectEqual(@as(i64, 1), asc[4].get(querymod.pkField).?.int); try std.testing.expectEqual(@as(i64, 50), asc[4].get("age").?.int); + // Descending, windowed to the middle page (offset 1, limit 2). Age + // decreases as pk increases here, so descending-by-age happens to land + // on the same row order as ascending pk (pk1..5 = ages 50,40,30,20,10) + // - the offset/limit window (pk 2, pk 3) is still only correct if the + // full 5-row set was sorted first and THEN windowed, not windowed off + // the raw scan and left unsorted (which would coincidentally return + // the same two rows here, but in the ascending-age windowed case below + // would not). + const midDesc = try client.tableSelect(allocator, "users", "", 1, 2, &.{.{ .field = "age", .descending = true }}); + defer protocol.freeRows(allocator, midDesc); + try std.testing.expectEqual(@as(usize, 2), midDesc.len); + try std.testing.expectEqual(@as(i64, 2), midDesc[0].get(querymod.pkField).?.int); + try std.testing.expectEqual(@as(i64, 40), midDesc[0].get("age").?.int); + try std.testing.expectEqual(@as(i64, 3), midDesc[1].get(querymod.pkField).?.int); + try std.testing.expectEqual(@as(i64, 30), midDesc[1].get("age").?.int); + + // Ascending, same window (offset 1, limit 2): ascending-by-age order is + // pk 5,4,3,2,1 (ages 10,20,30,40,50), so offset 1..3 is pk 4 then pk 3 + // - a DIFFERENT pair than the descending window above, which a + // window-before-sort bug (paging the raw pk scan, then sorting only + // the already-cut window) would fail to distinguish. + const midAsc = try client.tableSelect(allocator, "users", "", 1, 2, &.{.{ .field = "age" }}); + defer protocol.freeRows(allocator, midAsc); + try std.testing.expectEqual(@as(usize, 2), midAsc.len); + try std.testing.expectEqual(@as(i64, 4), midAsc[0].get(querymod.pkField).?.int); + try std.testing.expectEqual(@as(i64, 20), midAsc[0].get("age").?.int); + try std.testing.expectEqual(@as(i64, 3), midAsc[1].get(querymod.pkField).?.int); + try std.testing.expectEqual(@as(i64, 30), midAsc[1].get("age").?.int); + + // Cross-check: table_explain reports the `age` index is used for the + // predicate that would narrow this query, same as the ordered path's + // `selectAllMatches` internally reuses. + var plan = try client.tableExplain(allocator, "users", "age gte i:0"); + defer plan.deinit(allocator); + try std.testing.expectEqual(querymod.PlanKind.index_range, plan.kind); +} + +// A second, INDEPENDENT `orderBy` column exercises a path the single-column +// test above cannot: `handleTableSelect` decoding a wire count > 1 and +// `sortRowsBy`'s tie-breaking chain, both new with multi-column support. +test "server table_select orders a remote result by two columns, sorting then breaking ties" { + 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 table = try querymod.Table.open(&db, allocator, "people"); + defer table.deinit(); + // Two cities, scores deliberately NOT already grouped by city in + // scan order, so a single-column `city` sort alone would leave + // each city's rows unsorted by `score` - only a real tiebreak + // fixes that. + try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 30 } } }); + try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 20 } } }); + try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "score", .value = .{ .int = 10 } } }); + try table.put(.{ .int = 4 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "score", .value = .{ .int = 40 } } }); + } + + 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 rows = try client.tableSelect(allocator, "people", "", 0, 0, &.{ .{ .field = "city" }, .{ .field = "score" } }); + defer protocol.freeRows(allocator, rows); + try std.testing.expectEqual(@as(usize, 4), rows.len); + try std.testing.expectEqual(@as(i64, 2), rows[0].get(querymod.pkField).?.int); // krakow/20 + try std.testing.expectEqual(@as(i64, 4), rows[1].get(querymod.pkField).?.int); // krakow/40 + try std.testing.expectEqual(@as(i64, 3), rows[2].get(querymod.pkField).?.int); // warsaw/10 + try std.testing.expectEqual(@as(i64, 1), rows[3].get(querymod.pkField).?.int); // warsaw/30 +} + +test "server table_select rejects a malformed expr with 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(); + + { + var table = try querymod.Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + } + + 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 err = client.tableSelect(allocator, "users", "city bogus s:warsaw", 0, 0, &.{}); + try std.testing.expectError(client_mod.ClientError.ServerBadRequest, err); +} + +fn seedAggregateOrders(allocator: Allocator, db: *Db) !void { + var orders = try querymod.Table.open(db, allocator, "orders"); + defer orders.deinit(); + try orders.put(.{ .int = 1 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "total", .value = .{ .int = 100 } }, + }); + try orders.put(.{ .int = 2 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "total", .value = .{ .int = 250 } }, + }); + try orders.put(.{ .int = 3 }, &.{ + .{ .name = "city", .value = .{ .text = "krakow" } }, + .{ .name = "total", .value = .{ .int = 40 } }, + }); +} + +test "server table_aggregate groups remote rows by field with count/sum, matching the local library result" { + 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 }, + .{ .field = "total", .kind = .sum }, + }; + const groups = try client.tableAggregateGroups(allocator, "orders", "city", &specs, 0, 0, &.{}, "", ""); + defer protocol.freeAggGroupRows(allocator, groups); +