diff --git a/src/query.zig b/src/query.zig index 3c9e605..87dd3d6 100644 --- a/src/query.zig +++ b/src/query.zig @@ -1595,3 +1595,160 @@ test "compound and predicate narrows via one indexed conjunct" { .{ .name = "age", .value = .{ .int = 40 } }, }); + const preds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .gte = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + const results = try table.select(.{ .and_ = &preds }); + defer freeRows(allocator, results); + try std.testing.expectEqual(@as(usize, 1), results.len); + try std.testing.expectEqual(@as(i64, 2), results[0].get(pkField).?.int); +} + +test "not and or predicates evaluate via full scan" { + 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 = "age", .value = .{ .int = 10 } }}); + try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 20 } }}); + try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 30 } }}); + + const orPreds = [_]Predicate{ + .{ .eq = .{ .field = "age", .value = .{ .int = 10 } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + const orResults = try table.select(.{ .or_ = &orPreds }); + defer freeRows(allocator, orResults); + try std.testing.expectEqual(@as(usize, 2), orResults.len); + + const inner = Predicate{ .eq = .{ .field = "age", .value = .{ .int = 20 } } }; + const notResult = try table.select(.{ .not_ = &inner }); + defer freeRows(allocator, notResult); + try std.testing.expectEqual(@as(usize, 2), notResult.len); +} + +test "is_null/is_not_null evaluate against missing fields, explicit none, and present values" { + const allocator = std.testing.allocator; + var sparse = Row.init(allocator); + defer sparse.deinit(); + try sparse.set("age", .{ .int = 10 }); + + var withNone = Row.init(allocator); + defer withNone.deinit(); + try withNone.set("city", .{ .none = {} }); + + var withCity = Row.init(allocator); + defer withCity.deinit(); + try withCity.set("city", .{ .text = "warsaw" }); + + const isNull = Predicate{ .is_null = "city" }; + const isNotNull = Predicate{ .is_not_null = "city" }; + + try std.testing.expect(isNull.evaluate(&sparse)); + try std.testing.expect(!isNotNull.evaluate(&sparse)); + try std.testing.expect(isNull.evaluate(&withNone)); + try std.testing.expect(!isNotNull.evaluate(&withNone)); + try std.testing.expect(!isNull.evaluate(&withCity)); + try std.testing.expect(isNotNull.evaluate(&withCity)); +} + +test "is_null selects only sparse rows missing the field, over a full scan" { + 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, "people"); + defer table.deinit(); + + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + try table.put(.{ .int = 2 }, &.{.{ .name = "age", .value = .{ .int = 40 } }}); + try table.put(.{ .int = 3 }, &.{.{ .name = "age", .value = .{ .int = 50 } }}); + + var plan = try table.explain(allocator, .{ .is_null = "city" }); + defer plan.deinit(allocator); + try std.testing.expectEqual(PlanKind.full_scan, plan.kind); + + const missing = try table.select(.{ .is_null = "city" }); + defer freeRows(allocator, missing); + try std.testing.expectEqual(@as(usize, 2), missing.len); + + const present = try table.select(.{ .is_not_null = "city" }); + defer freeRows(allocator, present); + try std.testing.expectEqual(@as(usize, 1), present.len); + + // is_null on an indexed field still falls back to full_scan (not + // indexable), but the result is unaffected by the index's presence. + try table.createIndex("age"); + const stillMissing = try table.select(.{ .is_null = "city" }); + defer freeRows(allocator, stillMissing); + try std.testing.expectEqual(@as(usize, 2), stillMissing.len); +} + +test "invalid identifiers are rejected" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try std.testing.expectError(error.InvalidIdentifier, Table.open(&db, allocator, "")); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try std.testing.expectError(error.InvalidIdentifier, table.createIndex("bad\x00field")); +} + +test "selectPage skips offset and stops at limit on a full scan" { + 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(); + + // Row keys are the pk's order-preserving encoding, so full-scan order + // is ascending pk regardless of insertion order. + var pk: i64 = 10; + while (pk >= 1) : (pk -= 1) { + try table.put(.{ .int = pk }, &.{}); + } + + const page = try table.selectPage(null, .{ .offset = 3, .limit = 4 }); + defer freeRows(allocator, page); + try std.testing.expectEqual(@as(usize, 4), page.len); + var expected: i64 = 4; + for (page) |r| { + try std.testing.expectEqual(expected, r.get(pkField).?.int); + expected += 1; + } +} + +test "selectPage with limit 0 matches unlimited select" { + 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(); + var pk: i64 = 1; + while (pk <= 5) : (pk += 1) try table.put(.{ .int = pk }, &.{}); + + const all = try table.select(null); + defer freeRows(allocator, all); + const page = try table.selectPage(null, .{}); + defer freeRows(allocator, page); + try std.testing.expectEqual(all.len, page.len); +} +