feat: add openFourPairJoin and customerNames to join

This commit is contained in:
dualfroz committed 2026-09-06 18:02:42 +02:00
1 parent 453b21c01d
commit 6b7de3edb7
1 file changed
+216
+216
View File
@@ -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.
}