From 453b21c01d4e511622cacda320e2c0c8a41845d0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 16:52:06 +0200 Subject: [PATCH] feat: build out join --- src/join.zig | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/src/join.zig b/src/join.zig index d7feb7e..8e16a56 100644 --- a/src/join.zig +++ b/src/join.zig @@ -783,3 +783,160 @@ test "leftOuterJoin: left row missing the join field entirely still appears once } } +test "leftOuterJoin: 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 leftOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 5), joined.len); + for (joined) |pair| { + try testing.expect(pair.right != null); + } +} + +test "leftOuterJoin: empty right table yields exactly one unmatched row per left row" { + 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 } }}); + try left.put(.{ .int = 2 }, &.{.{ .name = "k", .value = .{ .int = 2 } }}); + + var right = try Table.open(&db, testing.allocator, "right"); + defer right.deinit(); + + const joined = try leftOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 2), joined.len); + for (joined) |pair| { + try testing.expect(pair.right == null); + } +} + +test "rightOuterJoin: unmatched right row survives with left=null instead of vanishing" { + 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" } }}); + + 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 } }}); // no matching customer + + const joined = try rightOuterJoin(testing.allocator, &customers, "__pk", null, &orders, "customer_id", null); + defer freeRightOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 2), joined.len); + var sawMatched = false; + var sawUnmatched = false; + for (joined) |pair| { + const customerId = pair.right.get("customer_id").?.int; + if (customerId == 1) { + try testing.expect(pair.left != null); + try testing.expectEqualStrings("alice", pair.left.?.get("name").?.text); + sawMatched = true; + } else { + try testing.expect(pair.left == null); + sawUnmatched = true; + } + } + try testing.expect(sawMatched); + try testing.expect(sawUnmatched); +} + +test "rightOuterJoin: index nested-loop join (index on left table) matches hash join results" { + 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.createIndex("__pk"); + try customers.put(.{ .int = 1 }, &.{}); + + 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 = 2 } }}); // unmatched + + const joined = try rightOuterJoin(testing.allocator, &customers, "__pk", null, &orders, "customer_id", null); + defer freeRightOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 2), joined.len); + var unmatchedCount: usize = 0; + for (joined) |pair| { + if (pair.left == null) unmatchedCount += 1; + } + try testing.expectEqual(@as(usize, 1), unmatchedCount); +} + +test "rightOuterJoin: leftPred filtering out the only match still keeps the right row with left=null" { + 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 = "active", .value = .{ .boolean = false } }}); + + var orders = try Table.open(&db, testing.allocator, "orders"); + defer orders.deinit(); + try orders.put(.{ .int = 100 }, &.{.{ .name = "customer_id", .value = .{ .int = 1 } }}); + + const activeOnly = Predicate{ .eq = .{ .field = "active", .value = .{ .boolean = true } } }; + const joined = try rightOuterJoin(testing.allocator, &customers, "__pk", activeOnly, &orders, "customer_id", null); + defer freeRightOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 1), joined.len); + try testing.expect(joined[0].left == null); +} + +test "rightOuterJoin: empty left table yields exactly one unmatched row per right row" { + 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(); + try right.put(.{ .int = 1 }, &.{.{ .name = "k", .value = .{ .int = 1 } }}); + try right.put(.{ .int = 2 }, &.{.{ .name = "k", .value = .{ .int = 2 } }}); + + const joined = try rightOuterJoin(testing.allocator, &left, "k", null, &right, "k", null); + defer freeRightOuterJoined(testing.allocator, joined); + + try testing.expectEqual(@as(usize, 2), joined.len); + for (joined) |pair| { + try testing.expect(pair.left == null); + } +} +