lib/sql/src/plan.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const branch = @import("branch.zig");
  3 const catalog_mod = @import("catalog.zig");
  4 const file = @import("file.zig");
  5 const index_mod = @import("index.zig");
  6 const relation_mod = @import("relation.zig");
  7 const row = @import("row.zig");
  8 const session_mod = @import("session/root.zig");
  9 const trace = @import("trace.zig");
 10 const version = @import("version.zig");
 11 const wal = @import("wal.zig");
 12 
 13 const Allocator = std.mem.Allocator;
 14 
 15 const PlanError = error{
 16     PlanChanged,
 17 };
 18 
 19 pub const Error = catalog_mod.Error || version.Error || session_mod.DatabaseError || PlanError;
 20 
 21 pub const PlanKey = struct {
 22     relation: version.Hash,
 23     schema: version.Hash,
 24     stats: version.Hash,
 25     parameters: version.Hash,
 26 
 27     pub fn fromRoot(root: *const version.RelationRoot, parameters: version.Hash) PlanKey {
 28         return .{
 29             .relation = root.hash,
 30             .schema = root.schema,
 31             .stats = root.stats.hash,
 32             .parameters = parameters,
 33         };
 34     }
 35 
 36     pub fn fromRelationKey(relation_key: version.RelationKey, parameters: version.Hash) PlanKey {
 37         return .{
 38             .relation = relation_key.hash,
 39             .schema = relation_key.schema,
 40             .stats = relation_key.stats,
 41             .parameters = parameters,
 42         };
 43     }
 44 
 45     pub fn same(left: PlanKey, right: PlanKey) bool {
 46         return version.same(left.relation, right.relation) and
 47             version.same(left.schema, right.schema) and
 48             version.same(left.stats, right.stats) and
 49             version.same(left.parameters, right.parameters);
 50     }
 51 };
 52 
 53 pub const Validation = enum {
 54     content,
 55     shape,
 56 };
 57 
 58 pub fn emptyParameterShape() version.Hash {
 59     return version.emptyHash("sql.parameters.none");
 60 }
 61 
 62 fn shapeKey(name: []const u8, handle: *const catalog_mod.RelationHandle, stats: ?*const catalog_mod.RelationStats, parameters: version.Hash) PlanKey {
 63     const schema_hash = version.schemaHash(handle.definitions, handle.index_definitions);
 64     const stats_hash = statsShapeHash(stats);
 65     var relation = ShapeBuilder.init("sql.plan.relation.shape");
 66     relation.bytes(name);
 67     relation.hash(schema_hash);
 68     relation.hash(stats_hash);
 69     relation.writeU32(handle.relation.table.rows.root_page);
 70     relation.writeU64(handle.specs.len);
 71     for (handle.specs) |spec| {
 72         relation.writeU32(spec.root_page);
 73         relation.writeU64(spec.fields.len);
 74         for (spec.fields) |field| relation.writeU64(field);
 75         relation.writeU64(spec.columns.len);
 76         for (spec.columns) |column| relation.writeU64(@backingInt(column.collation));
 77     }
 78     return .{
 79         .relation = relation.finish(),
 80         .schema = schema_hash,
 81         .stats = stats_hash,
 82         .parameters = parameters,
 83     };
 84 }
 85 
 86 fn statsShapeHash(stats: ?*const catalog_mod.RelationStats) version.Hash {
 87     const relation_stats = stats orelse return version.emptyHash("sql.stats.none");
 88     var builder = ShapeBuilder.init("sql.plan.stats.shape");
 89     builder.writeU32(relation_stats.table_root_page);
 90     builder.summary(relation_stats.table);
 91     builder.writeU64(relation_stats.indexes.len);
 92     for (relation_stats.indexes) |index_stats| {
 93         builder.bytes(index_stats.name);
 94         builder.writeU32(index_stats.root_page);
 95         builder.summary(index_stats.summary);
 96         builder.distribution(index_stats.distribution);
 97     }
 98     return builder.finish();
 99 }
