diff --git a/src/main.zig b/src/main.zig index 885dab8..e0958d2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1176,6 +1176,179 @@ fn extractRangeFlag(allocator: std.mem.Allocator, args: []const []const u8) !str const SelectFlags = struct { page: PageFlags = .{}, orderBy: []const cobalt.query.OrderBy = &.{} }; /// `table select`/`remote-select`-only counterpart of `extractPageFlags`: +/// same `--limit`/`--offset` handling plus `--order-by +/// field[:desc][,field[:desc]...]` (one or more comma-separated columns, +/// applied left to right - later columns only break ties left by earlier +/// ones, same as SQL's `ORDER BY a, b`), kept as a SEPARATE function (not +/// folded into `extractPageFlags` itself) because `extractPageFlags` is +/// also shared by `table join`/`table aggregate` (and their remote +/// mirrors) - if `--order-by` were recognized there too, it would be +/// silently consumed and have no effect, since neither join nor aggregate +/// sorts its result. Better a flag those two commands don't recognize at +/// all than one they swallow without honoring. Caller owns and frees +/// `rest` AND `flags.orderBy` (empty slice when the flag was never given, +/// still safe to free). +fn extractSelectFlags(allocator: std.mem.Allocator, args: []const []const u8) !struct { flags: SelectFlags, rest: [][]const u8 } { + var flags = SelectFlags{}; + var rest = std.ArrayList([]const u8).init(allocator); + errdefer rest.deinit(); + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const isLimit = std.mem.eql(u8, args[i], "--limit"); + const isOffset = std.mem.eql(u8, args[i], "--offset"); + const isOrderBy = std.mem.eql(u8, args[i], "--order-by"); + if (isLimit or isOffset) { + i += 1; + if (i >= args.len) return error.InvalidPageFlag; + const n = std.fmt.parseInt(u32, args[i], 10) catch return error.InvalidPageFlag; + if (isLimit) flags.page.limit = n else flags.page.offset = n; + } else if (isOrderBy) { + i += 1; + if (i >= args.len) return error.InvalidPageFlag; + allocator.free(flags.orderBy); + flags.orderBy = try parseOrderByList(allocator, args[i]); + } else { + try rest.append(args[i]); + } + } + return .{ .flags = flags, .rest = try rest.toOwnedSlice() }; +} + +/// Parses `--order-by`'s `field[:desc]` value, shared by `parseOrderByList` +/// (both `table select` and `remote-select` route through it) and +/// repl.zig's identically-shaped copy of the same flag. The suffix after +/// the colon must be exactly `desc` or `asc` - anything else (a typo'd +/// `:dsc`) is rejected rather than silently defaulting to ascending, +/// matching the CLI's usual "malformed input is an error, not a guess" +/// convention. +fn parseOrderByFlag(text: []const u8) !cobalt.query.OrderBy { + if (std.mem.indexOfScalar(u8, text, ':')) |colon| { + const field = text[0..colon]; + const dir = text[colon + 1 ..]; + if (field.len == 0) return error.InvalidPageFlag; + if (std.mem.eql(u8, dir, "desc")) return .{ .field = field, .descending = true }; + if (std.mem.eql(u8, dir, "asc")) return .{ .field = field, .descending = false }; + return error.InvalidPageFlag; + } + if (text.len == 0) return error.InvalidPageFlag; + return .{ .field = text }; +} + +/// Splits `--order-by`'s comma-separated value into one or more columns, +/// each parsed by `parseOrderByFlag` - `city,age:desc` sorts by `city` +/// ascending, breaking ties by `age` descending. A leading, trailing, or +/// doubled comma yields an empty segment, rejected the same way a +/// malformed direction suffix is (not silently skipped), so `city,,age` is +/// an error rather than a silently-collapsed two-column sort. Caller owns +/// and frees the returned slice. +fn parseOrderByList(allocator: std.mem.Allocator, text: []const u8) ![]cobalt.query.OrderBy { + var list = std.ArrayList(cobalt.query.OrderBy).init(allocator); + errdefer list.deinit(); + var it = std.mem.splitScalar(u8, text, ','); + while (it.next()) |segment| { + try list.append(try parseOrderByFlag(segment)); + } + return list.toOwnedSlice(); +} + +/// Splits `--set`'s comma-separated `name=value[,name=value...]` value into +/// a list of Fields, each parsed by `parseField` - the `table update`/ +/// `remote-update` counterpart of `parseOrderByList`. A leading, trailing, +/// or doubled comma yields an empty segment, which `parseField`'s missing +/// `=` check rejects the same way a malformed order-by suffix is (not +/// silently skipped). Caller owns and frees the returned slice. +fn parseSetList(allocator: std.mem.Allocator, text: []const u8) ![]Field { + var list = std.ArrayList(Field).init(allocator); + errdefer list.deinit(); + var it = std.mem.splitScalar(u8, text, ','); + while (it.next()) |segment| { + try list.append(try parseField(segment)); + } + return list.toOwnedSlice(); +} + +/// Pulls a single required `--set name=value[,name=value...]` flag out of a +/// `table update`/`remote-update` argument list, returning the parsed field +/// list alongside the remaining tokens (destined to be joined into the expr +/// text) - the update counterpart of `extractSelectFlags`. A missing +/// `--set` is `error.MissingSetFlag`: unlike a `WHERE` predicate (optional, +/// empty text means every row), a `SET`-less `UPDATE` names nothing to +/// change and is rejected rather than silently accepted as a no-op. Caller +/// owns and frees `rest` and the returned `sets` slice. +fn extractSetFlag(allocator: std.mem.Allocator, args: []const []const u8) !struct { sets: []Field, rest: [][]const u8 } { + var sets: []Field = &.{}; + errdefer allocator.free(sets); + var rest = std.ArrayList([]const u8).init(allocator); + errdefer rest.deinit(); + + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--set")) { + i += 1; + if (i >= args.len) return error.InvalidSetFlag; + allocator.free(sets); + sets = parseSetList(allocator, args[i]) catch return error.InvalidSetFlag; + } else { + try rest.append(args[i]); + } + } + if (sets.len == 0) return error.MissingSetFlag; + return .{ .sets = sets, .rest = try rest.toOwnedSlice() }; +} + +fn printValue(stdout: anytype, value: Value) !void { + switch (value) { + .none => try stdout.writeAll("(none)"), + .boolean => |b| try stdout.print("{}", .{b}), + .int => |i| try stdout.print("{d}", .{i}), + .float => |f| try stdout.print("{d}", .{f}), + .text => |s| try stdout.writeAll(s), + .bytes => |b| for (b) |byte| try stdout.print("{x:0>2}", .{byte}), + } +} + +fn printRow(stdout: anytype, row: cobalt.row.Row) !void { + for (row.fields.items, 0..) |f, i| { + if (i > 0) try stdout.writeAll("\t"); + try stdout.print("{s}=", .{f.name}); + try printValue(stdout, f.value); + } + try stdout.writeAll("\n"); +} + +/// Prints a `query.Plan` in the same human-readable shape for `table +/// explain`, `remote-explain`, and the repl's `table explain` - one line +/// naming the strategy `selectPage` would actually take, not a guess. +fn printPlan(stdout: anytype, plan: cobalt.query.Plan) !void { + switch (plan.kind) { + .full_scan => try stdout.writeAll("full scan (no usable index)\n"), + .index_eq => try stdout.print("index '{s}' (equality)\n", .{plan.indexName.?}), + .index_range => try stdout.print("index '{s}' (range)\n", .{plan.indexName.?}), + .composite_eq => try stdout.print("composite index '{s}' (equality, {d}/{d} columns matched)\n", .{ plan.indexName.?, plan.matchedFields, plan.totalFields }), + .composite_range => try stdout.print("composite index '{s}' (range, {d}/{d} columns matched)\n", .{ plan.indexName.?, plan.matchedFields, plan.totalFields }), + } +} + +fn parseAggKind(name: []const u8) ?cobalt.AggKind { + if (std.mem.eql(u8, name, "count")) return .count; + if (std.mem.eql(u8, name, "sum")) return .sum; + if (std.mem.eql(u8, name, "avg")) return .avg; + if (std.mem.eql(u8, name, "min")) return .min; + if (std.mem.eql(u8, name, "max")) return .max; + if (std.mem.eql(u8, name, "distinct")) return .count_distinct; + return null; +} + +/// Parses a comma-separated `table aggregate` spec list (e.g. +/// `count,sum:score,max:score,distinct:city`) into `AggSpec`s. Every kind +/// but bare `count` (COUNT(*)) requires a `:field` suffix, validated with +/// the same identifier rule tables/indexes already enforce. Returned +/// `field` slices borrow from `text` (itself a CLI arg living for the +/// process), so callers only need to free the returned slice itself, not +/// each field. +fn parseAggSpecs(allocator: std.mem.Allocator, text: []const u8) ![]cobalt.AggSpec { + var specs = std.ArrayList(cobalt.AggSpec).init(allocator); } fn runVerify(allocator: std.mem.Allocator, stdout: anytype, dir: []const u8, count: usize) !void {