feat: add FullOuterJoinedRow and deinit to join

This commit is contained in:
dualfroz committed 2026-09-06 15:18:14 +02:00
1 parent 16ee4d2e18
commit 03ad32ad97
1 file changed
+151
+151
View File
@@ -313,3 +313,154 @@ pub fn rightOuterJoin(
const swapped = try leftOuterJoin(allocator, right, rightField, rightPred, left, leftField, leftPred);
defer allocator.free(swapped);
const results = try allocator.alloc(RightOuterJoinedRow, swapped.len);
for (swapped, 0..) |pair, i| {
results[i] = .{ .left = pair.right, .right = pair.left };
}
return results;
}
/// One row produced by `fullOuterJoin`: both `left` and `right` are
/// optional, and every row from either side that never matched on the other
/// still appears exactly once (SQL FULL OUTER JOIN semantics - the union of
/// what `leftOuterJoin` and `rightOuterJoin` each produce).
pub const FullOuterJoinedRow = struct {
left: ?Row,
right: ?Row,
pub fn deinit(self: *FullOuterJoinedRow) void {
if (self.left) |*l| l.deinit();
if (self.right) |*r| r.deinit();
}
};
pub fn freeFullOuterJoined(allocator: Allocator, rows: []FullOuterJoinedRow) void {
for (rows) |*r| r.deinit();
allocator.free(rows);
}
/// Full outer equi-join: the union of `leftOuterJoin` (every left row,
/// matched or not) and every right row `rightOuterJoin` reports as
/// unmatched (`left == null`) - those are exactly the right-side rows a
/// plain left outer join would never produce. Built from the two existing
/// outer joins rather than a third independent matching pass, at the
/// honest cost of computing the match twice (once per direction) - the
/// same "correctness over a second matching pass" tradeoff already used by
/// `rank`/`rangeByRank` in collections.zig.
///
/// Caller owns the returned slice and every `FullOuterJoinedRow` in it;
/// free with `freeFullOuterJoined`.
pub fn fullOuterJoin(
allocator: Allocator,
left: *Table,
leftField: []const u8,
leftPred: ?Predicate,
right: *Table,
rightField: []const u8,
rightPred: ?Predicate,
) ![]FullOuterJoinedRow {
const leftSide = try leftOuterJoin(allocator, left, leftField, leftPred, right, rightField, rightPred);
errdefer freeOuterJoined(allocator, leftSide);
const rightSide = try rightOuterJoin(allocator, left, leftField, leftPred, right, rightField, rightPred);
errdefer freeRightOuterJoined(allocator, rightSide);
var unmatchedRightCount: usize = 0;
for (rightSide) |pair| {
if (pair.left == null) unmatchedRightCount += 1;
}
var results = try std.ArrayList(FullOuterJoinedRow).initCapacity(allocator, leftSide.len + unmatchedRightCount);
errdefer {
for (results.items) |*r| r.deinit();
results.deinit();
}
for (leftSide) |pair| {
results.appendAssumeCapacity(.{ .left = pair.left, .right = pair.right });
}
allocator.free(leftSide);
for (rightSide) |pair| {
if (pair.left) |l| {
var lm = l;
lm.deinit();
var rm = pair.right;
rm.deinit();
} else {
results.appendAssumeCapacity(.{ .left = null, .right = pair.right });
}
}
allocator.free(rightSide);
return results.toOwnedSlice();
}
/// Trims one of the four join result slices above to a `query.zig` `Page`
/// window (0 = unlimited, the same convention `Table.selectPage` uses) -
/// the join counterpart of pagination for `table_join`/`remote-join`.
/// Unlike `Table.selectPage`, which narrows an index scan and can stop
/// before materializing a row past the limit, a join has already had to
/// produce a full pair to know it matched at all, so there is no cheaper
/// way to honor `offset`/`limit` than windowing the already-complete
/// result - this always runs after `innerJoin`/`leftOuterJoin`/
/// `rightOuterJoin`/`fullOuterJoin` returns, never instead of it.
///
/// Takes ownership of `rows`: frees the backing slice, `.deinit()`s every
/// pair that falls outside `[offset, offset+limit)`, and returns a freshly
/// allocated slice holding only the kept pairs in their original order.
/// Callers own the returned slice and the pairs in it exactly as they
/// would have owned `rows` - free the pairs, then `allocator.free` the
/// slice (or hand ownership onward, as `server.zig`'s `table_join` handler
/// does by moving each kept pair into a `protocol.JoinPair`).
pub fn applyPage(comptime T: type, allocator: Allocator, rows: []T, page: querymod.Page) ![]T {
const total = rows.len;
const start = @min(page.offset, total);
const end = if (page.limit == 0) total else @min(start + page.limit, total);
for (rows[0..start]) |*r| r.deinit();
for (rows[end..total]) |*r| r.deinit();
const kept = end - start;
const result = allocator.alloc(T, kept) catch |err| {
for (rows[start..end]) |*r| r.deinit();
allocator.free(rows);
return err;
};
@memcpy(result, rows[start..end]);
allocator.free(rows);
return result;
}
const testing = std.testing;
const dbmod = @import("db.zig");
fn openTestDb(tmp: *std.testing.TmpDir) !dbmod.Db {
const dbPath = try tmp.dir.realpathAlloc(testing.allocator, ".");
defer testing.allocator.free(dbPath);
return dbmod.Db.open(testing.allocator, dbPath, .{});
}
test "innerJoin: unindexed hash join matches on equal field value" {
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" } }});
try customers.put(.{ .int = 2 }, &.{.{ .name = "name", .value = .{ .text = "bob" } }});
var orders = try Table.open(&db, testing.allocator, "orders");
defer orders.deinit();
try orders.put(.{ .int = 100 }, &.{ .{ .name = "customer_id", .value = .{ .int = 1 } }, .{ .name = "item", .value = .{ .text = "widget" } } });
try orders.put(.{ .int = 101 }, &.{ .{ .name = "customer_id", .value = .{ .int = 1 } }, .{ .name = "item", .value = .{ .text = "gadget" } } });
try orders.put(.{ .int = 102 }, &.{ .{ .name = "customer_id", .value = .{ .int = 2 } }, .{ .name = "item", .value = .{ .text = "gizmo" } } });
try orders.put(.{ .int = 103 }, &.{.{ .name = "customer_id", .value = .{ .int = 999 } }}); // no matching customer
const joined = try innerJoin(testing.allocator, &customers, "__pk", null, &orders, "customer_id", null);
defer freeJoined(testing.allocator, joined);
try testing.expectEqual(@as(usize, 3), joined.len);