100 
101 const ShapeBuilder = struct {
102     hasher: std.crypto.hash.sha2.Sha256,
103 
104     fn init(tag: []const u8) ShapeBuilder {
105         var builder = ShapeBuilder{ .hasher = std.crypto.hash.sha2.Sha256.init(.{}) };
106         builder.bytes(tag);
107         return builder;
108     }
109 
110     fn finish(self: *ShapeBuilder) version.Hash {
111         var digest: version.Hash = undefined;
112         self.hasher.final(&digest);
113         return digest;
114     }
115 
116     fn bytes(self: *ShapeBuilder, value: []const u8) void {
117         self.writeU64(value.len);
118         self.hasher.update(value);
119     }
120 
121     fn hash(self: *ShapeBuilder, value: version.Hash) void {
122         self.hasher.update(&value);
123     }
124 
125     fn writeU32(self: *ShapeBuilder, value: u32) void {
126         var encoded: [4]u8 = undefined;
127         std.mem.writeInt(u32, &encoded, value, .big);
128         self.hasher.update(&encoded);
129     }
130 
131     fn writeU64(self: *ShapeBuilder, value: anytype) void {
132         var encoded: [8]u8 = undefined;
133         std.mem.writeInt(u64, &encoded, @intCast(value), .big);
134         self.hasher.update(&encoded);
135     }
136 
137     fn summary(self: *ShapeBuilder, value: @import("tree.zig").Summary) void {
138         self.writeU64(value.branch_pages);
139         self.writeU64(value.leaf_pages);
140         self.writeU64(value.overflow_pages);
141         self.writeU64(value.entries);
142         self.writeU64(value.inline_records);
143         self.writeU64(value.overflow_records);
144         self.writeU64(value.max_depth);
145         self.writeU64(value.key_bytes);
146         self.writeU64(value.record_bytes);
147         self.writeU64(value.value_bytes);
148     }
149 
150     fn distribution(self: *ShapeBuilder, value: catalog_mod.IndexDistribution) void {
151         self.writeU64(value.distinct_values);
152         self.writeU64(value.max_equal);
153         self.samples(value.samples);
154         self.bytes(value.sample_keys);
155         self.writeU64(value.prefixes.len);
156         for (value.prefixes) |prefix| {
157             self.writeU64(prefix.field_count);
158             self.writeU64(prefix.distinct_values);
159             self.writeU64(prefix.max_equal);
160             self.samples(prefix.samples);
161             self.bytes(prefix.sample_keys);
162         }
163     }
164 
165     fn samples(self: *ShapeBuilder, values: []const catalog_mod.IndexSample) void {
166         self.writeU64(values.len);
167         for (values) |sample| {
168             self.bytes(sample.key);
169             self.writeU64(sample.less_than);
170             self.writeU64(sample.equal_count);
171             self.writeU64(sample.less_distinct);
172         }
173     }
174 };
175 
176 pub const RelationExecution = struct {
177     reader: relation_mod.Reader,
178 
179     pub fn get(self: RelationExecution, allocator: Allocator, rowid: i64) Error!?[]u8 {
180         return try self.reader.get(allocator, rowid);
181     }
182 
183     pub fn scan(
184         self: RelationExecution,
185         target: *relation_mod.Scan,
186         allocator: Allocator,
187         start: ?i64,
188         end: ?i64,
189     ) Error!void {
190         try self.reader.scan(target, allocator, start, end);
191     }
192 
193     pub fn lookup(
194         self: RelationExecution,
195         target: *index_mod.Scan,
196         allocator: Allocator,
197         index_slot: usize,
198         prefix: []const row.Value,
199     ) Error!void {
200         try self.reader.lookup(target, allocator, index_slot, prefix);
201     }
202 
203     pub fn indexScan(
204         self: RelationExecution,
205         target: *index_mod.Scan,
206         allocator: Allocator,
207         index_slot: usize,
208         start: ?[]const row.Value,
209         end: ?[]const row.Value,
210     ) Error!void {
211         try self.reader.indexScan(target, allocator, index_slot, start, end);
212     }
213 
214     pub fn indexRange(
215         self: RelationExecution,
216         target: *index_mod.Scan,
217         allocator: Allocator,
218         index_slot: usize,
219         start: ?index_mod.Bound,
220         end: ?index_mod.Bound,
221     ) Error!void {
222         try self.reader.indexRange(target, allocator, index_slot, start, end);
223     }
224 };
225 
226 pub const RelationRead = struct {
227     lease: file.ReadLease,
228     execution: RelationExecution,
229 
230     pub fn deinit(self: *RelationRead) void {
231         self.lease.deinit();
232         self.* = undefined;
233     }
234 
235     pub fn borrow(self: *const RelationRead) RelationExecution {
236         return self.execution;
237     }
238 
239     pub fn get(self: *const RelationRead, allocator: Allocator, rowid: i64) Error!?[]u8 {
240         return try self.execution.get(allocator, rowid);
241     }
242 
243     pub fn scan(
244         self: *const RelationRead,
245         target: *relation_mod.Scan,
246         allocator: Allocator,
247         start: ?i64,
248         end: ?i64,
249     ) Error!void {
250         try self.execution.scan(target, allocator, start, end);
251     }
252 
253     pub fn lookup(
254         self: *const RelationRead,
255         target: *index_mod.Scan,
256         allocator: Allocator,
257         index_slot: usize,
258         prefix: []const row.Value,
259     ) Error!void {
260         try self.execution.lookup(target, allocator, index_slot, prefix);
261     }
262 
263     pub fn indexScan(
264         self: *const RelationRead,
265         target: *index_mod.Scan,
266         allocator: Allocator,
267         index_slot: usize,
268         start: ?[]const row.Value,
269         end: ?[]const row.Value,
270     ) Error!void {
271         try self.execution.indexScan(target, allocator, index_slot, start, end);
272     }
273 
274     pub fn indexRange(
275         self: *const RelationRead,
276         target: *index_mod.Scan,
277         allocator: Allocator,
278         index_slot: usize,
279         start: ?index_mod.Bound,
280         end: ?index_mod.Bound,
281     ) Error!void {
282         try self.execution.indexRange(target, allocator, index_slot, start, end);
283     }
284 };
285 
286 pub const PreparedRelation = struct {
287     allocator: Allocator,
288     catalog: catalog_mod.Catalog,
289     name: []u8,
290     schema: catalog_mod.Schema,
291     handle: catalog_mod.RelationHandle,
292     stats: ?catalog_mod.RelationStats,
293     root: ?version.RelationRoot,
294     key: PlanKey,
295     validation: Validation = .content,
296 
297     pub fn deinit(self: *PreparedRelation) void {
298         if (self.root) |*root| root.deinit();
299         if (self.stats) |*stats| stats.deinit();
300         self.handle.deinit();
301         self.allocator.free(self.name);
302         self.* = undefined;
303     }
304 
305     pub fn validate(self: *PreparedRelation) Error!void {
306         const phase = trace.scope("plan.validate_relation");
307         defer phase.end();
308 
309         const current = try self.currentCacheKey();
310         if (!PlanKey.same(current, self.key)) return error.PlanChanged;
311     }
312 
313     pub fn execute(self: *PreparedRelation) Error!RelationRead {
314         const phase = trace.scope("plan.execute_relation");
315         defer phase.end();
316 
317         try self.validate();
318         var lease = try self.handle.relation.space.database.beginRead();
319         errdefer lease.deinit();
320         return .{
321             .lease = lease,
322             .execution = .{
323                 .reader = try self.handle.relation.reader(lease.snapshot()),
324             },
325         };
326     }
327 
328     pub fn relationStats(self: *const PreparedRelation) ?*const catalog_mod.RelationStats {
329         if (self.stats) |*stats| return stats;
330         return null;
331     }
332 
333     pub fn cacheKey(self: *const PreparedRelation) PlanKey {
334         return self.key;
335     }
336 
337     pub fn currentCacheKey(self: *PreparedRelation) Error!PlanKey {
338         var state = try self.catalog.readRelation(self.allocator, self.name);
339         defer state.deinit();
340         return switch (self.validation) {
341             .content => content_key: {
342                 const relation_key = try version.relationKey(
343                     self.name,
344                     &state.handle,
345                     state.relationStats(),
346                 );
347                 break :content_key PlanKey.fromRelationKey(relation_key, self.key.parameters);
348             },
349             .shape => shapeKey(
350                 self.name,
351                 &state.handle,
352                 state.relationStats(),
353                 self.key.parameters,
354             ),
355         };
356     }
357 
358     pub fn matchesRoot(self: *const PreparedRelation, root: *const version.RelationRoot) bool {
359         if (self.root == null) return false;
360         return PlanKey.same(self.key, PlanKey.fromRoot(root, self.key.parameters));
361     }
362 
363     pub fn writeSession(self: *PreparedRelation) Error!session_mod.RelationSession {
364         const phase = trace.scope("plan.write_session");
365         defer phase.end();
366 
367         var relation_session = try session_mod.RelationSession.open(self.allocator, &self.catalog, self.name);
368         errdefer relation_session.deinit();
369         if (!self.matchesRoot(&relation_session.root)) return error.PlanChanged;
370         return relation_session;
371     }
372 
373     pub fn refresh(self: *PreparedRelation) Error!void {
374         var state = try self.catalog.readRelation(self.allocator, self.name);
375         errdefer state.deinit();
376         var root: ?version.RelationRoot = null;
377         errdefer if (root) |*relation_root| relation_root.deinit();
378         const key = switch (self.validation) {
379             .content => key: {
380                 root = try version.relationRootMaintained(
381                     self.allocator,
382                     self.name,
383                     state.schema,
384                     &state.handle,
385                     state.relationStats(),
386                 );
387                 break :key PlanKey.fromRoot(&root.?, self.key.parameters);
388             },
389             .shape => shapeKey(
390                 self.name,
391                 &state.handle,
392                 state.relationStats(),
393                 self.key.parameters,
394             ),
395         };
396 
397         if (self.root) |*old_root| old_root.deinit();
398         if (self.stats) |*old_stats| old_stats.deinit();
399         self.handle.deinit();
400         self.schema = state.schema;
401         self.handle = state.handle;
402         self.stats = state.stats;
403         self.root = root;
404         self.key = key;
405     }
406 
407     pub fn get(self: *PreparedRelation, allocator: Allocator, rowid: i64) Error!?[]u8 {
408         var execution = try self.execute();
409         defer execution.deinit();
410         return try execution.get(allocator, rowid);
411     }
412 
413     pub fn scan(
414         self: *PreparedRelation,
415         target: *relation_mod.Scan,
416         allocator: Allocator,
417         start: ?i64,
418         end: ?i64,
419     ) Error!void {
420         var execution = try self.execute();
421         defer execution.deinit();
422         try execution.scan(target, allocator, start, end);
423     }
424 
425     pub fn lookup(
426         self: *PreparedRelation,
427         target: *index_mod.Scan,
428         allocator: Allocator,
429         index_slot: usize,
430         prefix: []const row.Value,
431     ) Error!void {
432         var execution = try self.execute();
433         defer execution.deinit();
434         try execution.lookup(target, allocator, index_slot, prefix);
435     }
436 
437     pub fn indexScan(
438         self: *PreparedRelation,
439         target: *index_mod.Scan,
440         allocator: Allocator,
441         index_slot: usize,
442         start: ?[]const row.Value,
443         end: ?[]const row.Value,
444     ) Error!void {
445         var execution = try self.execute();
446         defer execution.deinit();
447         try execution.indexScan(target, allocator, index_slot, start, end);
448     }
449 
450     pub fn indexRange(
451         self: *PreparedRelation,
452         target: *index_mod.Scan,
453         allocator: Allocator,
454         index_slot: usize,
455         start: ?index_mod.Bound,
456         end: ?index_mod.Bound,
457     ) Error!void {
458         var execution = try self.execute();
459         defer execution.deinit();
460         try execution.indexRange(target, allocator, index_slot, start, end);
461     }
462 };
463 
464 pub fn prepareRelation(catalog: *const catalog_mod.Catalog, allocator: Allocator, name: []const u8, parameters: version.Hash) Error!PreparedRelation {
465     return try prepareRelationWithValidation(catalog, allocator, name, parameters, .content);
466 }
467 
468 pub fn prepareRelationShape(catalog: *const catalog_mod.Catalog, allocator: Allocator, name: []const u8, parameters: version.Hash) Error!PreparedRelation {
469     return try prepareRelationWithValidation(catalog, allocator, name, parameters, .shape);
470 }
471 
472 fn prepareRelationWithValidation(catalog: *const catalog_mod.Catalog, allocator: Allocator, name: []const u8, parameters: version.Hash, validation: Validation) Error!PreparedRelation {
473     const phase = trace.scope("plan.prepare_relation");
474     defer phase.end();
475 
476     const owned_name = try allocator.dupe(u8, name);
477     errdefer allocator.free(owned_name);
478     var state = try catalog.readRelation(allocator, name);
479     errdefer state.deinit();
480     var root: ?version.RelationRoot = null;
481     errdefer if (root) |*relation_root| relation_root.deinit();
482     const key = switch (validation) {
483         .content => key: {
484             root = try version.relationRootMaintained(
485                 allocator,
486                 name,
487                 state.schema,
488                 &state.handle,
489                 state.relationStats(),
490             );
491             break :key PlanKey.fromRoot(&root.?, parameters);
492         },
493         .shape => shapeKey(name, &state.handle, state.relationStats(), parameters),
494     };
495     return .{
496         .allocator = allocator,
497         .catalog = catalog.*,
498         .name = owned_name,
499         .schema = state.schema,
500         .handle = state.handle,
501         .stats = state.stats,
502         .root = root,
503         .key = key,
504         .validation = validation,
505     };
506 }
507 
508 fn preparedPut(prepared: *PreparedRelation, allocator: Allocator, rowid: i64, values: []const row.Value, options: file.CommitOptions) Error!session_mod.RelationFlush {
509     var relation_session = try prepared.writeSession();
510     var relation_session_live = true;
511     errdefer if (relation_session_live) relation_session.deinit();
512     try relation_session.put(rowid, values);
513     const flush = try flushPreparedRelation(prepared, allocator, &relation_session, &relation_session_live, options);
514     try prepared.refresh();
515     return flush;
516 }
517 
518 fn preparedPutEncoded(prepared: *PreparedRelation, allocator: Allocator, rowid: i64, bytes: []const u8, options: file.CommitOptions) Error!session_mod.RelationFlush {
519     var relation_session = try prepared.writeSession();
520     var relation_session_live = true;
521     errdefer if (relation_session_live) relation_session.deinit();
522     try relation_session.putEncoded(rowid, bytes);
523     const flush = try flushPreparedRelation(prepared, allocator, &relation_session, &relation_session_live, options);
524     try prepared.refresh();
525     return flush;
526 }
527 
528 fn preparedDelete(prepared: *PreparedRelation, allocator: Allocator, rowid: i64, options: file.CommitOptions) Error!session_mod.RelationFlush {
529     var relation_session = try prepared.writeSession();
530     var relation_session_live = true;
531     errdefer if (relation_session_live) relation_session.deinit();
532     try relation_session.delete(rowid);
533     const flush = try flushPreparedRelation(prepared, allocator, &relation_session, &relation_session_live, options);
534     try prepared.refresh();
535     return flush;
536 }
537 
538 fn flushPreparedRelation(prepared: *PreparedRelation, allocator: Allocator, relation_session: *session_mod.RelationSession, relation_session_live: *bool, options: file.CommitOptions) Error!session_mod.RelationFlush {
539     var root = try version.databaseRootMaintained(
540         allocator,
541         &prepared.catalog,
542         version.ConflictRoot.empty().hash,
543     );
544     var root_live = true;
545     errdefer if (root_live) root.deinit();
546     const commit = version.Commit.init(root.hash, &.{});
547     const root_hash = root.hash;
548     var database_session = session_mod.DatabaseSession.initWithRoot(allocator, branch.checkout(.{
549         .name = "prepared",
550         .target = commit.hash,
551     }, root_hash), &root);
552     root_live = false;
553     defer database_session.deinit();
554 
555     const limits = try relation_session.stagingLimits();
556     var workspace = try session_mod.DatabaseWrite.Workspace.allocate(
557         allocator,
558         limits,
559     );
560     defer workspace.deallocate(allocator);
561     var write = try database_session.beginWrite(
562         &workspace,
563         allocator,
564         limits,
565         options,
566     );
567     defer write.deinit();
568     try write.stageRelation(relation_session);
569     relation_session_live.* = false;
570     var database_flush = try write.flush();
571     defer database_flush.deinit();
572     return database_flush.onlyRelation();
573 }
574 
575 test "prepared relation ignores unrelated catalog schema version bumps" {
576     var tmp = std.testing.tmpDir(.{});
577     defer tmp.cleanup();
578 
579     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
580         .paths = .{ .database = "plan.db", .wal = "plan.wal" },
581         .header = testingHeader(),
582     });
583     defer database.deinit();
584     try database.reserve(.{ .wal_frames = 420 });
585 
586     var catalog = try catalog_mod.Catalog.open(&database, .{});
587     const indexes = [_]catalog_mod.IndexDefinition{.{
588         .name = "items_value",
589         .fields = &.{0},
590     }};
591     const created = try catalog.createRelation(std.testing.allocator, .{
592         .name = "items",
593         .indexes = &indexes,
594     }, .{ .durability = .buffered });
595     try std.testing.expectEqual(@as(u64, 1), created.schema.version);
596 
597     {
598         var prepared = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
599         defer prepared.deinit();
600         try std.testing.expectEqual(created.schema, prepared.schema);
601 
602         _ = try preparedPut(&prepared, std.testing.allocator, 1, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered });
603         try std.testing.expectEqual(created.schema, try catalog.schemaState(std.testing.allocator));
604 
605         var lookup: index_mod.Scan = undefined;
606         try prepared.lookup(&lookup, std.testing.allocator, 0, &.{.{ .integer = 7 }});
607         defer lookup.deinit();
608         try std.testing.expectEqual(@as(i64, 1), (try lookup.next()).?.rowid);
609         try std.testing.expect(try lookup.next() == null);
610 
611         const second = try catalog.createRelation(std.testing.allocator, .{
612             .name = "users",
613         }, .{ .durability = .buffered });
614         try std.testing.expectEqual(@as(u64, 2), second.schema.version);
615 
616         try prepared.validate();
617         var execution = try prepared.execute();
618         defer execution.deinit();
619         const bytes = (try execution.get(std.testing.allocator, 1)).?;
620         defer std.testing.allocator.free(bytes);
621         const view = try row.View.init(bytes);
622         try std.testing.expectEqual(@as(i64, 7), (try view.column(0)).integer);
623         try std.testing.expectEqualStrings("seven", (try view.column(1)).text);
624         try std.testing.expect(PlanKey.same(prepared.cacheKey(), try prepared.currentCacheKey()));
625 
626         _ = try preparedPut(&prepared, std.testing.allocator, 2, &.{ .{ .integer = 9 }, .{ .text = "nine" } }, .{ .durability = .buffered });
627         const second_bytes = (try prepared.get(std.testing.allocator, 2)).?;
628         defer std.testing.allocator.free(second_bytes);
629         const second_view = try row.View.init(second_bytes);
630         try std.testing.expectEqual(@as(i64, 9), (try second_view.column(0)).integer);
631     }
632 
633     {
634         var prepared = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
635         defer prepared.deinit();
636         try std.testing.expectEqual(@as(u64, 2), prepared.schema.version);
637 
638         var execution = try prepared.execute();
639         defer execution.deinit();
640         const bytes = (try execution.get(std.testing.allocator, 1)).?;
641         defer std.testing.allocator.free(bytes);
642         const view = try row.View.init(bytes);
643         try std.testing.expectEqual(@as(i64, 7), (try view.column(0)).integer);
644         try std.testing.expectEqualStrings("seven", (try view.column(1)).text);
645     }
646 }
647 
648 test "prepared relation detects external relation root changes" {
649     var tmp = std.testing.tmpDir(.{});
650     defer tmp.cleanup();
651 
652     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
653         .paths = .{ .database = "plan-root.db", .wal = "plan-root.wal" },
654         .header = testingHeader(),
655     });
656     defer database.deinit();
657     try database.reserve(.{ .wal_frames = 420 });
658 
659     var catalog = try catalog_mod.Catalog.open(&database, .{});
660     const indexes = [_]catalog_mod.IndexDefinition{.{
661         .name = "items_value",
662         .fields = &.{0},
663     }};
664     _ = try catalog.createRelation(std.testing.allocator, .{
665         .name = "items",
666         .indexes = &indexes,
667     }, .{ .durability = .buffered });
668 
669     var prepared = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
670     defer prepared.deinit();
671     const before = prepared.cacheKey();
672 
673     var handle = try catalog.openRelation(std.testing.allocator, "items");
674     defer handle.deinit();
675     _ = try handle.relation.put(std.testing.allocator, 1, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered });
676 
677     const current = try prepared.currentCacheKey();
678     try std.testing.expect(!PlanKey.same(before, current));
679     try std.testing.expect(!version.same(before.relation, current.relation));
680     try std.testing.expect(version.same(before.schema, current.schema));
681     try std.testing.expect(version.same(before.stats, current.stats));
682     try std.testing.expectError(error.PlanChanged, prepared.execute());
683     try std.testing.expectError(error.PlanChanged, prepared.get(std.testing.allocator, 1));
684     var stale_scan: index_mod.Scan = undefined;
685     try std.testing.expectError(
686         error.PlanChanged,
687         prepared.lookup(&stale_scan, std.testing.allocator, 0, &.{.{ .integer = 7 }}),
688     );
689     try std.testing.expectError(
690         error.PlanChanged,
691         prepared.indexScan(&stale_scan, std.testing.allocator, 0, null, null),
692     );
693     try std.testing.expectError(error.PlanChanged, prepared.writeSession());
694 
695     var fresh = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
696     defer fresh.deinit();
697     const bytes = (try fresh.get(std.testing.allocator, 1)).?;
698     defer std.testing.allocator.free(bytes);
699     const view = try row.View.init(bytes);
700     try std.testing.expectEqual(@as(i64, 7), (try view.column(0)).integer);
701 }
702 
703 test "prepared relation data changes preserve captured schema" {
704     var tmp = std.testing.tmpDir(.{});
705     defer tmp.cleanup();
706 
707     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
708         .paths = .{ .database = "plan.db", .wal = "plan.wal" },
709         .header = recoveredHeader(),
710     });
711     defer database.deinit();
712     try database.reserve(.{ .wal_frames = 220 });
713 
714     var catalog = try catalog_mod.Catalog.open(&database, .{});
715     const created = try catalog.createRelation(std.testing.allocator, .{
716         .name = "items",
717     }, .{ .durability = .buffered });
718     var prepared = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
719     defer prepared.deinit();
720 
721     _ = try preparedPut(&prepared, std.testing.allocator, 1, &.{.{ .integer = 11 }}, .{ .durability = .buffered });
722     var encoded_buffer: [32]u8 = undefined;
723     const encoded = try row.encode(&encoded_buffer, &.{.{ .integer = 22 }});
724     _ = try preparedPutEncoded(&prepared, std.testing.allocator, 2, encoded, .{ .durability = .buffered });
725     _ = try preparedDelete(&prepared, std.testing.allocator, 1, .{ .durability = .buffered });
726 
727     try prepared.validate();
728     try std.testing.expectEqual(created.schema, try catalog.schemaState(std.testing.allocator));
729     try std.testing.expectEqual(created.schema, prepared.schema);
730 
731     const bytes = (try prepared.get(std.testing.allocator, 2)).?;
732     defer std.testing.allocator.free(bytes);
733     const view = try row.View.init(bytes);
734     try std.testing.expectEqual(@as(i64, 22), (try view.column(0)).integer);
735 }
736 
737 test "prepared relation cache key changes after data root changes" {
738     var tmp = std.testing.tmpDir(.{});
739     defer tmp.cleanup();
740 
741     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
742         .paths = .{ .database = "plan-key.db", .wal = "plan-key.wal" },
743         .header = recoveredHeader(),
744     });
745     defer database.deinit();
746     try database.reserve(.{ .wal_frames = 220 });
747 
748     var catalog = try catalog_mod.Catalog.open(&database, .{});
749     _ = try catalog.createRelation(std.testing.allocator, .{
750         .name = "items",
751     }, .{ .durability = .buffered });
752     var prepared = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
753     defer prepared.deinit();
754 
755     const before = prepared.cacheKey();
756     try std.testing.expect(PlanKey.same(before, try prepared.currentCacheKey()));
757 
758     _ = try preparedPut(&prepared, std.testing.allocator, 1, &.{.{ .integer = 11 }}, .{ .durability = .buffered });
759     const after = prepared.cacheKey();
760     try std.testing.expect(PlanKey.same(after, try prepared.currentCacheKey()));
761     try std.testing.expect(!PlanKey.same(before, after));
762     try std.testing.expect(version.same(before.schema, after.schema));
763     try std.testing.expect(version.same(before.stats, after.stats));
764     try std.testing.expect(!version.same(before.relation, after.relation));
765 }
766 
767 test "prepared relation cache key includes parameter shape" {
768     var tmp = std.testing.tmpDir(.{});
769     defer tmp.cleanup();
770 
771     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
772         .paths = .{ .database = "plan-parameters.db", .wal = "plan-parameters.wal" },
773         .header = recoveredHeader(),
774     });
775     defer database.deinit();
776     try database.reserve(.{ .wal_frames = 220 });
777 
778     var catalog = try catalog_mod.Catalog.open(&database, .{});
779     _ = try catalog.createRelation(std.testing.allocator, .{
780         .name = "items",
781     }, .{ .durability = .buffered });
782 
783     const first_shape = version.emptyHash("sql.parameters.first");
784     const second_shape = version.emptyHash("sql.parameters.second");
785     var first = try prepareRelation(&catalog, std.testing.allocator, "items", first_shape);
786     defer first.deinit();
787     var second = try prepareRelation(&catalog, std.testing.allocator, "items", second_shape);
788     defer second.deinit();
789 
790     const first_key = first.cacheKey();
791     const second_key = second.cacheKey();
792     try std.testing.expect(version.same(first_key.relation, second_key.relation));
793     try std.testing.expect(version.same(first_key.schema, second_key.schema));
794     try std.testing.expect(version.same(first_key.stats, second_key.stats));
795     try std.testing.expect(!version.same(first_key.parameters, second_key.parameters));
796     try std.testing.expect(!PlanKey.same(first_key, second_key));
797 }
798 
799 test "prepared relation invalidates by stats root without schema change" {
800     var tmp = std.testing.tmpDir(.{});
801     defer tmp.cleanup();
802 
803     var database = try file.Database.openForTesting(std.testing.allocator, tmp.dir, .{
804         .paths = .{ .database = "plan.db", .wal = "plan.wal" },
805         .header = testingHeader(),
806     });
807     defer database.deinit();
808     try database.reserve(.{ .wal_frames = 360 });
809 
810     var catalog = try catalog_mod.Catalog.open(&database, .{});
811     const indexes = [_]catalog_mod.IndexDefinition{.{
812         .name = "items_value",
813         .fields = &.{0},
814     }};
815     const created = try catalog.createRelation(std.testing.allocator, .{
816         .name = "items",
817         .indexes = &indexes,
818     }, .{ .durability = .buffered });
819 
820     var stale = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
821     defer stale.deinit();
822     try std.testing.expect(stale.relationStats() == null);
823 
824     _ = try preparedPut(&stale, std.testing.allocator, 1, &.{ .{ .integer = 7 }, .{ .text = "seven" } }, .{ .durability = .buffered });
825     _ = try preparedPut(&stale, std.testing.allocator, 2, &.{ .{ .integer = 9 }, .{ .text = "nine" } }, .{ .durability = .buffered });
826     const analyzed = try catalog.analyzeRelation(std.testing.allocator, "items", .{ .durability = .buffered });
827     try std.testing.expectEqual(created.schema, analyzed.schema);
828     try std.testing.expectError(error.PlanChanged, stale.validate());
829     try std.testing.expect(stale.relationStats() == null);
830 
831     var fresh = try prepareRelation(&catalog, std.testing.allocator, "items", emptyParameterShape());
832     defer fresh.deinit();
833     const stats = fresh.relationStats().?;
834     try std.testing.expectEqual(@as(usize, 2), stats.table.entries);
835     try std.testing.expectEqual(@as(usize, 1), stats.indexes.len);
836     try std.testing.expectEqual(@as(usize, 2), stats.index("items_value").?.summary.entries);
837     try std.testing.expect(!PlanKey.same(stale.cacheKey(), fresh.cacheKey()));
838     try std.testing.expect(!version.same(stale.cacheKey().stats, fresh.cacheKey().stats));
839 }
840 
841 fn testingHeader() wal.Header {
842     return .{
843         .sequence = 1201,
844         .salt = .{ .first = 0x1212_eeee, .second = 0x3434_ffff },
845     };
846 }
847 
848 fn recoveredHeader() wal.Header {
849     return .{
850         .sequence = 1202,
851         .salt = .{ .first = 0x5656_dddd, .second = 0x7878_cccc },
852     };
853 }