From 4fa334072e58204d557bac91682dba4566395265 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 21:32:41 +0200 Subject: [PATCH] feat: add compositeCursorForMatch and valueTupleEql to query --- src/query.zig | 159 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/query.zig b/src/query.zig index 0d2c03d..a295ab4 100644 --- a/src/query.zig +++ b/src/query.zig @@ -1275,3 +1275,162 @@ pub const Table = struct { return null; } + fn compositeCursorForMatch(self: *Table, match: CompositeMatch) !IndexCursor { + const spec = self.composite.specs.items[match.specIdx]; + const eqValues = match.values[0..match.matchedLen]; + if (match.rangeLow != null or match.rangeHigh != null) { + return .{ .compositeRange = try idx.scanCompositeRangeCursor(self.db, self.allocator, self.name, spec.name, eqValues, match.rangeLow, match.rangeHigh) }; + } + return .{ .compositeEq = try idx.scanCompositeEqCursor(self.db, self.allocator, self.name, spec.name, eqValues) }; + } +}; + +fn valueTupleEql(a: []const Value, b: []const Value) bool { + if (a.len != b.len) return false; + for (a, b) |av, bv| { + if (!Value.eql(av, bv)) return false; + } + return true; +} + +pub fn freeRows(allocator: Allocator, rows: []Row) void { + for (rows) |*r| r.deinit(); + allocator.free(rows); +} + +/// Stable in-place sort of `rows` by `order`, using `rowmod.compare`'s +/// total order across value types (see `OrderBy`'s doc comment for how a +/// missing field sorts). Columns are compared left to right: the first +/// entry decides unless it ties (`.eq`), in which case the next entry +/// breaks the tie, and so on - a plain lexicographic comparator chain, the +/// same rule SQL's `ORDER BY a, b, c` follows. `std.sort.block` rather than +/// the faster unstable `std.sort.pdq` specifically so ties that survive +/// every column preserve scan order, matching what `OrderBy`'s doc comment +/// promises callers. +fn sortRowsBy(rows: []Row, order: []const OrderBy) void { + const Ctx = struct { + order: []const OrderBy, + + fn lessThan(ctx: @This(), a: Row, b: Row) bool { + for (ctx.order) |ob| { + const av = a.get(ob.field) orelse Value{ .none = {} }; + const bv = b.get(ob.field) orelse Value{ .none = {} }; + const cmp = rowmod.compare(av, bv); + if (cmp == .eq) continue; + return if (ob.descending) cmp == .gt else cmp == .lt; + } + return false; + } + }; + std.sort.block(Row, rows, Ctx{ .order = order }, Ctx.lessThan); +} + +/// Slices an already-materialized, unpaged match set (as `selectAllMatches` +/// returns) down to `page`'s offset/limit window, freeing every row that +/// falls outside it and the original backing allocation - the ordered +/// counterpart of the early-stop windowing `selectByIndexPage`/ +/// `scanFilteredPage` do inline while scanning. `rows` must not be used +/// after this call regardless of error (see `errdefer` at the call site). +fn applyRowPage(allocator: Allocator, rows: []Row, page: Page) ![]Row { + const start = @min(page.offset, rows.len); + const end = if (page.limit == 0) rows.len else @min(rows.len, start + page.limit); + for (rows[0..start]) |*r| r.deinit(); + for (rows[end..]) |*r| r.deinit(); + const kept = rows[start..end]; + const out = try allocator.alloc(Row, kept.len); + @memcpy(out, kept); + allocator.free(rows); + return out; +} + +fn openTemp(tmp: *std.testing.TmpDir, allocator: Allocator) !Db { + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + return Db.open(allocator, dbPath, .{}); +} + +test "table put get delete round trip" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + + try table.put(.{ .int = 1 }, &.{ + .{ .name = "name", .value = .{ .text = "ada" } }, + .{ .name = "age", .value = .{ .int = 36 } }, + }); + + var got = (try table.get(.{ .int = 1 })).?; + defer got.deinit(); + try std.testing.expectEqualStrings("ada", got.get("name").?.text); + try std.testing.expectEqual(@as(i64, 36), got.get("age").?.int); + try std.testing.expectEqual(@as(i64, 1), got.get(pkField).?.int); + + try table.delete(.{ .int = 1 }); + try std.testing.expect((try table.get(.{ .int = 1 })) == null); +} + +test "table put replaces whole row" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + + try table.put(.{ .int = 1 }, &.{ + .{ .name = "name", .value = .{ .text = "ada" } }, + .{ .name = "age", .value = .{ .int = 36 } }, + }); + try table.put(.{ .int = 1 }, &.{ + .{ .name = "name", .value = .{ .text = "ada" } }, + }); + + var got = (try table.get(.{ .int = 1 })).?; + defer got.deinit(); + try std.testing.expect(got.get("age") == null); + try std.testing.expectEqualStrings("ada", got.get("name").?.text); +} + +test "createIndex backfills then eq select uses index" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + try table.put(.{ .int = 2 }, &.{.{ .name = "city", .value = .{ .text = "krakow" } }}); + try table.put(.{ .int = 3 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + + try table.createIndex("city"); + try std.testing.expect(table.hasIndex("city")); + + const results = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }); + defer freeRows(allocator, results); + try std.testing.expectEqual(@as(usize, 2), results.len); +} + +test "like predicate filters by pattern and never uses an index on that field" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + // Index the very field the like-predicate targets: `like` must still + // fall back to a full scan (`Value.compare`-based index entries have no + // notion of substring matching), never mistake this for an eq lookup. + try table.createIndex("name"); +