From 6b7de3edb70ec4a361875b44970f71b49cdc24b8 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 18:02:42 +0200 Subject: [PATCH] feat: add openFourPairJoin and customerNames to join --- src/join.zig | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/src/join.zig b/src/join.zig index 8e16a56..e7fc83d 100644 --- a/src/join.zig +++ b/src/join.zig @@ -940,3 +940,219 @@ test "rightOuterJoin: empty left table yields exactly one unmatched row per righ } } +test "rightOuterJoin: one-to-many fan-out on the matched side still produces one row per match" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + var left = try Table.open(&db, testing.allocator, "left"); + defer left.deinit(); + try left.put(.{ .int = 1 }, &.{.{ .name = "k", .value = .{ .int = 42 } }}); + + var right = try Table.open(&db, testing.allocator, "right"); + defer right.deinit(); + var i: i64 = 0; + while (i < 5) : (i += 1) { + try right.put(.{ .int = i }, &.{.{ .name = "k", .value = .{ .int = 42 } }}); + } + + const joined = try rightOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeRightOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 5), joined.len); + for (joined) |pair| { + try testing.expect(pair.left != null); + } +} + +test "fullOuterJoin: rows unmatched on either side survive, matched pairs appear once" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + var customers = try Table.open(&db, testing.allocator, "customers"); + defer customers.deinit(); + try customers.put(.{ .int = 1 }, &.{.{ .name = "name", .value = .{ .text = "alice" } }}); // matches + try customers.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "bob" } }}); // never ordered + + var orders = try Table.open(&db, testing.allocator, "orders"); + defer orders.deinit(); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + try orders.put(.{ .int = 101 }, &.{.{ .name = "customer_id", .value = .{ .int = 999 } }}); // orphan order + + const joined = try fullOuterJoin(testing.allocator, &customers, "__pk", null, &orders, "customer_id", null); + defer freeFullOuterJoined(testing.allocator, joined); + + // alice+order100 (matched), bob+null (left-only), null+order101 (right-only). + try testing.expectEqual(@as(usize, 3), joined.len); + var sawMatched = false; + var sawLeftOnly = false; + var sawRightOnly = false; + for (joined) |pair| { + if (pair.left != null and pair.right != null) { + try testing.expectEqualStrings("alice", pair.left.?.get("name").?.text); + sawMatched = true; + } else if (pair.left != null and pair.right == null) { + try testing.expectEqualStrings("bob", pair.left.?.get("name").?.text); + sawLeftOnly = true; + } else if (pair.left == null and pair.right != null) { + try testing.expectEqual(@as(i64, 999), pair.right.?.get("customer_id").?.int); + sawRightOnly = true; + } else { + try testing.expect(false); // unreachable: both sides null never happens + } + } + try testing.expect(sawMatched); + try testing.expect(sawLeftOnly); + try testing.expect(sawRightOnly); +} + +test "fullOuterJoin: fully disjoint tables yield exactly one unmatched row per side" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + var left = try Table.open(&db, testing.allocator, "left"); + defer left.deinit(); + try left.put(.{ .int = 1 }, &.{.{ .name = "k", .value = .{ .int = 1 } }}); + + var right = try Table.open(&db, testing.allocator, "right"); + defer right.deinit(); + try right.put(.{ .int = 1 }, &.{.{ .name = "k", .value = .{ .int = 2 } }}); + + const joined = try fullOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeFullOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 2), joined.len); + var leftOnly: usize = 0; + var rightOnly: usize = 0; + for (joined) |pair| { + if (pair.left != null and pair.right == null) leftOnly += 1; + if (pair.left == null and pair.right != null) rightOnly += 1; + } + try testing.expectEqual(@as(usize, 1), leftOnly); + try testing.expectEqual(@as(usize, 1), rightOnly); +} + +test "fullOuterJoin: both tables empty yields zero rows" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + var left = try Table.open(&db, testing.allocator, "left"); + defer left.deinit(); + var right = try Table.open(&db, testing.allocator, "right"); + defer right.deinit(); + + const joined = try fullOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeFullOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 0), joined.len); +} + +fn openFourPairJoin(tmp: *std.testing.TmpDir, db: *dbmod.Db) ![]JoinedRow { + var customers = try Table.open(db, testing.allocator, "customers"); + defer customers.deinit(); + var orders = try Table.open(db, testing.allocator, "orders"); + defer orders.deinit(); + + var i: i64 = 1; + while (i <= 4) : (i += 1) { + try customers.put(.{ .int = i }, &.{.{ .name = "name", .value = .{ .int = i } }}); + try orders.put(.{ .int = i * 100 }, &.{.{ .name = "customer_id", .value = .{ .int = i } }}); + } + _ = tmp; + return innerJoin(testing.allocator, &customers, "__pk", null, &orders, "customer_id", null); +} + +fn customerNames(pairs: []const JoinedRow) [4]i64 { + var out: [4]i64 = undefined; + for (pairs, 0..) |p, i| out[i] = p.left.get("name").?.int; + return out; +} + +test "applyPage with offset and limit keeps a middle window in order" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + const joined = try openFourPairJoin(&tmp, &db); + try testing.expectEqual(@as(usize, 4), joined.len); + + const paged = try applyPage(JoinedRow, testing.allocator, joined, .{ .offset = 1, .limit = 2 }); + defer freeJoined(testing.allocator, paged); + + try testing.expectEqual(@as(usize, 2), paged.len); + try testing.expectEqual(@as(i64, 2), paged[0].left.get("name").?.int); + try testing.expectEqual(@as(i64, 3), paged[1].left.get("name").?.int); +} + +test "applyPage with limit 0 returns every row, matching unlimited select" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + const joined = try openFourPairJoin(&tmp, &db); + const paged = try applyPage(JoinedRow, testing.allocator, joined, .{}); + defer freeJoined(testing.allocator, paged); + + try testing.expectEqualSlices(i64, &.{ 1, 2, 3, 4 }, &customerNames(paged)); +} + +test "applyPage with offset past the end returns nothing" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + const joined = try openFourPairJoin(&tmp, &db); + const paged = try applyPage(JoinedRow, testing.allocator, joined, .{ .offset = 50 }); + defer freeJoined(testing.allocator, paged); + + try testing.expectEqual(@as(usize, 0), paged.len); +} + +test "applyPage with limit larger than the remaining rows stops at the end" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + const joined = try openFourPairJoin(&tmp, &db); + const paged = try applyPage(JoinedRow, testing.allocator, joined, .{ .offset = 3, .limit = 100 }); + defer freeJoined(testing.allocator, paged); + + try testing.expectEqual(@as(usize, 1), paged.len); + try testing.expectEqual(@as(i64, 4), paged[0].left.get("name").?.int); +} + +test "applyPage propagates OutOfMemory from its own result allocation without leaking any row" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTestDb(&tmp); + defer db.close(); + + // `joined` itself is built with the real `testing.allocator` - every + // `Row` inside stores that same allocator (see `row.zig`'s `Row.init`), + // so `applyPage`'s error path frees each one through it regardless of + // which allocator `applyPage` was asked to allocate its OWN result + // slice with below. Isolates the OOM path to exactly one call site: + // the `allocator.alloc(T, kept)` in `applyPage` itself, not the join + // that already succeeded. + const joined = try openFourPairJoin(&tmp, &db); + try testing.expectEqual(@as(usize, 4), joined.len); + + var failing = testing.FailingAllocator.init(testing.allocator, .{ .fail_index = 0 }); + try testing.expectError(error.OutOfMemory, applyPage(JoinedRow, failing.allocator(), joined, .{ .offset = 1, .limit = 2 })); + // No explicit assertion beyond this: `testing.allocator`'s leak + // detector at process exit is the real check - every one of the 4 + // `JoinedRow`s (2 kept by the window, 2 trimmed outside it) must have + // been `.deinit()`-ed on the error path in `applyPage`, and the + // `rows` backing slice itself freed, or this test fails on leak. +}