feat: add Condition and Page

This commit is contained in:
dualfroz committed 2026-09-08 19:54:52 +02:00
1 parent a91ac62468
commit fd91d6174c
1 file changed
+166
+166
View File
@@ -0,0 +1,166 @@
const std = @import("std");
const dbmod = @import("db.zig");
const rowmod = @import("row.zig");
const idx = @import("index.zig");
const exprmod = @import("expr.zig");
const Allocator = std.mem.Allocator;
const Db = dbmod.Db;
const Value = rowmod.Value;
const Row = rowmod.Row;
const Field = rowmod.Field;
const IndexSet = idx.IndexSet;
const CompositeIndexSet = idx.CompositeIndexSet;
const CompositeIndexSpec = idx.CompositeIndexSpec;
const UniqueSet = idx.UniqueSet;
const ForeignKeySet = idx.ForeignKeySet;
const CheckConstraintSet = idx.CheckConstraintSet;
const ReferencedBySet = idx.ReferencedBySet;
/// Reserved field name a Table stores the primary key under, so a decoded
/// Row is self-sufficient for callers that only have the Row (e.g. results
/// of a full table scan) without needing to separately decode it from the
/// row key's escaped ordered bytes.
pub const pkField = "__pk";
pub const Condition = struct {
field: []const u8,
value: Value,
};
/// Paging window for `Table.selectPage`: skip the first `offset` matches,
/// return at most `limit` of them (0 = unlimited).
pub const Page = struct {
offset: usize = 0,
limit: usize = 0,
};
/// One column of a `Table.selectPageOrdered` sort key: order matched rows
/// by `field` (via `rowmod.compare`'s total order across value types). A
/// row missing `field` entirely sorts as `Value.none`, which `compare`
/// places before every other tag - so rows lacking the sort column
/// consistently group at the front (ascending) or back (descending)
/// instead of erroring. `selectPageOrdered` takes a `[]const OrderBy`
/// (empty = unsorted): columns after the first only break ties left by
/// the ones before them, the same lexicographic rule SQL's `ORDER BY a, b`
/// follows - the sort is stable, so a `[]const OrderBy` with a single
/// entry that leaves ties unresolved behaves exactly as before this type
/// gained multi-column support (rows keep their relative scan order:
/// ascending pk for a full scan, index order for an indexed lookup).
pub const OrderBy = struct {
field: []const u8,
descending: bool = false,
};
/// Which access strategy `Table.selectPage` would take for a given
/// predicate, as reported by `Table.explain`. Mirrors the three real
/// branches of `selectPage`'s decision ladder: an unindexed full scan, a
/// single-field index (`eq` narrows to one key, `lt`/`lte`/`gt`/`gte`
/// narrow to a range), or a composite index matched on a leftmost prefix
/// of its columns (`_eq` when the prefix is the whole match, `_range` when
/// the column right after the prefix also had a range bound).
pub const PlanKind = enum(u8) {
full_scan,
index_eq,
index_range,
composite_eq,
composite_range,
};
/// `indexName` is an owned copy the caller must free with `Plan.deinit` -
/// null (and `matchedFields`/`totalFields` both 0) for `.full_scan`, which
/// names no index. For `.index_eq`/`.index_range` it is the single indexed
/// field name and `matchedFields == totalFields == 1`. For
/// `.composite_eq`/`.composite_range` it is the composite index's name and
/// `matchedFields` counts how many of the index's `totalFields` leading
/// columns the query actually pinned down (a strict prefix match still
/// narrows the scan, same leftmost-prefix rule a real multi-column B-tree
/// index gives you).
pub const Plan = struct {
kind: PlanKind,
indexName: ?[]const u8 = null,
matchedFields: usize = 0,
totalFields: usize = 0,
pub fn deinit(self: *Plan, allocator: Allocator) void {
if (self.indexName) |n| allocator.free(n);
self.* = undefined;
}
};
/// Predicate AST evaluated against a decoded Row. Leaves compare a named
/// field to a literal Value; And/Or/Not compose leaves and other predicates.
/// Predicate does not own the memory of any Value or field-name slice it
/// references - callers must keep those alive for as long as the predicate
/// tree is used.
pub const Predicate = union(enum) {
eq: Condition,
ne: Condition,
lt: Condition,
lte: Condition,
gt: Condition,
gte: Condition,
like: Condition,
/// True when `field` is absent from the row entirely, or present but
/// holding an explicit `Value.none` - a sparse row's "never set" and a
/// row that set the field to none are indistinguishable to a caller
/// asking "does this row have a real value here", same as SQL NULL.
is_null: []const u8,
is_not_null: []const u8,
and_: []const Predicate,
or_: []const Predicate,
not_: *const Predicate,
fn isNullish(v: ?Value) bool {
const val = v orelse return true;
return val == .none;
}
pub fn evaluate(self: Predicate, row: *const Row) bool {
return switch (self) {
.eq => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .eq else false,
.ne => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .eq else true,
.lt => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .lt else false,
.lte => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .gt else false,
.gt => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) == .gt else false,
.gte => |c| if (row.get(c.field)) |v| rowmod.compare(v, c.value) != .lt else false,
.like => |c| blk: {
const rowVal = row.get(c.field) orelse break :blk false;
if (rowVal != .text or c.value != .text) break :blk false;
break :blk matchLike(c.value.text, rowVal.text);
},
.is_null => |field| isNullish(row.get(field)),
.is_not_null => |field| !isNullish(row.get(field)),
.and_ => |preds| blk: {
for (preds) |p| {
if (!p.evaluate(row)) break :blk false;
}
break :blk true;
},
.or_ => |preds| blk: {
for (preds) |p| {
if (p.evaluate(row)) break :blk true;
}
break :blk false;
},
.not_ => |p| !p.evaluate(row),
};
}
};
/// SQL-style LIKE matching: `%` matches any run of characters (including
/// none), `_` matches exactly one character, every other byte in `pattern`
/// must match `text` literally. No escape character - a pattern that needs a
/// literal `%`/`_` cannot express it, same simplification `expr.zig`'s other
/// operators already make (no escaping in the predicate language at all).
/// Iterative two-pointer wildcard matcher (not recursive backtracking) so a
/// pattern like "a%a%a%a%a%a%b" against a long non-matching text stays
/// linear-ish instead of exponential: on mismatch after a `%`, only the
/// text pointer rewinds-and-retries, the pattern never re-walks from
/// scratch.
fn matchLike(pattern: []const u8, text: []const u8) bool {
var pIdx: usize = 0;
var tIdx: usize = 0;
var starIdx: ?usize = null;
var matchIdx: usize = 0;