From 03ad32ad97ccedb48c0bbc27b483b70c64a3b106 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 15:18:14 +0200 Subject: [PATCH] feat: add FullOuterJoinedRow and deinit to join --- src/join.zig | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/join.zig b/src/join.zig index 73f44a9..559de60 100644 --- a/src/join.zig +++ b/src/join.zig @@ -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); +