spg_storage/lib.rs
1//! In-memory storage primitives.
2//!
3//! v0.3 is intentionally simple: a flat catalog of tables, each holding rows
4//! as `Vec<Value>` (positional, matching the table's `TableSchema`). No MVCC,
5//! no on-disk format — those land in later milestones.
6#![no_std]
7// v3.3.2 NEON path for l2_distance_sq (aarch64 only). Scoped allow:
8// `unsafe_code = "deny"` at workspace level stays in force for every
9// other crate.
10#![cfg_attr(target_arch = "aarch64", allow(unsafe_code))]
11
12extern crate alloc;
13
14pub mod bignum;
15pub mod bloom;
16mod codec;
17pub mod fts_simple;
18pub mod halfvec;
19pub mod jsonb_gin;
20mod nsw;
21pub mod persistent;
22pub mod persistent_btree;
23pub mod posting;
24pub mod quantize;
25pub mod row_header;
26pub mod row_locator;
27pub mod segment;
28pub mod snapshot;
29mod table;
30pub mod trgm;
31pub mod vacuum;
32
33pub use self::bloom::{BloomError, BloomFilter};
34// v7.31 monster tier-3 cut 3 — on-disk codec moved to `codec`; the
35// public dense-row surface keeps its `spg_storage::*` paths, and the
36// low-level write/read primitives stay crate-visible for the
37// `Catalog::serialize`/`deserialize` methods that remain in this file.
38pub(crate) use self::codec::*;
39pub use self::codec::{
40 decode_row_body_dense, decode_row_body_dense_pruned, encode_row_body_dense,
41 encode_row_body_dense_into, encode_row_body_dense_masked_into, row_body_encoded_len,
42};
43// v7.31 monster tier-3 cut 2 — HNSW algorithms moved to `nsw`; the
44// public vector-search surface keeps its `spg_storage::*` paths via
45// these re-exports, and `nsw_insert_at` stays crate-visible for the
46// `Table` insert paths in the `table` module.
47pub(crate) use self::nsw::nsw_insert_at;
48pub use self::nsw::{NswMetric, cosine_dot_norms_f32, inner_product_f32, nsw_index_on, nsw_query};
49pub use self::posting::PostingList;
50
51/// The list handed back for an absent key, so callers cannot tell an
52/// absent key from an empty posting list — the property the old
53/// `&[][..]` return had, kept.
54static EMPTY_POSTINGS: crate::posting::PostingList = crate::posting::PostingList::new();
55pub use self::row_locator::{RowLocator, RowLocatorError};
56pub use self::segment::{
57 BRIN_SIDECAR_MAGIC, BrinSummary, OwnedSegment, SEGMENT_COMPRESS_ALGO_LZSS,
58 SEGMENT_COMPRESS_ALGO_NONE, SEGMENT_MAGIC, SEGMENT_MAGIC_V2, SEGMENT_PAGE_BYTES, SegmentError,
59 SegmentMeta, SegmentReader, derive_brin_summaries, encode_segment, wrap_v2_envelope,
60 wrap_v2_envelope_with_brin,
61};
62
63use alloc::borrow::Cow;
64use alloc::boxed::Box;
65use alloc::collections::{BTreeMap, BTreeSet};
66use alloc::format;
67use alloc::string::{String, ToString};
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70use core::fmt;
71
72use self::persistent::PersistentVec;
73use self::persistent_btree::PersistentBTreeMap;
74
75/// In-cell encoding for `DataType::Vector`. Mirrors
76/// `spg_sql::ast::VecEncoding` — kept here so storage stays
77/// dep-free of `spg-sql`. The engine bridges between the two
78/// at DDL-execution time.
79///
80/// `F32` is the pre-v6 default: each cell holds a raw `Vec<f32>`.
81/// `Sq8` (v6.0.1) stores `Sq8Vector { min, max, bytes: Vec<u8> }`
82/// per cell; 4× compression vs `F32` with recall@10 ≥ 0.95 on
83/// natural embeddings (Gaussian / unit-sphere corpora).
84/// `F16` (v6.0.3, DDL keyword `HALF`) stores each element as
85/// IEEE-754 binary16; 2× compression and bit-exact dequantise.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum VecEncoding {
88 #[default]
89 F32,
90 Sq8,
91 F16,
92}
93
94impl fmt::Display for VecEncoding {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self {
97 Self::F32 => f.write_str("F32"),
98 Self::Sq8 => f.write_str("SQ8"),
99 Self::F16 => f.write_str("HALF"),
100 }
101 }
102}
103
104/// Runtime type tags. `Vector { dim, encoding }` / `Varchar(max)` /
105/// `Char(size)` are parameterised; the parameter travels with both
106/// the column schema and the on-wire serialised representation.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum DataType {
109 /// 16-bit signed. Backed by `Value::SmallInt(i16)`; arithmetic that
110 /// would overflow surfaces as a type error at INSERT time.
111 SmallInt,
112 Int, // 32-bit signed
113 BigInt, // 64-bit signed
114 Float, // f64 (PG double precision)
115 /// v7.38 (read01, T-float4) — `real` / `float4`: 32-bit IEEE float (PG
116 /// `real`). Backed by `Value::Real(f32)`; behaves like `Float` for most
117 /// dispatch but renders / stores at f32 precision.
118 Real,
119 Text,
120 /// `VARCHAR(n)` — same byte representation as `Text`, but INSERT
121 /// rejects values longer than `n` Unicode characters.
122 Varchar(u32),
123 /// `CHAR(n)` — same representation as `Text`, but INSERT right-pads
124 /// with U+0020 to exactly `n` Unicode characters (or rejects when
125 /// the input is already longer).
126 Char(u32),
127 Bool,
128 /// pgvector-style fixed-dimension vector. `encoding` selects
129 /// the in-cell representation (`F32` = pre-v6 raw f32 buffer;
130 /// `Sq8` = v6.0.1 8-bit scalar-quantised). The DDL grammar
131 /// surfaces encoding via the optional `USING <encoding>`
132 /// clause: `VECTOR(128) USING SQ8`.
133 Vector {
134 dim: u32,
135 encoding: VecEncoding,
136 },
137 /// `NUMERIC(precision, scale)` — exact fixed-point decimal stored as
138 /// a scaled `i128`. `precision` caps total decimal digits, `scale`
139 /// fixes digits after the decimal point. v1.12 supports up to
140 /// precision 38 (the i128-safe ceiling). `NUMERIC` and `NUMERIC(p)`
141 /// surface as `Numeric { precision: p, scale: 0 }`.
142 Numeric {
143 /// v7.39 (round 272) — widened from u8. PG's declared precision
144 /// runs to 1000; at u8 it could not even be spelled, and the
145 /// parser rejected anything past 38 (i128's width) outright.
146 precision: u16,
147 /// v7.39 (round 271) — widened alongside the value's scale.
148 /// v7.39 (round 273) — and signed: PG's DECLARED scale runs
149 /// -1000..=1000, where a negative one rounds to tens / hundreds.
150 /// A VALUE's display scale is always non-negative.
151 scale: i16,
152 },
153 /// `DATE` — calendar date with day precision, stored as `i32` days
154 /// since the Unix epoch (1970-01-01).
155 Date,
156 /// `TIMESTAMP` (a.k.a. `MySQL` `DATETIME`) — instant with microsecond
157 /// precision, stored as `i64` microseconds since the Unix epoch.
158 Timestamp,
159 /// v7.9.2 `TIMESTAMPTZ` — bit-identical to `Timestamp` on disk
160 /// (i64 microseconds, UTC by convention). Carried as a distinct
161 /// type tag so the PG-wire layer can advertise OID 1184 (PG's
162 /// `timestamp with time zone`) and `sqlx`/`pgx`/JDBC clients
163 /// decode into their TZ-aware datetime types. The internal
164 /// semantics are unchanged: SPG never stored per-row offsets,
165 /// and neither did PG — `TIMESTAMPTZ` in PG is also UTC i64.
166 Timestamptz,
167 /// v7.39 (round 291) — PG's `name`: the type its catalogs use for
168 /// identifiers. Text truncated to NAMEDATALEN-1 (63) bytes, with
169 /// its own type identity — `pg_typeof('abc'::name)` is `name`, and
170 /// `CREATE TABLE t (a name)` is legal SQL that SPG rejected.
171 Name,
172 /// v7.39 (round 640) — PG's `xid`: a transaction id. [`Value::Xid`]
173 /// has existed since round 512, so a `'5'::xid` literal already knew
174 /// what it was; this is the DECLARED half, which nothing had. Without
175 /// it `pg_typeof(NULL::xid)` answered `bigint`, `pg_type` could not
176 /// list oid 28 — leaving the 48 `pg_attribute` rows that describe
177 /// `xmin` / `xmax` pointing at a type no catalog carried — and
178 /// `CREATE TABLE t (a xid)` was refused as an unknown type.
179 ///
180 /// On disk it is the 8-byte body its BIGINT sibling writes, and it
181 /// reads back as a `Value::Xid`, so a stored column and a literal are
182 /// the same thing to everything downstream.
183 ///
184 /// What is NOT yet true of the identity: PG gives `xid` equality and
185 /// hashing and no ordering operator at all, so `min` / `max` /
186 /// `count(DISTINCT …)` / `<=` all error there and all answer here.
187 /// Measured, not assumed — and left for the operator surface rather
188 /// than claimed by this comment.
189 Xid,
190 /// v7.39 (round 640) — PG's `xid8`: the same transaction id, 64 bits
191 /// wide and monotonic. Unlike [`DataType::Xid`] it has no value of
192 /// its own; a cell is a `Value::BigInt` and only the declared type
193 /// witnesses it. That is enough for `pg_typeof`, the catalogs and
194 /// the wire OID, and not enough to refuse a bigint where PG refuses
195 /// one. `pg_current_xact_id()` returns this type on PG.
196 Xid8,
197 /// v7.39 (round 667) — PG's `oid`: an unsigned 32-bit object
198 /// identifier. Modelled exactly like [`DataType::Xid8`] above: it has
199 /// no value of its own, a cell is a `Value::BigInt`, and only the
200 /// declared type witnesses it.
201 ///
202 /// That deliberately buys less than a full value type. What it buys:
203 /// `CREATE TABLE t(o OID)` is accepted (it was rejected outright with
204 /// `type "oid" does not exist`, while the neighbouring `XID` worked),
205 /// `pg_typeof` answers `oid` rather than `bigint`, and the catalogs
206 /// report their own key columns honestly. What it does NOT buy is
207 /// refusing a bigint where PG refuses an oid — `sum(oid)` and
208 /// `avg(oid)` still answer here and error on PG, because at runtime
209 /// the cell is indistinguishable from a bigint. Round 664 tried to
210 /// close those two by name and withdrew: a guard keyed on the name
211 /// would have caught `sum(bigint)` with it.
212 ///
213 /// The cast itself was already right before this — `4294967296::oid`
214 /// and `'abc'::oid` produce PG's errors word for word, and `(-1)::oid`
215 /// wraps to 4294967295 as PG does. Only the resulting type was lost,
216 /// because `conversions.rs` mapped the target to `BigInt`.
217 Oid,
218 /// `INTERVAL` — calendar-aware span (months + microseconds). v2.11
219 /// supports INTERVAL only as a runtime intermediate (literals,
220 /// arithmetic results); on-disk encoding is rejected so this branch
221 /// can't appear in a `ColumnSchema`.
222 Interval,
223 /// v4.9: `JSON` — text-backed JSON document. We don't parse
224 /// the content (no path operators or jsonb functions yet) —
225 /// the column accepts any TEXT-compatible value and round-trips
226 /// it verbatim. PG OID 114 on the wire.
227 Json,
228 /// v7.9.0: `JSONB` — semantically identical to `Json` on
229 /// the storage side (same `Value::Json` cells, same
230 /// row codec), but advertised as PG OID 3802 on the wire
231 /// so `sqlx`-style clients that bind `jsonb` columns
232 /// decode correctly. mailrs migration blocker #3.
233 Jsonb,
234 /// v7.10.4: `BYTES` / `BYTEA` — variable-length raw binary.
235 /// Backed by `Value::Bytes(Vec<u8>)`. PG wire OID 17. Literal
236 /// forms accepted by parser/engine: PG hex form `'\xDEADBEEF'`
237 /// (case-insensitive hex pairs) and escape form
238 /// `'foo\\000bar'` (the latter decoded at coercion time when
239 /// the target column is BYTEA — TEXT columns leave the
240 /// backslash sequence verbatim).
241 Bytes,
242 /// v7.10.9: `TEXT[]` — single-dimension TEXT array. Elements
243 /// may be NULL (PG semantics). PG wire OID 1009. Literal
244 /// forms: `ARRAY['a', 'b', NULL]` and the PG external form
245 /// `'{a,b,NULL}'::TEXT[]`. Engine implements `= ANY(arr)`,
246 /// `<> ALL(arr)`, and 1-based indexing `arr[i]`. Catalog
247 /// FILE_VERSION 18+; older snapshots reject this DataType
248 /// (forward-only by design — TEXT[] columns aren't readable
249 /// on a pre-v7.10 binary).
250 TextArray,
251 /// v7.11.12: `INT[]` — single-dimension i32 array. PG wire
252 /// OID 1007 (_int4). Same `ARRAY[...]` / `'{1,2,3}'::INT[]`
253 /// literal surface as TEXT[]. Catalog FILE_VERSION 19+.
254 IntArray,
255 /// v7.11.12: `BIGINT[]` — single-dimension i64 array. PG
256 /// wire OID 1016 (_int8). Catalog FILE_VERSION 19+.
257 BigIntArray,
258 /// v7.39 (round 694) — `oid[]`. It exists for the reason
259 /// [`DataType::Oid`] does: mapping it onto `BigIntArray` answers
260 /// `pg_typeof('{1,2}'::oid[])` with `bigint[]`, which is the defect
261 /// round 667 closed for the scalar.
262 OidArray,
263 /// v7.39.11 — PG's `int2vector`: the type its catalogs use for
264 /// `pg_index.indkey` and `pg_index.indoption`. It IS an array of
265 /// `smallint` — `a.attnum = ANY (i.indkey)` is how Django, Rails,
266 /// sqlalchemy and every hand-written schema-diff query ask which
267 /// columns an index covers — but its output function prints the
268 /// elements space-separated with no braces, and its subscripts
269 /// start at 0 rather than 1. Carrying it as `text` (which SPG did
270 /// through 7.39.10) got the printing right and made every array
271 /// operation raise; carrying it as `smallint[]` would trade one
272 /// of those for the other. It is its own type here for the same
273 /// reason it is one there.
274 Int2Vector,
275 /// v7.39.11 — PG's `oidvector`: `pg_index.indclass`,
276 /// `pg_index.indcollation`, `pg_proc.proargtypes`. `int2vector`
277 /// with `oid` elements; see [`DataType::Int2Vector`].
278 OidVector,
279 /// v7.37.5 β-P4 — `INTERVAL[]` — single-dimension array of
280 /// `IntervalSpan { months, days, micros }`. PG wire OID 1187
281 /// (`_interval`). Catalog tag 35 + per-cell body
282 /// `[u16 count][per elem: u8 null + (if non-null) 16-byte
283 /// interval body in LE PG-byte-equal field order]`.
284 /// FILE_VERSION 48+.
285 IntervalArray,
286 /// v7.37.5 γ — full PG array-of-scalar family. Catalog tags
287 /// 36..48; wire OIDs from PG `pg_type.dat`. Per-element body
288 /// uses the scalar's existing `write_value_body` shape.
289 /// FILE_VERSION 48+ (same window as β; no separate bump).
290 BoolArray, // PG `_bool` OID 1000, tag 36
291 SmallIntArray, // PG `_int2` OID 1005, tag 37
292 FloatArray, // PG `_float8` OID 1022, tag 38
293 NumericArray, // PG `_numeric` OID 1231, tag 39
294 DateArray, // PG `_date` OID 1182, tag 40
295 TimestampArray, // PG `_timestamp` OID 1115, tag 41
296 TimestamptzArray, // PG `_timestamptz` OID 1185, tag 42
297 UuidArray, // PG `_uuid` OID 2951, tag 43
298 JsonArray, // PG `_json` OID 199, tag 44
299 JsonbArray, // PG `_jsonb` OID 3807, tag 45
300 BytesArray, // PG `_bytea` OID 1001, tag 46
301 VarcharArray, // PG `_varchar` OID 1015, tag 47
302 CharArray, // PG `_bpchar` OID 1014, tag 48
303 /// v7.37.5 δ — PG 14+ multirange types. A multirange is an
304 /// ordered collection of non-overlapping ranges of the same
305 /// element kind (e.g. `int4multirange(int4range(1,5),
306 /// int4range(10,15))` → `{[1,5),[10,15)}`). The same DataType
307 /// variant covers all six builtin multiranges; `RangeKind`
308 /// pins the element type so encode/decode/display can route
309 /// off one switch (parallel to `Range(RangeKind)`).
310 /// Wire OIDs: int4multirange=4451, int8multirange=4537,
311 /// nummultirange=4536, tsmultirange=4533, tstzmultirange=4534,
312 /// datemultirange=4535. Catalog tag 49 + 1-byte RangeKind on
313 /// the dense type-tag side. FILE_VERSION 48+ (same window as
314 /// β/γ, no separate bump).
315 Multirange(RangeKind),
316 /// v7.37.5 ε — PG geometry scalar family. Mirrors PG's seven
317 /// builtin geometric types one-for-one. Body shapes (LE):
318 /// Point = 16 B fixed (f64 x + f64 y) OID 600
319 /// Lseg = 32 B fixed (Point p1 + Point p2) OID 601
320 /// Path = varlena ([u8 closed][u32 n][Point*n]) OID 602
321 /// Box = 32 B fixed (Point ur + Point ll) OID 603
322 /// Polygon = varlena ([u32 n][Point*n]) OID 604
323 /// Line = 24 B fixed (f64 a + f64 b + f64 c) OID 628
324 /// Circle = 24 B fixed (Point center + f64 r) OID 718
325 /// Catalog tags 50..56. FILE_VERSION 48+ (same window as β/γ/δ;
326 /// no separate bump). Geometric operators (`<->` / `@>` / `&&`
327 /// / `<<` / `>>` / `~=`) are a planner-integration follow-up,
328 /// parallel to the Range operator defer in e2e_pg_range.rs.
329 Point,
330 Lseg,
331 Path,
332 PgBox,
333 Polygon,
334 Line,
335 Circle,
336 /// v7.37.5 ζ-A — PG network address family. Body shapes (LE):
337 /// Inet = 18 B fixed (u8 family + u8 bits + 16 B addr) OID 869
338 /// Cidr = 18 B fixed (same shape as Inet; CIDR rejects
339 /// host bits at parse / coerce) OID 650
340 /// Macaddr = 6 B fixed OID 829
341 /// Macaddr8 = 8 B fixed (EUI-64) OID 774
342 /// Catalog tags 57-60. FILE_VERSION 48+. `family = 4` is IPv4
343 /// (uses the first 4 bytes of the 16-B addr slot, rest 0);
344 /// `family = 6` is IPv6 (full 16 B).
345 Inet,
346 Cidr,
347 Macaddr,
348 Macaddr8,
349 /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn` (WAL location). 8 bytes,
350 /// rendered `%X/%X`. Catalog tag 66. OID 3220.
351 PgLsn,
352 /// v7.37.5 ζ-A — PG bit string. Body = `[u32 nbits][ceil(nbits/8) bytes]`,
353 /// big-endian within each byte (matches PG binary).
354 /// Bit OID 1560 (fixed-length, but SPG carries the
355 /// length per cell — column declaration
356 /// `BIT(n)` constrains at coerce time)
357 /// BitVarying OID 1562 (variable-length, declared as `VARBIT`)
358 /// Catalog tags 61-62.
359 /// v7.39 (round 281) — `BIT(n)`: a FIXED-length bit string. `0`
360 /// means the type was written without a typmod, which PG treats as
361 /// `bit(1)`. Column assignment requires the length to match
362 /// exactly; an explicit cast pads or truncates instead.
363 Bit(u32),
364 /// v7.39 (round 281) — `BIT VARYING(n)`: `n` is a MAXIMUM, and `0`
365 /// means unbounded (`varbit` with no typmod).
366 BitVarying(u32),
367 /// v7.37.5 ζ-A — PG `xml`. Body identical to TEXT (storage is
368 /// the verbatim XML string; no parse-time validation). Only
369 /// the wire OID (142) differs. Catalog tag 63.
370 Xml,
371 /// v7.37.5 ζ-A — PG `"char"` (the internal single-byte type,
372 /// distinct from `CHAR(n)` / `BPCHAR`). Body = 1 byte raw.
373 /// OID 18. Catalog tag 64.
374 Char1,
375 /// v7.37.5 ζ-A — `MONEY[]`. Body = `[u16 count][per elem: u8 null
376 /// + (non-null) i64 LE cents]`. OID 791. Catalog tag 65.
377 MoneyArray,
378 /// v7.12.0: PG `tsvector` — ordered, deduplicated set of
379 /// `(lexeme, positions, weight)` tuples. PG wire OID 3614.
380 /// Catalog FILE_VERSION 20+. Storage shape is row-codec
381 /// tag 22; the schema-agnostic `write_value` path emits tag
382 /// 18. Literal: `'foo:1 bar:2,3'::tsvector` (PG external
383 /// form). G-CRIT-3 entry — v7.12.0 only ships the type +
384 /// codec; matching `@@` lands in v7.12.2.
385 TsVector,
386 /// v7.12.0: PG `tsquery` — parse tree of lexemes joined by
387 /// `&` `|` `!` and phrase operators. PG wire OID 3615.
388 /// Catalog FILE_VERSION 20+.
389 TsQuery,
390 /// v7.17.0: PG `uuid` — 128-bit identifier stored as
391 /// `Value::Uuid([u8; 16])`. PG wire OID 2950. Canonical
392 /// text form is lowercase 8-4-4-4-12 hyphenated; input
393 /// also accepts uppercase, unhyphenated, and brace-wrapped
394 /// forms (`{xxxx…}`). Catalog FILE_VERSION 36+; tag 24 on
395 /// the dense type-tag side, tag 20 on the schema-agnostic
396 /// value side. The drop-in PG/MySQL surface for Django /
397 /// Rails / Hibernate "id UUID PRIMARY KEY DEFAULT
398 /// gen_random_uuid()" default-PK pattern.
399 Uuid,
400 /// v7.17.0 Phase 3.P0-32: PG `time` (without time zone) — i64
401 /// microseconds since 00:00:00. PG wire OID 1083. Display:
402 /// canonical zero-padded `HH:MM:SS` when fractional is zero,
403 /// `HH:MM:SS.ffffff` otherwise. Catalog FILE_VERSION 37+;
404 /// tag 25 on the dense type-tag side, tag 21 on the schema-
405 /// agnostic value side. The wall-clock-of-day half of PG's
406 /// date/time triplet (date / time / timestamp).
407 Time,
408 /// v7.17.0 Phase 3.P0-33: MySQL `YEAR` — u16 in range
409 /// 1901..=2155 plus the special zero-year sentinel 0. No
410 /// dedicated PG OID (advertised as INT4 / OID 23 on the wire
411 /// — psql renders integers, MySQL CLI renders 4-digit
412 /// zero-padded text). Display always 4 digits: `0000` for the
413 /// zero-year, `1985` / `2007` / etc otherwise. Catalog
414 /// FILE_VERSION 38+; tag 26 on the dense type-tag side, tag
415 /// 22 on the schema-agnostic value side.
416 Year,
417 /// v7.17.0 Phase 3.P0-34: PG `time with time zone` (TIMETZ) —
418 /// i64 microseconds since 00:00:00 in the local wall clock
419 /// PLUS i32 offset-from-UTC in seconds. PG wire OID 1266.
420 /// Display: `HH:MM:SS[.ffffff]±HH[:MM]` (PG `timetz_out`).
421 /// Range: offset in ±50400 seconds (±14 hours). Catalog
422 /// FILE_VERSION 39+; tag 27 on the dense type-tag side, tag
423 /// 23 on the schema-agnostic value side.
424 TimeTz,
425 /// v7.17.0 Phase 3.P0-35: PG `money` — i64 cents (locale-
426 /// independent storage). PG wire OID 790. Display: en_US
427 /// locale (`$N,NNN.CC`, negative → `-$1.23`). Input accepts
428 /// `$N.NN`, `$N,NNN.NN`, bare integer (treated as major
429 /// units), optional leading `-`. Range: full i64. Catalog
430 /// FILE_VERSION 40+; tag 28 on the dense type-tag side, tag
431 /// 24 on the schema-agnostic value side.
432 Money,
433 /// v7.17.0 Phase 3.P0-38: PG range type. The same DataType
434 /// variant covers all six builtin ranges (int4range,
435 /// int8range, numrange, tsrange, tstzrange, daterange) —
436 /// `RangeKind` pins the element type so encode / decode /
437 /// display can route off one switch. Catalog FILE_VERSION
438 /// 43+; tag 29 + a 1-byte RangeKind on the dense type-tag
439 /// side, tag 25 on the schema-agnostic value side.
440 Range(RangeKind),
441 /// v7.17.0 Phase 3.P0-39: PG `hstore` extension type — flat
442 /// `text => text` map with NULL value support. Catalog
443 /// FILE_VERSION 44+; tag 30 on the dense type-tag side, tag
444 /// 26 on the schema-agnostic value side. The contrib OID is
445 /// installation-dependent in real PG; SPG advertises it via
446 /// dynamic lookup, falling back to TEXT (OID 25) on the wire
447 /// when the installed `hstore` extension hasn't claimed an
448 /// OID yet.
449 Hstore,
450 /// v7.17.0 Phase 3.P0-40: PG `int[][]` — 2-dimensional INT
451 /// matrix. Storage: row-major Vec<Vec<Option<i32>>>. All
452 /// rows must share the same column count. Wire OID 1007
453 /// (same as INT[]; the dimension count travels in the data
454 /// header, not the OID). Catalog FILE_VERSION 45+; tag 31
455 /// on the dense type-tag side, tag 27 on the schema-agnostic
456 /// value side.
457 IntArray2D,
458 /// v7.17.0 Phase 3.P0-40: PG `bigint[][]` — 2-dimensional
459 /// BIGINT matrix. Storage / OID / tags mirror IntArray2D.
460 /// Tag 32 dense, tag 28 schema-agnostic.
461 BigIntArray2D,
462 /// v7.17.0 Phase 3.P0-40: PG `text[][]` — 2-dimensional TEXT
463 /// matrix. Storage: row-major Vec<Vec<Option<String>>>.
464 /// Tag 33 dense, tag 29 schema-agnostic.
465 TextArray2D,
466 /// v7.39 (read01 round 75) — `bool[][]`. BOOL is the ONE element type whose
467 /// ARRAY rendering differs from its scalar one (`t` vs `true`), so a
468 /// text-backed 2-D cannot be PG-faithful for it: rendering the whole array
469 /// wants `t`, and subscripting a cell to text wants `false`. Every other
470 /// element type renders the same either way, which is why this is the only
471 /// typed 2-D variant SPG needs.
472 BoolArray2D,
473}
474
475/// v7.17.0 Phase 3.P0-38 — pins the element type of a range value
476/// or column. Wire OIDs: Int4=3904, Int8=3926, Num=3906,
477/// Ts=3908, TsTz=3910, Date=3912.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
479pub enum RangeKind {
480 Int4,
481 Int8,
482 Num,
483 Ts,
484 TsTz,
485 Date,
486}
487
488impl RangeKind {
489 pub const fn tag(self) -> u8 {
490 match self {
491 Self::Int4 => 0,
492 Self::Int8 => 1,
493 Self::Num => 2,
494 Self::Ts => 3,
495 Self::TsTz => 4,
496 Self::Date => 5,
497 }
498 }
499 pub const fn from_tag(t: u8) -> Option<Self> {
500 Some(match t {
501 0 => Self::Int4,
502 1 => Self::Int8,
503 2 => Self::Num,
504 3 => Self::Ts,
505 4 => Self::TsTz,
506 5 => Self::Date,
507 _ => return None,
508 })
509 }
510 pub const fn keyword(self) -> &'static str {
511 match self {
512 Self::Int4 => "INT4RANGE",
513 Self::Int8 => "INT8RANGE",
514 Self::Num => "NUMRANGE",
515 Self::Ts => "TSRANGE",
516 Self::TsTz => "TSTZRANGE",
517 Self::Date => "DATERANGE",
518 }
519 }
520}
521
522impl fmt::Display for DataType {
523 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524 match self {
525 Self::SmallInt => f.write_str("SMALLINT"),
526 Self::Int => f.write_str("INT"),
527 Self::BigInt => f.write_str("BIGINT"),
528 Self::Xid => f.write_str("XID"),
529 Self::Xid8 => f.write_str("XID8"),
530 Self::Oid => f.write_str("OID"),
531 Self::OidArray => f.write_str("OID[]"),
532 Self::Int2Vector => f.write_str("INT2VECTOR"),
533 Self::OidVector => f.write_str("OIDVECTOR"),
534 Self::Float => f.write_str("FLOAT"),
535 Self::Real => f.write_str("REAL"),
536 Self::Text => f.write_str("TEXT"),
537 Self::Varchar(n) => write!(f, "VARCHAR({n})"),
538 Self::Char(n) => write!(f, "CHAR({n})"),
539 Self::Bool => f.write_str("BOOL"),
540 Self::Vector { dim, encoding } => match encoding {
541 VecEncoding::F32 => write!(f, "VECTOR({dim})"),
542 VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
543 VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
544 },
545 Self::Numeric { precision, scale } => {
546 if *scale == 0 {
547 write!(f, "NUMERIC({precision})")
548 } else {
549 write!(f, "NUMERIC({precision}, {scale})")
550 }
551 }
552 Self::Date => f.write_str("DATE"),
553 Self::Timestamp => f.write_str("TIMESTAMP"),
554 Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
555 Self::Name => f.write_str("NAME"),
556 Self::Interval => f.write_str("INTERVAL"),
557 Self::Json => f.write_str("JSON"),
558 Self::Jsonb => f.write_str("JSONB"),
559 Self::Bytes => f.write_str("BYTEA"),
560 Self::TextArray => f.write_str("TEXT[]"),
561 Self::IntArray => f.write_str("INT[]"),
562 Self::BigIntArray => f.write_str("BIGINT[]"),
563 Self::IntervalArray => f.write_str("INTERVAL[]"),
564 Self::BoolArray => f.write_str("BOOL[]"),
565 Self::SmallIntArray => f.write_str("SMALLINT[]"),
566 Self::FloatArray => f.write_str("FLOAT[]"),
567 Self::NumericArray => f.write_str("NUMERIC[]"),
568 Self::DateArray => f.write_str("DATE[]"),
569 Self::TimestampArray => f.write_str("TIMESTAMP[]"),
570 Self::TimestamptzArray => f.write_str("TIMESTAMPTZ[]"),
571 Self::UuidArray => f.write_str("UUID[]"),
572 Self::JsonArray => f.write_str("JSON[]"),
573 Self::JsonbArray => f.write_str("JSONB[]"),
574 Self::BytesArray => f.write_str("BYTEA[]"),
575 Self::VarcharArray => f.write_str("VARCHAR[]"),
576 Self::CharArray => f.write_str("CHAR[]"),
577 Self::Multirange(k) => f.write_str(match k {
578 RangeKind::Int4 => "INT4MULTIRANGE",
579 RangeKind::Int8 => "INT8MULTIRANGE",
580 RangeKind::Num => "NUMMULTIRANGE",
581 RangeKind::Ts => "TSMULTIRANGE",
582 RangeKind::TsTz => "TSTZMULTIRANGE",
583 RangeKind::Date => "DATEMULTIRANGE",
584 }),
585 Self::Point => f.write_str("POINT"),
586 Self::Lseg => f.write_str("LSEG"),
587 Self::Path => f.write_str("PATH"),
588 Self::PgBox => f.write_str("BOX"),
589 Self::Polygon => f.write_str("POLYGON"),
590 Self::Line => f.write_str("LINE"),
591 Self::Circle => f.write_str("CIRCLE"),
592 Self::Inet => f.write_str("INET"),
593 Self::Cidr => f.write_str("CIDR"),
594 Self::Macaddr => f.write_str("MACADDR"),
595 Self::Macaddr8 => f.write_str("MACADDR8"),
596 Self::PgLsn => f.write_str("PG_LSN"),
597 Self::Bit(0) => f.write_str("BIT"),
598 Self::Bit(n) => write!(f, "BIT({n})"),
599 Self::BitVarying(0) => f.write_str("VARBIT"),
600 Self::BitVarying(n) => write!(f, "VARBIT({n})"),
601 Self::Xml => f.write_str("XML"),
602 Self::Char1 => f.write_str("\"char\""),
603 Self::MoneyArray => f.write_str("MONEY[]"),
604 Self::TsVector => f.write_str("TSVECTOR"),
605 Self::TsQuery => f.write_str("TSQUERY"),
606 Self::Uuid => f.write_str("UUID"),
607 Self::Time => f.write_str("TIME"),
608 Self::Year => f.write_str("YEAR"),
609 Self::TimeTz => f.write_str("TIMETZ"),
610 Self::Money => f.write_str("MONEY"),
611 Self::Range(k) => f.write_str(k.keyword()),
612 Self::Hstore => f.write_str("HSTORE"),
613 Self::IntArray2D => f.write_str("INT[][]"),
614 Self::BigIntArray2D => f.write_str("BIGINT[][]"),
615 Self::TextArray2D => f.write_str("TEXT[][]"),
616 Self::BoolArray2D => f.write_str("BOOL[][]"),
617 }
618 }
619}
620
621/// v7.12.0 — one entry in a `Value::TsVector`. The lexeme is the
622/// (already-tokenised + stemmed in v7.12.1+) word; `positions` is
623/// a strictly-ascending list of 1-based positions; `weight` is the
624/// PG weight letter (A=3, B=2, C=1, D=0) — v7.12.0 defaults every
625/// lexeme to D, the v7.12.2 ranking path consumes the weight.
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub struct TsLexeme {
628 pub word: String,
629 pub positions: Vec<u16>,
630 pub weight: u8,
631}
632
633/// v7.12.0 — parse tree for a PG `tsquery`. v7.12.0 ships the
634/// type + codec only; the `to_tsquery` / `plainto_tsquery` lexer
635/// lands in v7.12.1 and the `@@` evaluator in v7.12.2.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum TsQueryAst {
638 /// Single lexeme term. The `weight_mask` is the PG-style
639 /// bitmask of accepted weights (`A=1<<3`, `B=1<<2`, `C=1<<1`,
640 /// `D=1<<0`); `0` = any weight. v7.12.0 always sets it to 0.
641 Term {
642 word: String,
643 weight_mask: u8,
644 },
645 And(Box<TsQueryAst>, Box<TsQueryAst>),
646 Or(Box<TsQueryAst>, Box<TsQueryAst>),
647 Not(Box<TsQueryAst>),
648 /// `phrase <distance> phrase`. v7.12.0 only persists this; the
649 /// match semantics arrive in v7.12.2 alongside `@@`.
650 Phrase {
651 left: Box<TsQueryAst>,
652 right: Box<TsQueryAst>,
653 distance: u16,
654 },
655}
656
657/// v7.38.19 — whether an `interval` is finite, and if not, which way.
658///
659/// PostgreSQL has no NaN interval — measured, not assumed: `'nan'::interval`
660/// is a syntax error on 18.4 while `'infinity'` and `'-infinity'` parse —
661/// so this carries three states where `NumericKind` carries four.
662#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
663pub enum IntervalKind {
664 #[default]
665 Finite,
666 NegInf,
667 PosInf,
668}
669
670impl IntervalKind {
671 /// PostgreSQL's own representation of the two infinities, measured
672 /// off the wire rather than read out of its source.
673 ///
674 /// ```text
675 /// COPY (SELECT 'infinity'::interval) TO STDOUT (FORMAT binary)
676 /// … 7fffffffffffffff 7fffffff 7fffffff
677 /// COPY (SELECT '-infinity'::interval) TO STDOUT (FORMAT binary)
678 /// … 8000000000000000 80000000 80000000
679 /// COPY (SELECT '1 day'::interval) TO STDOUT (FORMAT binary)
680 /// … 0000000000000000 00000001 00000000
681 /// ```
682 ///
683 /// All three fields at their extreme, which is why SPG can carry an
684 /// explicit `kind` in memory -- so the compiler names every site
685 /// that has to decide what infinity means there -- and still write
686 /// sixteen bytes on disk and on the wire. No finite interval reaches
687 /// the triple: PostgreSQL reserves it, so no value PostgreSQL ever
688 /// produced holds it either, and a file written before this version
689 /// cannot contain one.
690 #[must_use]
691 pub const fn from_fields(months: i32, days: i32, micros: i64) -> Self {
692 if micros == i64::MAX && days == i32::MAX && months == i32::MAX {
693 Self::PosInf
694 } else if micros == i64::MIN && days == i32::MIN && months == i32::MIN {
695 Self::NegInf
696 } else {
697 Self::Finite
698 }
699 }
700
701 /// The three fields this kind is written as. `Finite` hands back
702 /// what it was given.
703 #[must_use]
704 pub const fn to_fields(self, months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
705 match self {
706 Self::Finite => (months, days, micros),
707 Self::PosInf => (i32::MAX, i32::MAX, i64::MAX),
708 Self::NegInf => (i32::MIN, i32::MIN, i64::MIN),
709 }
710 }
711
712 #[must_use]
713 pub const fn is_finite(self) -> bool {
714 matches!(self, Self::Finite)
715 }
716
717 /// Where this kind sits in the total order.
718 ///
719 /// v7.38.19 — PostgreSQL 18.4, measured: `'-infinity' < '-100 years'`
720 /// and `'infinity' > '100 years'` are both true, and `'infinity' =
721 /// 'infinity'` is true. So the rank decides first and the numbers
722 /// only speak between two finite values.
723 ///
724 /// Every comparison of two intervals asks THIS -- the ordering
725 /// comparator, the value comparator and the binary operators each
726 /// had their own copy of the span arithmetic, and three copies of a
727 /// question is how they come to disagree.
728 #[must_use]
729 pub const fn rank(self) -> i8 {
730 match self {
731 Self::NegInf => -1,
732 Self::Finite => 0,
733 Self::PosInf => 1,
734 }
735 }
736}
737
738/// A row-cell value, including SQL `NULL`. `Float` uses `f64`; NaN compares
739/// non-equal to itself (PG behaviour) — `PartialEq` is derived so callers
740/// must opt into NaN-aware comparison if they need stronger guarantees.
741///
742/// v7.37.42-arena Phase 1: parameterised on `'arena` so heap-bearing
743/// variants (Text/Json/Xml/Bytes/Vector/BitString.bytes) can borrow from
744/// a per-query bump arena (`Cow::Borrowed(&'arena ...)`). Persistent /
745/// catalog Values use `Value<'static>` (alias `ValueOwned`) with
746/// `Cow::Owned(...)`. Phase 1 keeps Range/Multirange recursive `Box<Value>`
747/// at `'static` (owned) — arena migration deferred to a later phase.
748/// Array-of-Option<String> variants (TextArray etc.) also stay owned in
749/// Phase 1; their nested shape is awkward for the simple Cow lift and the
750/// SCALARSQ hot path doesn't touch them.
751/// v7.38 (read01, T6) — the IEEE-style class of a NUMERIC value. `Finite` is the
752/// ordinary fixed-point case; the specials mirror PG's `'NaN'` / `'Infinity'` /
753/// `'-Infinity'`. Derived `PartialEq` gives `NaN == NaN` — correct for NUMERIC
754/// (unlike float's NaN ≠ NaN); the total order (`-Inf < finite < +Inf < NaN`)
755/// lives in the comparison paths, not in `Ord`.
756#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
757pub enum NumericKind {
758 #[default]
759 Finite,
760 NaN,
761 PosInf,
762 NegInf,
763}
764
765#[derive(Debug, Clone, PartialEq)]
766#[non_exhaustive]
767pub enum Value<'arena> {
768 SmallInt(i16),
769 Int(i32),
770 BigInt(i64),
771 Float(f64),
772 /// v7.38 (read01, T-float4) — PG `real` (32-bit IEEE float).
773 Real(f32),
774 Text(Cow<'arena, str>),
775 Bool(bool),
776 Vector(Cow<'arena, [f32]>),
777 /// v6.0.1: 8-bit scalar-quantised vector cell. Lives in
778 /// columns declared `VECTOR(N) USING SQ8`. Layout per cell:
779 /// `Sq8Vector { min: f32, max: f32, bytes: Vec<u8> }` —
780 /// 4× compression vs `Vector(Vec<f32>)`. The wire layer
781 /// dequantises to `f32` on SELECT; INSERT path quantises
782 /// incoming `Vector(Vec<f32>)` cells into this variant.
783 Sq8Vector(crate::quantize::Sq8Vector),
784 /// v6.0.3: IEEE-754 binary16 vector cell. Lives in columns
785 /// declared `VECTOR(N) USING HALF`. Stores raw u16 LE bits
786 /// (2× compression vs `Vector(Vec<f32>)`). Wire / display
787 /// paths dequantise to f32 bit-exactly; INSERT path converts
788 /// incoming f32 vectors at the engine boundary.
789 HalfVector(crate::halfvec::HalfVector),
790 /// Exact fixed-point decimal. `scaled` holds the value as
791 /// `actual * 10^scale` so the storage type is always integral —
792 /// arithmetic never falls back to floating-point. v7.38 (read01, T6) —
793 /// `kind` classifies the value as finite (the common case, using
794 /// `scaled`/`scale`) or one of PG's NUMERIC specials (NaN / ±Infinity),
795 /// which ignore `scaled`/`scale` (canonicalized to 0).
796 Numeric {
797 scaled: i128,
798 /// v7.39 (round 271) — widened from u8. PG's numeric carries a
799 /// display scale up to 16383; at u8 a literal with 256 decimal
800 /// places could not be represented at all, and the conversion
801 /// aborted the query with an internal error.
802 scale: u16,
803 kind: NumericKind,
804 },
805 /// v7.38 (read01, T3) — an exact NUMERIC whose mantissa overflows `i128`
806 /// (PG's NUMERIC is unbounded). Boxed so the common finite case keeps its
807 /// small footprint; specials never take this form (they stay `Numeric`).
808 NumericBig(alloc::boxed::Box<crate::bignum::BigNumeric>),
809 /// Days since the Unix epoch (1970-01-01). Negative for earlier dates.
810 Date(i32),
811 /// Microseconds since the Unix epoch (1970-01-01T00:00:00Z).
812 Timestamp(i64),
813 /// Calendar span: `months` + `days` + `micros`. Three fields are
814 /// required for PG byte-equal: `'1 day'` ≠ `'24 hours'` (DST,
815 /// month-boundary, and the on-wire `pg_type` `interval` are all
816 /// `i64 micros + i32 days + i32 months`). v7.37.5 β widened from
817 /// `{months, micros}`; column storage lands in the same window.
818 Interval {
819 months: i32,
820 days: i32,
821 micros: i64,
822 /// v7.38.19 — finite, or one of the two infinities.
823 ///
824 /// PostgreSQL 17 gave `interval` an infinite value and SPG had
825 /// none, so `'infinity'::interval` was refused outright and the
826 /// subtraction error the ledger described was one symptom of
827 /// that, not the defect.
828 ///
829 /// A field beside the numbers rather than a sentinel inside
830 /// them, which is the shape `Value::Numeric` already uses for
831 /// exactly this question — and a field on THIS variant rather
832 /// than a new one, so the compiler names every site that has to
833 /// decide what infinity means there. A new variant would have
834 /// compiled everywhere on the first try and let a `_` arm
835 /// answer for it at one of a hundred and five of them.
836 kind: IntervalKind,
837 },
838 /// v4.9 `JSON` — raw JSON text. No structural validation
839 /// happens at the storage layer; whatever the parser hands us
840 /// round-trips verbatim. Equality is byte-wise.
841 Json(Cow<'arena, str>),
842 /// v7.10.4 `BYTEA` — raw binary blob. Equality is byte-wise.
843 /// Layout matches `Text`'s length-prefixed shape (`[u32 LE
844 /// len][bytes]`) under tag 18; the engine accepts PG hex
845 /// literals (`'\xDEADBEEF'`) and escape literals at the
846 /// coercion boundary.
847 Bytes(Cow<'arena, [u8]>),
848 /// v7.10.9 `TEXT[]` — single-dimension TEXT array with
849 /// optional NULL elements. Equality is element-wise. PG's
850 /// NULL-element comparison semantics: NULL ≠ NULL inside
851 /// arrays under `=`, so `[NULL] != [NULL]` (the engine
852 /// honours this).
853 TextArray(Vec<Option<String>>),
854 /// v7.11.12 `INT[]` — single-dimension i32 array with optional
855 /// NULL elements. Codec mirrors TextArray with i32 LE per
856 /// element instead of length-prefixed UTF-8.
857 IntArray(Vec<Option<i32>>),
858 /// v7.11.12 `BIGINT[]` — single-dimension i64 array with optional
859 /// NULL elements.
860 BigIntArray(Vec<Option<i64>>),
861 /// v7.37.5 β-P4 `INTERVAL[]` — single-dimension array of
862 /// `IntervalSpan { months, days, micros }` with optional NULL
863 /// elements. PG external form quotes each non-NULL element
864 /// (`{"1 day","24:00:00",NULL}`) because interval text contains
865 /// spaces and colons. Storage codec follows the BigIntArray
866 /// shape with a 16-byte per-element body.
867 IntervalArray(Vec<Option<IntervalSpan>>),
868 /// v7.37.5 γ — single-dimension arrays of the remaining PG
869 /// scalar types. Each carries `Vec<Option<T>>` with the
870 /// scalar's natural Rust shape; element NULLs are first-class
871 /// (per PG: `{1,NULL,3}` is a 3-element array, not a 2-element
872 /// one). Codec follows the IntervalArray shape — `[u16 count]
873 /// [per elem: u8 null + (non-null) scalar body]`.
874 BoolArray(Vec<Option<bool>>),
875 SmallIntArray(Vec<Option<i16>>),
876 /// v7.39.11 — PG `int2vector`. An array of `smallint` that prints
877 /// space-separated and subscripts from 0; see
878 /// [`DataType::Int2Vector`]. PG's own vectors never hold NULLs, so
879 /// the elements are plain.
880 Int2Vector(Vec<i16>),
881 /// v7.39.11 — PG `oidvector`; see [`Value::Int2Vector`].
882 OidVector(Vec<u32>),
883 FloatArray(Vec<Option<f64>>),
884 /// PG `NUMERIC[]` — `(scaled: i128, scale: u16)` per element.
885 NumericArray(Vec<Option<(i128, u16)>>),
886 DateArray(Vec<Option<i32>>),
887 TimestampArray(Vec<Option<i64>>),
888 TimestamptzArray(Vec<Option<i64>>),
889 UuidArray(Vec<Option<[u8; 16]>>),
890 JsonArray(Vec<Option<String>>),
891 JsonbArray(Vec<Option<String>>),
892 BytesArray(Vec<Option<Vec<u8>>>),
893 VarcharArray(Vec<Option<String>>),
894 CharArray(Vec<Option<String>>),
895 /// v7.37.5 δ — PG 14+ multirange. `ranges` is a Vec of
896 /// non-overlapping bounds spans of the shared `kind`. PG's
897 /// canonical text form is `{[a,b),[c,d),...}` (comma-separated
898 /// ranges in braces; `{}` for the empty multirange). SPG's
899 /// constructor enforces no overlap/coalescing — for now the
900 /// engine trusts the caller (mirrors PG's `_construct_array`
901 /// pattern). Catalog tag 49 + 1-byte RangeKind on the dense
902 /// type-tag side; schema-less path is unreachable (multirange
903 /// is column-typed only).
904 Multirange {
905 kind: RangeKind,
906 ranges: Vec<RangeSpan>,
907 },
908 /// v7.37.5 ε — PG geometry scalars. Per-type Vec/struct shape;
909 /// codec body shape is described on the matching DataType
910 /// variant. PG canonical text forms:
911 /// Point `(x,y)`
912 /// Lseg `[(x1,y1),(x2,y2)]`
913 /// Path open `[(x,y),(x,y),...]` / closed `((x,y),(x,y),...)`
914 /// Box `(ux,uy),(lx,ly)` (PG normalises to upper-right + lower-left)
915 /// Polygon `((x,y),(x,y),...)` (implicit closed)
916 /// Line `{a,b,c}` (Ax + By + C = 0)
917 /// Circle `<(x,y),r>`
918 Point(Point2D),
919 Lseg(Point2D, Point2D),
920 /// `closed = true` is `((p,p,...))`; `false` is `[(p,p,...)]`.
921 Path {
922 points: Vec<Point2D>,
923 closed: bool,
924 },
925 /// PG `box` — stored as `(upper_right, lower_left)` (PG's
926 /// normalised order). The engine accepts both endpoint
927 /// orderings at parse time and normalises here.
928 PgBox(Point2D, Point2D),
929 Polygon(Vec<Point2D>),
930 Line {
931 a: f64,
932 b: f64,
933 c: f64,
934 },
935 Circle {
936 center: Point2D,
937 radius: f64,
938 },
939 /// v7.37.5 ζ-A — PG `inet`. `family = 4` (IPv4) or `6` (IPv6).
940 /// `bits` is the netmask bit count (0..=32 for IPv4, 0..=128
941 /// for IPv6). `addr` is right-padded with zeros when family=4
942 /// (first 4 bytes are the address).
943 Inet {
944 family: u8,
945 bits: u8,
946 addr: [u8; 16],
947 },
948 /// v7.37.5 ζ-A — PG `cidr`. Same shape as Inet; CIDR's
949 /// invariant (host bits zero) is enforced at parse / coerce.
950 Cidr {
951 family: u8,
952 bits: u8,
953 addr: [u8; 16],
954 },
955 /// v7.37.5 ζ-A — PG `macaddr`. 6 bytes (XX:XX:XX:XX:XX:XX).
956 Macaddr([u8; 6]),
957 /// v7.37.5 ζ-A — PG `macaddr8`. 8 bytes (EUI-64).
958 Macaddr8([u8; 8]),
959 /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn`, a 64-bit WAL location.
960 PgLsn(u64),
961 /// v7.39 (read01 ruleutils.c) — PG `regclass`: an OID-typed relation
962 /// reference that renders as the relation name. SPG carries BOTH
963 /// (the synthetic oid for catalog joins, the name for display) so
964 /// `conrelid = 't'::regclass` and `'t'::regclass::text` agree.
965 /// Eval-only (no column storage).
966 RegClass(i64, alloc::boxed::Box<str>),
967 /// v7.39 (round 342, V65) — PG `regproc`: an OID-typed FUNCTION
968 /// reference that renders as the function name. Same dual shape
969 /// [`Value::RegClass`] carries, and for the same reason: without the
970 /// oid half, `pg_proc.oid = 'f'::regproc` cannot join, and a callee
971 /// cannot tell `pg_get_functiondef('f'::regproc)` — which PG answers
972 /// — from `pg_get_functiondef('f')` — which PG rejects.
973 /// Eval-only (no column storage).
974 RegProc(i64, alloc::boxed::Box<str>),
975 /// v7.39 (round 648) — PG `regtype`: an OID-typed TYPE reference
976 /// that renders as the type name. The third of the shape
977 /// [`Value::RegClass`] and [`Value::RegProc`] carry, and the one
978 /// that was missing it: `::regtype` produced a plain `Value::Text`
979 /// holding the canonical name, so `'text'::regtype::oid` tried to
980 /// parse the NAME as a number and answered `invalid input syntax
981 /// for type oid: "text"` where PG answers 25. `pg_typeof` on one
982 /// said `text` rather than `regtype` for the same reason.
983 ///
984 /// Eval-only (no column storage).
985 RegType(i64, alloc::boxed::Box<str>),
986 /// v7.39 (round 512) — PG `xid` and `cid`, the transaction and command
987 /// ids the `xmin` / `xmax` / `cmin` / `cmax` system columns carry.
988 ///
989 /// Their own types rather than integers, because PG deliberately gives
990 /// them almost no operators: measured on PG18, `xmin + 1` is "operator
991 /// does not exist: xid + integer", `xmin > 0` likewise, `xmin::bigint`
992 /// is "cannot cast type xid to bigint", and there is no `max(xid)`.
993 /// Carrying them as BigInt would quietly allow all four.
994 ///
995 /// Eval-only (no column storage).
996 Xid(u32),
997 Cid(u32),
998 /// v7.39 (round 511) — PG `tid`, the physical row identity `ctid`
999 /// carries: a block number and a one-based offset inside it, rendered
1000 /// `(block,offset)`.
1001 ///
1002 /// It is a real type rather than a two-field record because the idiom
1003 /// that makes `ctid` worth having — `DELETE … WHERE ctid NOT IN (SELECT
1004 /// min(ctid) … GROUP BY key)` — needs `min()` over it, and PG has no
1005 /// `min(record)`. Ordering is by block then offset, so `(0,2) < (0,9) <
1006 /// (0,10)`; a text form would order those `(0,10) < (0,2) < (0,9)` and
1007 /// the dedup would keep the wrong row.
1008 ///
1009 /// Eval-only (no column storage).
1010 Tid(u32, u32),
1011 /// v7.37.5 ζ-A — PG `bit` / `bit varying`. `nbits` is the
1012 /// actual bit count; `bytes` is the packed representation
1013 /// (big-endian within each byte; final byte right-padded
1014 /// with 0s if `nbits % 8 != 0`).
1015 BitString {
1016 nbits: u32,
1017 bytes: Cow<'arena, [u8]>,
1018 },
1019 /// v7.37.5 ζ-A — PG `xml`. Stored verbatim as a string; no
1020 /// parse-time validation (matches the SPG JSON convention).
1021 Xml(Cow<'arena, str>),
1022 /// v7.37.5 ζ-A — PG `"char"` (internal single-byte type,
1023 /// distinct from CHAR(n)).
1024 Char1(u8),
1025 /// v7.38 (read01, T11) — PG `bpchar` / CHAR(n): blank-padded fixed-length
1026 /// string. Stored space-padded to the declared width (as PG does + for wire
1027 /// display); length / comparison / ::text / concat all ignore the trailing
1028 /// blanks (handled at those sites).
1029 BpChar(Cow<'arena, str>),
1030 /// v7.37.5 ζ-A — PG `money[]`.
1031 MoneyArray(Vec<Option<i64>>),
1032 /// v7.12.0 `tsvector` — sorted-by-word, deduped lexeme set with
1033 /// positions + weights. The engine enforces sort/dedup on
1034 /// construction; consumers can rely on `lexemes.windows(2)`
1035 /// being strictly ascending by `word`.
1036 TsVector(Vec<TsLexeme>),
1037 /// v7.12.0 `tsquery` — boolean / phrase parse tree over
1038 /// lexemes. Engine builds via `to_tsquery` family.
1039 TsQuery(TsQueryAst),
1040 /// v7.17.0 `uuid` — 128-bit identifier. Stored as 16 bytes
1041 /// (big-endian / network-byte order, same as RFC 4122).
1042 /// Display normalises to canonical lowercase 8-4-4-4-12
1043 /// hyphenated form. Equality is byte-wise.
1044 Uuid([u8; 16]),
1045 /// v7.17.0 Phase 3.P0-32 — PG `time` (without time zone) —
1046 /// i64 microseconds since 00:00:00. Range 0..86_400_000_000.
1047 /// Display: `HH:MM:SS` zero-padded, with optional `.ffffff`
1048 /// suffix when fractional is non-zero.
1049 Time(i64),
1050 /// v7.17.0 Phase 3.P0-33 — MySQL `YEAR` — u16 in range
1051 /// 1901..=2155 plus the special zero-year sentinel 0.
1052 /// Display always 4 digits zero-padded (`0000` for the
1053 /// sentinel; `1985`/`2007` otherwise).
1054 Year(u16),
1055 /// v7.17.0 Phase 3.P0-34 — PG `time with time zone` — i64
1056 /// microseconds since 00:00:00 in the LOCAL wall clock PLUS
1057 /// an i32 offset-from-UTC in seconds. PG preserves the
1058 /// offset on output, so the wall-clock value is NOT shifted
1059 /// to UTC at storage time. Offset range: ±50400 seconds
1060 /// (±14 hours).
1061 TimeTz {
1062 us: i64,
1063 offset_secs: i32,
1064 },
1065 /// v7.17.0 Phase 3.P0-35 — PG `money` — i64 cents
1066 /// (locale-independent storage; the en_US locale renders on
1067 /// display via `$N,NNN.CC`).
1068 Money(i64),
1069 /// v7.17.0 Phase 3.P0-39 — PG `hstore` value: flat
1070 /// `text => text` map with NULL value support. Insertion
1071 /// order preserved on input; duplicate keys take last-write-
1072 /// wins at parse time.
1073 Hstore(Vec<(String, Option<String>)>),
1074 /// v7.17.0 Phase 3.P0-40 — 2D INT matrix (row-major).
1075 IntArray2D(Vec<Vec<Option<i32>>>),
1076 /// v7.17.0 Phase 3.P0-40 — 2D BIGINT matrix (row-major).
1077 BigIntArray2D(Vec<Vec<Option<i64>>>),
1078 /// v7.17.0 Phase 3.P0-40 — 2D TEXT matrix (row-major).
1079 TextArray2D(Vec<Vec<Option<String>>>),
1080 /// v7.39 (read01 round 75) — see `DataType::BoolArray2D`.
1081 BoolArray2D(Vec<Vec<Option<bool>>>),
1082 /// v7.17.0 Phase 3.P0-38 — PG range value. One shape covers
1083 /// all six builtin range types; `kind` pins the element type
1084 /// (must match the column's `DataType::Range(kind)`).
1085 /// `lower` / `upper` are `None` for the unbounded sides;
1086 /// `lower_inc` / `upper_inc` mirror the canonical PG
1087 /// `[` / `(` / `]` / `)` bracket inclusivity. `empty=true`
1088 /// supersedes all other fields (the empty range has no
1089 /// bounds).
1090 Range {
1091 kind: RangeKind,
1092 // v7.37.42-arena Phase 1: Range bounds stay owned ('static).
1093 // Recursive arena lifetimes are awkward to migrate at this
1094 // phase and the SCALARSQ hot path doesn't construct ranges.
1095 lower: Option<alloc::boxed::Box<Value<'static>>>,
1096 upper: Option<alloc::boxed::Box<Value<'static>>>,
1097 lower_inc: bool,
1098 upper_inc: bool,
1099 empty: bool,
1100 },
1101 /// v7.38 (read01, T9) — a composite / record value (a `row(...)`
1102 /// constructor or a whole-row reference). Fields are `(name, value)`; the
1103 /// names are `f1..fN` for an anonymous `row(...)` or the source column
1104 /// names for a table row. Transient — flows through row_to_json / to_json
1105 /// and the composite text form `(a,b)`; not a storable column type here.
1106 Composite(alloc::vec::Vec<(alloc::string::String, Value<'static>)>),
1107 Null,
1108}
1109
1110/// Owned `Value` — heap-bearing variants are `Cow::Owned`. Used everywhere
1111/// a Value must outlive a query-scoped arena (catalog defaults, persistent
1112/// storage, public APIs).
1113pub type ValueOwned = Value<'static>;
1114
1115/// v7.37.5 ε — PG `point` building block. Shared by every other
1116/// geometric type (lseg / path / box / polygon / circle all
1117/// reduce to compositions of `Point2D`). Packed `{x: f64, y: f64}`,
1118/// 16 B, on-disk LE field order matches the PG binary point
1119/// format byte-for-byte (so a future binary BIND path lands
1120/// without rearrangement).
1121#[derive(Debug, Clone, Copy, PartialEq)]
1122pub struct Point2D {
1123 pub x: f64,
1124 pub y: f64,
1125}
1126
1127/// v7.37.5 δ — single-range bounds without the kind tag. Used as
1128/// the element type of `Value::Multirange { kind, ranges }` so a
1129/// multirange carries one shared `RangeKind` plus N bounds-only
1130/// spans (saves 1 byte/elem vs duplicating the kind). The five
1131/// other fields mirror `Value::Range` exactly.
1132#[derive(Debug, Clone, PartialEq)]
1133pub struct RangeSpan {
1134 // v7.37.42-arena Phase 1: stays owned ('static) — same rationale as
1135 // Range bounds above.
1136 pub lower: Option<alloc::boxed::Box<Value<'static>>>,
1137 pub upper: Option<alloc::boxed::Box<Value<'static>>>,
1138 pub lower_inc: bool,
1139 pub upper_inc: bool,
1140 pub empty: bool,
1141}
1142
1143/// v7.37.5 β-P4 — element type for `Value::IntervalArray`. Mirrors
1144/// the `{months, days, micros}` shape of scalar `Value::Interval`,
1145/// broken out as a named struct so `IntervalArray`'s element type
1146/// is concrete (24 bytes, packed) instead of an enum-boxed Value.
1147/// All three dimensions are independent — `IntervalSpan { days: 1,
1148/// .. }` is distinct from `IntervalSpan { micros: 86_400_000_000,
1149/// .. }` per PG byte-equal.
1150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1151pub struct IntervalSpan {
1152 pub months: i32,
1153 pub days: i32,
1154 pub micros: i64,
1155 /// v7.38.19 — see [`IntervalKind`].
1156 pub kind: IntervalKind,
1157}
1158
1159impl<'arena> Value<'arena> {
1160 /// Type tag, or `None` for `NULL` (unknown at value level).
1161 pub fn data_type(&self) -> Option<DataType> {
1162 match self {
1163 Self::SmallInt(_) => Some(DataType::SmallInt),
1164 Self::Int(_) => Some(DataType::Int),
1165 Self::BigInt(_) => Some(DataType::BigInt),
1166 Self::Float(_) => Some(DataType::Float),
1167 Self::Real(_) => Some(DataType::Real),
1168 // `Text` covers both unbounded TEXT and bounded VARCHAR/CHAR
1169 // — the constraint lives on the column schema, not the value.
1170 Self::Text(_) => Some(DataType::Text),
1171 Self::Bool(_) => Some(DataType::Bool),
1172 Self::Vector(v) => Some(DataType::Vector {
1173 dim: u32::try_from(v.len()).expect("vector dim ≤ u32"),
1174 encoding: VecEncoding::F32,
1175 }),
1176 Self::Sq8Vector(q) => Some(DataType::Vector {
1177 dim: u32::try_from(q.bytes.len()).expect("vector dim ≤ u32"),
1178 encoding: VecEncoding::Sq8,
1179 }),
1180 Self::HalfVector(h) => Some(DataType::Vector {
1181 dim: u32::try_from(h.dim()).expect("vector dim ≤ u32"),
1182 encoding: VecEncoding::F16,
1183 }),
1184 // `Value::Numeric` doesn't carry its precision (the column
1185 // schema does); we surface precision=0 as "unknown" and let
1186 // the engine reconcile against the column type at coercion
1187 // time.
1188 // v7.39 (round 273) — a VALUE's display scale is unsigned and
1189 // never exceeds PG's 16383 ceiling, so it always fits the
1190 // signed declared-scale field this describes itself with.
1191 Self::Numeric { scale, .. } => Some(DataType::Numeric {
1192 precision: 0,
1193 scale: i16::try_from(*scale).unwrap_or(i16::MAX),
1194 }),
1195 Self::NumericBig(b) => Some(DataType::Numeric {
1196 precision: 0,
1197 scale: i16::try_from(b.scale()).unwrap_or(i16::MAX),
1198 }),
1199 Self::Date(_) => Some(DataType::Date),
1200 Self::Timestamp(_) => Some(DataType::Timestamp),
1201 Self::Interval { .. } => Some(DataType::Interval),
1202 Self::Json(_) => Some(DataType::Json),
1203 Self::Bytes(_) => Some(DataType::Bytes),
1204 Self::TextArray(_) => Some(DataType::TextArray),
1205 Self::IntArray(_) => Some(DataType::IntArray),
1206 Self::BigIntArray(_) => Some(DataType::BigIntArray),
1207 Self::IntervalArray(_) => Some(DataType::IntervalArray),
1208 Self::BoolArray(_) => Some(DataType::BoolArray),
1209 Self::SmallIntArray(_) => Some(DataType::SmallIntArray),
1210 Self::Int2Vector(_) => Some(DataType::Int2Vector),
1211 Self::OidVector(_) => Some(DataType::OidVector),
1212 Self::FloatArray(_) => Some(DataType::FloatArray),
1213 Self::NumericArray(_) => Some(DataType::NumericArray),
1214 Self::DateArray(_) => Some(DataType::DateArray),
1215 Self::TimestampArray(_) => Some(DataType::TimestampArray),
1216 Self::TimestamptzArray(_) => Some(DataType::TimestamptzArray),
1217 Self::UuidArray(_) => Some(DataType::UuidArray),
1218 Self::JsonArray(_) => Some(DataType::JsonArray),
1219 Self::JsonbArray(_) => Some(DataType::JsonbArray),
1220 Self::BytesArray(_) => Some(DataType::BytesArray),
1221 Self::VarcharArray(_) => Some(DataType::VarcharArray),
1222 Self::CharArray(_) => Some(DataType::CharArray),
1223 Self::Multirange { kind, .. } => Some(DataType::Multirange(*kind)),
1224 Self::Point(_) => Some(DataType::Point),
1225 Self::Lseg(_, _) => Some(DataType::Lseg),
1226 Self::Path { .. } => Some(DataType::Path),
1227 Self::PgBox(_, _) => Some(DataType::PgBox),
1228 Self::Polygon(_) => Some(DataType::Polygon),
1229 Self::Line { .. } => Some(DataType::Line),
1230 Self::Circle { .. } => Some(DataType::Circle),
1231 Self::Inet { .. } => Some(DataType::Inet),
1232 Self::Cidr { .. } => Some(DataType::Cidr),
1233 Self::Macaddr(_) => Some(DataType::Macaddr),
1234 Self::Macaddr8(_) => Some(DataType::Macaddr8),
1235 Self::PgLsn(_) => Some(DataType::PgLsn),
1236 // BitString could be either Bit or BitVarying; column
1237 // schema decides. Default to BitVarying when called
1238 // schema-less (rare; storage path is always
1239 // schema-aware so this only matters for diagnostics).
1240 Self::BitString { .. } => Some(DataType::BitVarying(0)),
1241 Self::Xml(_) => Some(DataType::Xml),
1242 Self::Char1(_) => Some(DataType::Char1),
1243 // BpChar reports its declared width from the padded length.
1244 Self::BpChar(s) => Some(DataType::Char(
1245 u32::try_from(s.chars().count()).unwrap_or(0),
1246 )),
1247 Self::MoneyArray(_) => Some(DataType::MoneyArray),
1248 Self::TsVector(_) => Some(DataType::TsVector),
1249 Self::TsQuery(_) => Some(DataType::TsQuery),
1250 Self::Uuid(_) => Some(DataType::Uuid),
1251 Self::Time(_) => Some(DataType::Time),
1252 Self::Year(_) => Some(DataType::Year),
1253 Self::TimeTz { .. } => Some(DataType::TimeTz),
1254 Self::Money(_) => Some(DataType::Money),
1255 Self::Range { kind, .. } => Some(DataType::Range(*kind)),
1256 Self::Hstore(_) => Some(DataType::Hstore),
1257 Self::IntArray2D(_) => Some(DataType::IntArray2D),
1258 Self::BigIntArray2D(_) => Some(DataType::BigIntArray2D),
1259 Self::TextArray2D(_) => Some(DataType::TextArray2D),
1260 Self::BoolArray2D(_) => Some(DataType::BoolArray2D),
1261 // v7.38 (read01, T9) — a transient composite/record has no storable
1262 // column DataType (it flows through row_to_json / to_json).
1263 Self::Composite(_) => None,
1264 // v7.39 (read01 ruleutils.c) — regclass is eval-only (dual
1265 // oid+name shape); no column storage type.
1266 // v7.39 (round 640) — `xid` became a column type, so its value
1267 // has a DataType to answer with. `cid` and `tid` are equally
1268 // legal column types on PG (measured: `CREATE TABLE t (a cid,
1269 // b tid)` is accepted), but SPG's grammar has no keyword for
1270 // them yet; they stay eval-only rather than half-declared.
1271 Self::Xid(_) => Some(DataType::Xid),
1272 Self::RegClass(..)
1273 | Self::RegProc(..)
1274 | Self::RegType(..)
1275 | Self::Tid(..)
1276 | Self::Cid(_) => None,
1277 Self::Null => None,
1278 }
1279 }
1280
1281 pub const fn is_null(&self) -> bool {
1282 matches!(self, Self::Null)
1283 }
1284
1285 /// v7.37.42-arena Phase 1: lift any `Value<'arena>` (possibly
1286 /// borrowing from a bump arena) into a fully-owned `Value<'static>`.
1287 /// Used at boundaries that must outlive the per-query arena
1288 /// (catalog write, public QueryResult emit, sqlx materialise).
1289 ///
1290 /// For the recursive Range/Multirange variants — bounds are already
1291 /// `Box<Value<'static>>` per Phase 1 design, so we just rebuild the
1292 /// outer enum at `'static`.
1293 pub fn into_owned(self) -> Value<'static> {
1294 match self {
1295 Value::SmallInt(n) => Value::SmallInt(n),
1296 Value::Int(n) => Value::Int(n),
1297 Value::BigInt(n) => Value::BigInt(n),
1298 Value::Float(f) => Value::Float(f),
1299 Value::Real(f) => Value::Real(f),
1300 Value::Text(s) => Value::Text(Cow::Owned(s.into_owned())),
1301 Value::Bool(b) => Value::Bool(b),
1302 Value::Vector(v) => Value::Vector(Cow::Owned(v.into_owned())),
1303 Value::Sq8Vector(q) => Value::Sq8Vector(q),
1304 Value::HalfVector(h) => Value::HalfVector(h),
1305 Value::Numeric {
1306 scaled,
1307 scale,
1308 kind,
1309 } => Value::Numeric {
1310 scaled,
1311 scale,
1312 kind,
1313 },
1314 Value::NumericBig(b) => Value::NumericBig(b),
1315 Value::Date(d) => Value::Date(d),
1316 Value::Timestamp(t) => Value::Timestamp(t),
1317 Value::Interval {
1318 months,
1319 days,
1320 micros,
1321 kind,
1322 } => Value::Interval {
1323 months,
1324 days,
1325 micros,
1326 kind,
1327 },
1328 Value::Json(s) => Value::Json(Cow::Owned(s.into_owned())),
1329 Value::Bytes(b) => Value::Bytes(Cow::Owned(b.into_owned())),
1330 Value::TextArray(v) => Value::TextArray(v),
1331 Value::IntArray(v) => Value::IntArray(v),
1332 Value::BigIntArray(v) => Value::BigIntArray(v),
1333 Value::IntervalArray(v) => Value::IntervalArray(v),
1334 Value::BoolArray(v) => Value::BoolArray(v),
1335 Value::SmallIntArray(v) => Value::SmallIntArray(v),
1336 Value::Int2Vector(v) => Value::Int2Vector(v),
1337 Value::OidVector(v) => Value::OidVector(v),
1338 Value::FloatArray(v) => Value::FloatArray(v),
1339 Value::NumericArray(v) => Value::NumericArray(v),
1340 Value::DateArray(v) => Value::DateArray(v),
1341 Value::TimestampArray(v) => Value::TimestampArray(v),
1342 Value::TimestamptzArray(v) => Value::TimestamptzArray(v),
1343 Value::UuidArray(v) => Value::UuidArray(v),
1344 Value::JsonArray(v) => Value::JsonArray(v),
1345 Value::JsonbArray(v) => Value::JsonbArray(v),
1346 Value::BytesArray(v) => Value::BytesArray(v),
1347 Value::VarcharArray(v) => Value::VarcharArray(v),
1348 Value::CharArray(v) => Value::CharArray(v),
1349 Value::Multirange { kind, ranges } => Value::Multirange { kind, ranges },
1350 // v7.38 (read01, T9) — Composite fields are already `Value<'static>`.
1351 Value::Composite(fields) => Value::Composite(fields),
1352 Value::RegClass(oid, name) => Value::RegClass(oid, name),
1353 Value::Tid(b, o) => Value::Tid(b, o),
1354 Value::Xid(x) => Value::Xid(x),
1355 Value::Cid(c) => Value::Cid(c),
1356 Value::RegProc(oid, name) => Value::RegProc(oid, name),
1357 Value::RegType(oid, name) => Value::RegType(oid, name),
1358 Value::Point(p) => Value::Point(p),
1359 Value::Lseg(a, b) => Value::Lseg(a, b),
1360 Value::Path { points, closed } => Value::Path { points, closed },
1361 Value::PgBox(a, b) => Value::PgBox(a, b),
1362 Value::Polygon(p) => Value::Polygon(p),
1363 Value::Line { a, b, c } => Value::Line { a, b, c },
1364 Value::Circle { center, radius } => Value::Circle { center, radius },
1365 Value::Inet { family, bits, addr } => Value::Inet { family, bits, addr },
1366 Value::Cidr { family, bits, addr } => Value::Cidr { family, bits, addr },
1367 Value::Macaddr(m) => Value::Macaddr(m),
1368 Value::Macaddr8(m) => Value::Macaddr8(m),
1369 Value::PgLsn(l) => Value::PgLsn(l),
1370 Value::BitString { nbits, bytes } => Value::BitString {
1371 nbits,
1372 bytes: Cow::Owned(bytes.into_owned()),
1373 },
1374 Value::Xml(s) => Value::Xml(Cow::Owned(s.into_owned())),
1375 Value::Char1(c) => Value::Char1(c),
1376 Value::BpChar(s) => Value::BpChar(Cow::Owned(s.into_owned())),
1377 Value::MoneyArray(v) => Value::MoneyArray(v),
1378 Value::TsVector(v) => Value::TsVector(v),
1379 Value::TsQuery(q) => Value::TsQuery(q),
1380 Value::Uuid(u) => Value::Uuid(u),
1381 Value::Time(t) => Value::Time(t),
1382 Value::Year(y) => Value::Year(y),
1383 Value::TimeTz { us, offset_secs } => Value::TimeTz { us, offset_secs },
1384 Value::Money(m) => Value::Money(m),
1385 Value::Range {
1386 kind,
1387 lower,
1388 upper,
1389 lower_inc,
1390 upper_inc,
1391 empty,
1392 } => Value::Range {
1393 kind,
1394 lower,
1395 upper,
1396 lower_inc,
1397 upper_inc,
1398 empty,
1399 },
1400 Value::Hstore(h) => Value::Hstore(h),
1401 Value::IntArray2D(a) => Value::IntArray2D(a),
1402 Value::BigIntArray2D(a) => Value::BigIntArray2D(a),
1403 Value::TextArray2D(a) => Value::TextArray2D(a),
1404 Value::BoolArray2D(a) => Value::BoolArray2D(a),
1405 Value::Null => Value::Null,
1406 }
1407 }
1408
1409 /// v7.37.42-arena Phase 4 — copy heap payloads into the supplied
1410 /// bump arena, yielding a `Value<'a>` whose Cow-variant payloads
1411 /// are arena-borrowed (or stay as small owned scalars for the
1412 /// `Copy`-able variants).
1413 ///
1414 /// Used at the catalog ↔ ephemeral boundary: a `ColumnSchema.default`
1415 /// is `Value<'static>` but INSERT-time eval may want it stamped into
1416 /// the per-statement arena alongside other arena-built scalars.
1417 ///
1418 /// Allocates only into the supplied arena; the input `&self` keeps
1419 /// its own storage. For `Copy`-able / nested-owned variants the
1420 /// implementation falls back to `clone()` (the nested heap blocks
1421 /// stay on the global allocator, which is fine — the boundary
1422 /// requirement is just "no aliasing of caller-owned strings").
1423 pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Value<'a> {
1424 match self {
1425 Value::Text(s) => Value::Text(Cow::Borrowed(arena.alloc_str(s))),
1426 Value::Json(s) => Value::Json(Cow::Borrowed(arena.alloc_str(s))),
1427 Value::Xml(s) => Value::Xml(Cow::Borrowed(arena.alloc_str(s))),
1428 Value::BpChar(s) => Value::BpChar(Cow::Borrowed(arena.alloc_str(s))),
1429 Value::Bytes(b) => {
1430 let slot = arena.alloc_slice_copy::<u8>(b);
1431 Value::Bytes(Cow::Borrowed(slot))
1432 }
1433 Value::Vector(v) => {
1434 let slot = arena.alloc_slice_copy::<f32>(v);
1435 Value::Vector(Cow::Borrowed(slot))
1436 }
1437 Value::BitString { nbits, bytes } => {
1438 let slot = arena.alloc_slice_copy::<u8>(bytes);
1439 Value::BitString {
1440 nbits: *nbits,
1441 bytes: Cow::Borrowed(slot),
1442 }
1443 }
1444 // Copy-able scalars + variants whose nested heap blocks are
1445 // `'static` regardless of `'arena` (TextArray, JsonArray,
1446 // Hstore, TsVector, Range bounds, …). Clone the heap block
1447 // via the standard `into_owned()` path then lift the
1448 // resulting `Value<'static>` to `Value<'a>` via the Cow
1449 // variance — `'static` covers any lifetime.
1450 other => other.clone().into_owned(),
1451 }
1452 }
1453}
1454
1455impl Value<'static> {
1456 /// v7.37.42-arena Phase 1 — owned-Text constructor. The variant now
1457 /// holds `Cow<'arena, str>`, so the previous `Value::Text(String)`
1458 /// shape no longer compiles directly. This helper preserves the
1459 /// historical ergonomics: `Value::text("foo")` or
1460 /// `Value::text(String::from("foo"))`.
1461 pub fn text<S: Into<String>>(s: S) -> Self {
1462 Value::Text(Cow::Owned(s.into()))
1463 }
1464
1465 /// v7.38 (read01, T6) — a finite NUMERIC from its fixed-point parts.
1466 pub const fn numeric(scaled: i128, scale: u16) -> Self {
1467 Value::Numeric {
1468 scaled,
1469 scale,
1470 kind: NumericKind::Finite,
1471 }
1472 }
1473
1474 /// v7.38 (read01, T6) — a special NUMERIC (NaN / ±Infinity). The fixed-point
1475 /// fields are canonicalized to 0 so equal specials compare byte-identical.
1476 pub const fn numeric_special(kind: NumericKind) -> Self {
1477 Value::Numeric {
1478 scaled: 0,
1479 scale: 0,
1480 kind,
1481 }
1482 }
1483
1484 /// v7.37.42-arena Phase 1 — owned-Json constructor (mirrors `text`).
1485 pub fn json<S: Into<String>>(s: S) -> Self {
1486 Value::Json(Cow::Owned(s.into()))
1487 }
1488
1489 /// v7.37.42-arena Phase 1 — owned-Xml constructor.
1490 pub fn xml<S: Into<String>>(s: S) -> Self {
1491 Value::Xml(Cow::Owned(s.into()))
1492 }
1493
1494 /// v7.37.42-arena Phase 1 — owned-Bytes constructor.
1495 pub fn bytes<B: Into<Vec<u8>>>(b: B) -> Self {
1496 Value::Bytes(Cow::Owned(b.into()))
1497 }
1498
1499 /// v7.37.42-arena Phase 1 — owned-Vector constructor.
1500 pub fn vector<V: Into<Vec<f32>>>(v: V) -> Self {
1501 Value::Vector(Cow::Owned(v.into()))
1502 }
1503
1504 /// v7.37.42-arena Phase 1 — owned-BitString constructor.
1505 pub fn bit_string<B: Into<Vec<u8>>>(nbits: u32, bytes: B) -> Self {
1506 Value::BitString {
1507 nbits,
1508 bytes: Cow::Owned(bytes.into()),
1509 }
1510 }
1511}
1512
1513/// One table row — values are positional and must match
1514/// `TableSchema.columns` in length and (modulo NULL) in `DataType`.
1515///
1516/// v7.37.42-arena Phase 1: parameterised on `'arena` so per-query rows
1517/// can borrow from a bump arena. The owned shape (`Row<'static>`, alias
1518/// `RowOwned`) is what catalog storage, public APIs, and tests use.
1519#[derive(Debug, Clone, PartialEq)]
1520pub struct Row<'arena> {
1521 pub values: Vec<Value<'arena>>,
1522}
1523
1524/// Owned `Row` — values are `Value<'static>`. Used everywhere a row must
1525/// outlive a query-scoped arena.
1526pub type RowOwned = Row<'static>;
1527
1528impl<'arena> Row<'arena> {
1529 pub const fn new(values: Vec<Value<'arena>>) -> Self {
1530 Self { values }
1531 }
1532
1533 pub fn len(&self) -> usize {
1534 self.values.len()
1535 }
1536
1537 pub fn is_empty(&self) -> bool {
1538 self.values.is_empty()
1539 }
1540}
1541
1542impl<'arena> Row<'arena> {
1543 /// v7.37.42-arena Phase 4 — copy every cell into the supplied bump
1544 /// arena, yielding a `Row<'a>` whose Cow-payloads are arena-borrowed.
1545 /// Boundary helper for catalog defaults → DML eval handoff and
1546 /// arena-local row scratch.
1547 pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Row<'a> {
1548 Row {
1549 values: self.values.iter().map(|v| v.clone_into(arena)).collect(),
1550 }
1551 }
1552
1553 /// v7.37.42-arena Phase 4 — lift this `Row<'arena>` to a fully-owned
1554 /// `Row<'static>` for catalog write / WAL serialisation. Equivalent
1555 /// to `Row::from_arena(self)` but consumes by value at any lifetime
1556 /// (callers can write `row.into_owned()` mirroring `Value::into_owned`).
1557 pub fn into_owned(self) -> Row<'static> {
1558 Row {
1559 values: self.values.into_iter().map(Value::into_owned).collect(),
1560 }
1561 }
1562}
1563
1564impl Row<'static> {
1565 /// v7.37.42-arena Phase 1 — lift any `Row<'arena>` (possibly arena-
1566 /// borrowed) into a fully-owned `Row<'static>`. Mirrors
1567 /// `Value::into_owned`.
1568 pub fn from_arena(row: Row<'_>) -> Self {
1569 Self {
1570 values: row.values.into_iter().map(Value::into_owned).collect(),
1571 }
1572 }
1573}
1574
1575/// Each bool is an independent, separately-persisted column attribute
1576/// (`nullable`, `auto_increment`, `is_unsigned`, `identity_always`) that the
1577/// catalog appendix reads and writes by name. Packing them into a bitflags
1578/// word would buy nothing and would put a decoding step between the on-disk
1579/// format and every reader of the schema.
1580#[allow(clippy::struct_excessive_bools)]
1581#[derive(Debug, Clone, PartialEq)]
1582pub struct ColumnSchema {
1583 pub name: String,
1584 pub ty: DataType,
1585 pub nullable: bool,
1586 /// Optional `DEFAULT` value, frozen at CREATE TABLE time. `None`
1587 /// means "no default" (so omitted columns become NULL, or error
1588 /// out when the column is NOT NULL). Literal defaults take this
1589 /// path.
1590 ///
1591 /// v7.37.42-arena Phase 1: explicitly `Value<'static>` — catalog
1592 /// defaults must outlive any per-query arena.
1593 pub default: Option<Value<'static>>,
1594 /// v7.9.21 — for DEFAULT expressions that need INSERT-time
1595 /// evaluation (e.g. `DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`),
1596 /// the Display form of the expression. The engine re-parses
1597 /// it on each INSERT default-fill, evaluates against an empty
1598 /// row context, and coerces to the column type. mailrs G4.
1599 /// Persisted in catalog FILE_VERSION 15+; older catalogs
1600 /// deserialise with None.
1601 pub runtime_default: Option<String>,
1602 /// MySQL-style `AUTO_INCREMENT`. When set, an INSERT that leaves
1603 /// this column unbound (or sets it to NULL) gets the next integer
1604 /// computed from the column's current max + 1.
1605 /// v7.39 (round 676) — the collation NAME as written, when the column
1606 /// carried an explicit `COLLATE`.
1607 ///
1608 /// `spg_sql::Collation` cannot carry it: it is a two-variant MySQL enum
1609 /// and `from_collation_name` folds `C`, `POSIX`, `en_US` and `default`
1610 /// all into `Binary`. Without the name `pg_attribute.attcollation` can
1611 /// only ever report the type's default, which is what F36 records as
1612 /// "the declaration is taken and ignored".
1613 ///
1614 /// None means the column was written without a `COLLATE` clause and
1615 /// takes its type's collation. Persisted through the v88 appendix,
1616 /// which costs two bytes for a table that declares none.
1617 pub collation_name: Option<String>,
1618 pub auto_increment: bool,
1619 /// v7.17.0 Phase 1.4 — when the column is bound to a user-
1620 /// defined ENUM type (the parser saw an unknown type ident
1621 /// and the engine resolved it against `catalog.enum_types`),
1622 /// this carries the enum name so INSERT/UPDATE can validate
1623 /// the cell value against the enum's labels. `ty` is
1624 /// `DataType::Text` in that case. Persisted in catalog
1625 /// FILE_VERSION 29+; older catalogs deserialise with None.
1626 pub user_enum_type: Option<String>,
1627 /// v7.17.0 Phase 1.5 — when the column is bound to a user-
1628 /// defined DOMAIN (the parser saw an unknown type ident and
1629 /// the engine resolved it against `catalog.domain_types`),
1630 /// this carries the domain name. `ty` is the domain's base
1631 /// type; INSERT/UPDATE re-evaluates the domain's CHECK list
1632 /// + NOT NULL against the cell value. Persisted in catalog
1633 /// FILE_VERSION 30+; older catalogs deserialise with None.
1634 pub user_domain_type: Option<String>,
1635 /// v7.39 (read01 round 56) — when the column is bound to a user-defined
1636 /// COMPOSITE type. `ty` stays `DataType::Jsonb` (the on-disk form), but the
1637 /// engine REHYDRATES the stored JSON into a `Value::Composite` on read, so
1638 /// field access `(p).x`, `= ROW(…)`, ordering and the canonical `(2,b)`
1639 /// text form all work — they were already implemented on Value::Composite;
1640 /// what was missing was that the column never recorded WHICH composite type
1641 /// it holds (this field's doc comment existed for two releases, the field
1642 /// itself did not). Persisted in the composite-column appendix
1643 /// (FILE_VERSION 63+); older catalogs deserialise with None.
1644 pub user_composite_type: Option<String>,
1645 /// v7.39 (read01 round 59) — column-level privileges (PG
1646 /// `pg_attribute.attacl`). `GRANT SELECT (pub) ON t TO dan` lands here and
1647 /// does NOT touch the table's `relacl`. Empty = no column grant, which is
1648 /// every column until one is made.
1649 pub acl: Vec<AclItem>,
1650 /// v7.17.0 Phase 2.1 — MySQL `ON UPDATE CURRENT_TIMESTAMP`
1651 /// column attribute. When `Some(expr_src)`, an UPDATE that
1652 /// does NOT bind this column overrides the new value with
1653 /// the engine-evaluated expression (always `now()` in
1654 /// v7.17.0). Stored as Display-form source so storage
1655 /// stays free of spg-sql; the engine re-parses at UPDATE
1656 /// time. Persisted in catalog FILE_VERSION 32+; older
1657 /// catalogs deserialise with None — preserves the existing
1658 /// "silent ignore" behaviour for snapshots written before
1659 /// the upgrade.
1660 pub on_update_runtime: Option<String>,
1661 /// v7.17.0 Phase 2.5 — text collation. Pre-2.5 SPG accepted
1662 /// `COLLATE <name>` clauses but discarded the name, so a
1663 /// column declared `COLLATE "case_insensitive"` (or any
1664 /// MySQL `_ci` collation) still compared byte-wise — a
1665 /// Tier-S silent failure where `WHERE name = 'foo'` never
1666 /// matched stored `'Foo'`. This carries the parser-derived
1667 /// classification so the engine's WHERE evaluator can route
1668 /// text equality through a case-aware compare. `Binary` (the
1669 /// default) preserves the prior byte-wise behaviour. Only
1670 /// CaseInsensitive lands in the catalog appendix — Binary
1671 /// columns stay implicit, keeping snapshots compact.
1672 /// Persisted in catalog FILE_VERSION 34+; older catalogs
1673 /// deserialise every column as `Binary`.
1674 pub collation: Collation,
1675 /// v7.17.0 Phase 4.4 — MySQL `UNSIGNED` modifier flag. Drives
1676 /// engine-side INSERT / UPDATE range enforcement (rejects
1677 /// negative values on UNSIGNED int columns). Pre-4.4 the
1678 /// parser consumed and discarded the keyword silently, so
1679 /// every UNSIGNED column quietly accepted negatives — a
1680 /// Tier-A correctness drift. Sparse: only UNSIGNED columns
1681 /// land in the catalog appendix; the default `false` keeps
1682 /// snapshots compact for the common signed-int path.
1683 /// Persisted in catalog FILE_VERSION 35+; older catalogs
1684 /// deserialise every column as `is_unsigned = false`.
1685 pub is_unsigned: bool,
1686 /// v7.17.0 Phase 3.P0-36 — MySQL inline `ENUM('a','b','c')`
1687 /// value list. Distinct from `user_enum_type` (which points
1688 /// to a separately CREATE TYPE'd PG enum); this carries the
1689 /// column-local list MySQL DDL declares inline. When `Some`,
1690 /// `ty` is `DataType::Text` and INSERT/UPDATE validates the
1691 /// cell value against this list. Variant ORDER is preserved
1692 /// (MySQL uses it for `ORDER BY col`). Sparse: only ENUM
1693 /// columns land in the catalog appendix.
1694 /// Persisted in catalog FILE_VERSION 41+; older catalogs
1695 /// deserialise with None — preserves silent-drop behaviour
1696 /// for snapshots written before P0-36.
1697 pub inline_enum_variants: Option<Vec<String>>,
1698 /// v7.17.0 Phase 3.P0-37 — MySQL inline `SET('a','b','c')`
1699 /// variant list. Storage is TEXT (canonical comma-joined in
1700 /// definition order, de-duplicated). INSERT/UPDATE validates
1701 /// every comma-separated token against this list. Sparse:
1702 /// only SET columns land in the catalog appendix.
1703 /// Persisted in catalog FILE_VERSION 42+; older catalogs
1704 /// deserialise with None.
1705 pub inline_set_variants: Option<Vec<String>>,
1706 /// v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS (<expr>)
1707 /// STORED` computed-column source. When `Some`, INSERT / UPDATE
1708 /// recompute the cell against the candidate row(re-parse the
1709 /// stored Display form and evaluate)and overwrite any
1710 /// user-supplied value, matching PG's stored-generated-column
1711 /// semantics. `None` (the default) preserves the regular
1712 /// "column value is whatever the caller passed" path.
1713 /// Persisted in catalog FILE_VERSION 50+; older catalogs
1714 /// deserialise with None.
1715 pub generated_stored_expr: Option<String>,
1716 /// v7.38 (read01) — `GENERATED ALWAYS AS IDENTITY`. Both identity
1717 /// flavours set `auto_increment`; this additionally marks the ALWAYS
1718 /// flavour, whose explicit INSERT value PG rejects ("cannot insert a
1719 /// non-DEFAULT value into column …") unless `OVERRIDING SYSTEM VALUE`.
1720 /// `false` (serial / `BY DEFAULT`) keeps the permissive path. In-memory
1721 /// only for now — not yet in the catalog appendix, so a reloaded table
1722 /// deserialises as `false` (the pre-existing permissive behaviour).
1723 pub identity_always: bool,
1724 /// v7.38 (read01) — the DEFAULT expression's source text, deparsed to
1725 /// PG-compatible form at CREATE TABLE time (e.g. `0`, `(3 + 4)`,
1726 /// `'hi'::text`, `now()`, `CURRENT_DATE`). Distinct from `default`
1727 /// (the coerced value the INSERT path fills) and `runtime_default`
1728 /// (the recompute-per-row Display form): those lose the source
1729 /// spelling, so `information_schema.columns.column_default` /
1730 /// `pg_attrdef` / `pg_get_expr` reported the coerced render
1731 /// (`0.00` for `numeric(10,2) DEFAULT 0`) instead of PG's `0`.
1732 /// `None` for a column with no explicit default. Persisted in catalog
1733 /// FILE_VERSION 58+; older catalogs deserialise with None.
1734 pub default_text: Option<String>,
1735 /// v7.39 (round 220) — `ALTER TABLE … ALTER COLUMN … RESTART [WITH n]`
1736 /// on an identity column. SPG's identity allocation is a max+1 scan;
1737 /// this floor lifts the next allocated value to at least `n`
1738 /// (`max(max+1, n)`) — exactly what a dump-restore RESTART needs, and
1739 /// safer than PG for a backward RESTART (no duplicate-key landmine).
1740 /// Persisted in the FILE_VERSION 73+ sparse appendix; older catalogs
1741 /// deserialise with None.
1742 pub auto_restart: Option<i64>,
1743 /// v7.39 (read01 round 78) — this column is the ONLY column of a FROM item
1744 /// that calls a function returning a BASE type, so the item's row type IS
1745 /// this column: a whole-row reference collapses to the value
1746 /// (`SELECT j FROM jsonb_array_elements('[1]') AS j` → `1`, PG). Runtime
1747 /// only — a catalogued table column is never one, and it is not persisted.
1748 pub scalar_row_source: bool,
1749 /// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1750 /// integer width (TINYINT / MEDIUMINT) whose range the storage `ty`
1751 /// (SmallInt / Int) is too wide to enforce. `None` for every other
1752 /// column. Drives the epic-P2 write-path range check. Persisted in the
1753 /// FILE_VERSION 81+ sparse appendix; older catalogs deserialise as None.
1754 pub mysql_int_width: Option<MysqlIntWidth>,
1755 /// v7.39 (round 424, type-fidelity epic) — the declared MySQL
1756 /// fractional-seconds precision of a temporal column: `DATETIME(3)` is
1757 /// `Some(3)`, a BARE `DATETIME` / `TIME` / `TIMESTAMP` is `Some(0)`
1758 /// (MySQL's default is zero — the fraction is dropped on write), and
1759 /// `None` means "not a MySQL-declared temporal column", which is every
1760 /// PG column and leaves microsecond behaviour untouched.
1761 ///
1762 /// Drives write-path truncation (toward zero) and render padding
1763 /// (exactly this many digits, `.000` when the fraction is zero).
1764 /// Persisted in the FILE_VERSION 82+ sparse appendix; older catalogs
1765 /// deserialise as None.
1766 pub mysql_fsp: Option<u8>,
1767 /// v7.39.2 — this column was DECLARED `TIMESTAMP` in a MySQL
1768 /// session.
1769 ///
1770 /// MySQL and MariaDB both keep `timestamp` and `datetime` apart in
1771 /// `SHOW CREATE TABLE`, `SHOW COLUMNS` and `information_schema`
1772 /// (measured on 9.7.2 and 12.3.3); SPG stores both as
1773 /// `DataType::Timestamp` and so reported `datetime` for both. A
1774 /// client dumping and reloading had the column's declared type
1775 /// SILENTLY CHANGED — and MySQL's TIMESTAMP is not DATETIME: it has
1776 /// a different range and converts to and from UTC.
1777 ///
1778 /// What this records is the SPELLING, which is the half a dump
1779 /// round-trips. The storage and the semantics are unchanged, and
1780 /// that gap is written down rather than papered over.
1781 ///
1782 /// Persisted in the FILE_VERSION 93+ sparse appendix; older
1783 /// catalogs deserialise as `false`.
1784 pub mysql_declared_timestamp: bool,
1785 /// v7.39.3 — a MySQL `FLOAT(m,d)` / `DOUBLE(m,d)`'s declared pair.
1786 ///
1787 /// The digits are NOT a display hint, which is what SPG's comment
1788 /// claimed and 7.39.2 recorded as a residual: MySQL 9.7.2 ROUNDS on
1789 /// write (3.14159265358979 into either stores 3.14) and refuses a
1790 /// value wider than `m` with errno 1264. SPG accepted the syntax and
1791 /// kept the full double, so a column declared for money held more
1792 /// precision than the schema said and every reader saw a different
1793 /// number from MySQL's.
1794 ///
1795 /// Persisted in the FILE_VERSION 94+ sparse appendix; older catalogs
1796 /// deserialise as None, which is "no declared pair".
1797 pub mysql_float_md: Option<(u8, u8)>,
1798}
1799
1800/// v7.17.0 Phase 2.5 — column-level text collation. Drives the
1801/// engine's WHERE / GROUP BY equality routing for `Value::Text`.
1802/// Only two variants are modelled in v7.17:
1803/// * `Binary` — byte-wise comparison (the SPG default;
1804/// matches PG `COLLATE "C"` / `pg_catalog.default`
1805/// and MySQL `*_bin`).
1806/// * `CaseInsensitive` — ASCII case-folded comparison (like
1807/// MySQL `*_ci` collations; PG has NO built-in
1808/// collation of this name — round-761 audit: a
1809/// nondeterministic ICU collation must be CREATEd
1810/// there first). Non-ASCII bytes
1811/// still compare byte-wise; full ICU folding is
1812/// out of v7.17 scope.
1813/// New variants append at the end — older catalogs read missing
1814/// columns as `Binary`.
1815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1816pub enum Collation {
1817 Binary,
1818 CaseInsensitive,
1819}
1820
1821/// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1822/// integer type for a column whose storage `DataType` cannot express it.
1823/// MySQL `TINYINT` (i8, -128..127) collapses to `DataType::SmallInt` (i16)
1824/// and `MEDIUMINT` (24-bit) to `DataType::Int` (i32) — both wider than the
1825/// declared type, so a range check against `ty` alone accepts out-of-range
1826/// values (`INSERT 128 INTO TINYINT` is stored silently where MariaDB
1827/// strict raises ERROR 1264). This annotation records the lost width so the
1828/// write path (epic P2) can enforce the real bounds. `SMALLINT` / `INT` /
1829/// `BIGINT` need no marker — their storage `DataType` is already faithful.
1830/// Sparse: only TINYINT / MEDIUMINT columns carry it; persisted in the
1831/// FILE_VERSION 81+ appendix, older catalogs deserialise as None.
1832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1833pub enum MysqlIntWidth {
1834 /// MySQL `TINYINT` — signed -128..127, unsigned 0..255. Storage i16.
1835 Tiny,
1836 /// MySQL `SMALLINT UNSIGNED` — 0..65535. Storage widened to i32 (a
1837 /// signed SMALLINT keeps `DataType::SmallInt` and carries no marker).
1838 Small,
1839 /// MySQL `MEDIUMINT` — signed -8388608..8388607, unsigned 0..16777215.
1840 /// Storage i32.
1841 Medium,
1842 /// MySQL `INT UNSIGNED` — 0..4294967295. Storage widened to i64 (a
1843 /// signed INT keeps `DataType::Int` and carries no marker).
1844 Int,
1845 /// v7.39 (round 471, epic P4b) — MySQL `BIGINT UNSIGNED` —
1846 /// 0..18446744073709551615. i64 stops at 2^63-1, so the storage tag is
1847 /// widened to `Numeric` (i128-backed, scale 0), which already compares,
1848 /// orders, indexes and renders as an exact integer. A signed BIGINT
1849 /// keeps `DataType::BigInt` and carries no marker.
1850 Big,
1851}
1852
1853/// v7.39 (round 363, M4 P1) — MySQL's default accent- and
1854/// case-insensitive fold (`utf8mb4_uca1400_ai_ci`).
1855///
1856/// This is the primitive M4 rests on: a session on the MySQL dialect
1857/// compares, groups, sorts and de-duplicates text by its FOLDED form, so
1858/// `Foo` = `foo` = `FOO` and, because the default collation is accent-
1859/// insensitive too, `Bär` = `bar`. The later stages (read path, then the
1860/// UNIQUE / index write path) all route through here so they cannot fold
1861/// differently from one another.
1862///
1863/// The fold is more than case + strip-combining: MariaDB EXPANDS some
1864/// letters — `ß` → `ss`, `æ` → `ae`, `œ` → `oe` — which is why the result
1865/// is built as a `String` rather than mapped char-for-char. Every mapping
1866/// below was measured on MariaDB 11 (`'Bär'='bar'` is 1, `'straße'=
1867/// 'strasse'` is 1, `'a'='æ'` is 0, `'s'='ß'` is 0). Characters with no
1868/// entry keep their lower-cased self, so ASCII and unknown scripts pass
1869/// through unchanged.
1870#[must_use]
1871pub fn mysql_ci_fold(s: &str) -> String {
1872 let mut out = String::with_capacity(s.len());
1873 for ch in s.chars() {
1874 // Lower-case first (`À` → `à`, `Æ` → `æ`), then fold the base.
1875 for lc in ch.to_lowercase() {
1876 match fold_latin_base(lc) {
1877 Some(base) => out.push_str(base),
1878 None => out.push(lc),
1879 }
1880 }
1881 }
1882 out
1883}
1884
1885/// The fold used to COMPARE / GROUP / de-dup text on the MySQL dialect:
1886/// case- and accent-insensitive, and **trailing spaces significant**.
1887///
1888/// v7.38.17 — this used to strip trailing spaces first, and its comment
1889/// said why: "measured on MariaDB 11". MariaDB's default collation is
1890/// PAD SPACE, so that measurement was right about MariaDB. SPG
1891/// advertises `8.0.0-spg-v…` on the MySQL wire, and MySQL 8.0's default
1892/// `utf8mb4_0900_ai_ci` is **NO PAD**. The rule had been calibrated
1893/// against the engine we do not claim to be.
1894///
1895/// Measured today, MySQL 9.7.2 against MariaDB 12.3.2, each in its own
1896/// default collation, over rows `'alpha'` and `'alpha '`:
1897///
1898/// | | MySQL | MariaDB |
1899/// |---|---|---|
1900/// | `WHERE s = 'alpha'` | 1 | 1,2 |
1901/// | `s IN ('alpha','beta')` | 1,3,4 | 1,2,3,4 |
1902/// | `COUNT(DISTINCT s)` | 3 | 2 |
1903/// | `GROUP BY s` groups | 3 | 2 |
1904/// | `JOIN ON v.s = r.s` | 1/10, 2/20 | all four pairs |
1905///
1906/// SPG answered MariaDB's four and MySQL's join — the same question
1907/// decided differently by two paths, which is the shape v7.38.13,
1908/// v7.38.14 and v7.38.16 were each spent on.
1909///
1910/// `CHAR(n)` is a separate question and keeps its old answer: BOTH
1911/// engines ignore a CHAR's trailing spaces, because that is a property
1912/// of the TYPE rather than of the collation. Use
1913/// [`mysql_compare_fold_char`] for a `BpChar` cell.
1914///
1915/// Only literal spaces ever padded — a tab is significant either way —
1916/// and neither function is used by `LIKE`, whose pattern treats a
1917/// trailing space literally.
1918/// Whether a collation of this NAME orders by bytes.
1919///
1920/// v7.38.18 (S0) — pure string classification, and it lives here because
1921/// storage has to ask it: an index whose column collates by a locale
1922/// cannot key on the raw text, and the write path is here. The engine's
1923/// `collate::is_byte_wise` delegates to this one, for the reason the SQL
1924/// type spellings have one owner.
1925///
1926/// `C`, `POSIX`, MySQL's `binary` and every `_bin` family member. The
1927/// encoding suffix rides along: PG publishes `C.utf8` beside `C`.
1928pub fn collation_is_byte_wise(collation: &str) -> bool {
1929 let name = collation.trim();
1930 let base = name.split(['.', '@']).next().unwrap_or(name);
1931 base.eq_ignore_ascii_case("C")
1932 || base.eq_ignore_ascii_case("POSIX")
1933 || base.eq_ignore_ascii_case("binary")
1934 || base
1935 .rsplit_once('_')
1936 .is_some_and(|(_, tail)| tail.eq_ignore_ascii_case("bin"))
1937}
1938
1939/// v7.38.18 (S0/S2) — does an index on a column of this collation key
1940/// by an ICU SORT KEY rather than by the raw text?
1941///
1942/// True for a locale collation (`en_US.utf8`, `de_DE`), which orders by
1943/// rules a byte comparison cannot express.
1944///
1945/// False for byte-wise names, and false for MySQL's folding collations
1946/// (`utf8mb4_0900_ai_ci` and family). Those fold rather than collate,
1947/// and the engine has folded them since v7.37 — routing them here made
1948/// an indexed `s = 'ALPHA'` over the MySQL wire answer nothing where
1949/// MySQL 9.7.1 answers one row, because ICU at PG's strength does not
1950/// call `ALPHA` and `alpha` equal.
1951///
1952/// One owner for the same reason the byte-wise question has one: the
1953/// engine builds the PROBE and this crate builds the ENTRIES, and a
1954/// probe built in another space finds nothing — which reads exactly
1955/// like "no matching rows".
1956pub fn collation_uses_sort_key(collation: &str) -> bool {
1957 if collation_is_byte_wise(collation) {
1958 return false;
1959 }
1960 let name = collation.trim();
1961 let base = name.split(['.', '@']).next().unwrap_or(name);
1962 let lower = base.to_ascii_lowercase();
1963 !(lower.ends_with("_ci") || lower.ends_with("_cs"))
1964}
1965
1966pub fn mysql_compare_fold(s: &str) -> String {
1967 mysql_ci_fold(s)
1968}
1969
1970/// The comparison form of one text value under the MySQL default
1971/// collation, or `None` for a value that is not text.
1972///
1973/// v7.38.18 — one function, applied to each side SEPARATELY, because
1974/// the pair is not the unit. Several sites matched
1975/// `(Text, Text) | (BpChar, BpChar)` and folded a pair; a CHAR compared
1976/// against a VARCHAR or against a literal is neither shape, so it fell
1977/// through and was compared by bytes — with the CHAR still carrying its
1978/// padding. `CASE c WHEN 'ALPHA'` on a `CHAR(8)` holding `'alpha'`
1979/// answered ELSE where MySQL 9.7.2 answers the branch.
1980///
1981/// Folding per value also states the rule correctly: whether trailing
1982/// spaces count is a property of EACH side's own type, so a pair whose
1983/// sides differ has two answers rather than one.
1984pub fn mysql_fold_value(v: &Value<'_>) -> Option<String> {
1985 match v {
1986 Value::BpChar(s) => Some(mysql_compare_fold_char(s)),
1987 Value::Text(s) => Some(mysql_compare_fold(s)),
1988 _ => None,
1989 }
1990}
1991
1992/// [`mysql_compare_fold`] for a `CHAR(n)` cell, whose trailing spaces
1993/// are padding rather than data.
1994///
1995/// Measured on both engines: over `'alpha'` and `'alpha '` in a
1996/// `CHAR(8)`, `WHERE s = 'alpha'` returns both rows and
1997/// `COUNT(DISTINCT s)` is 2 (four rows folding to two values) — MySQL
1998/// 9.7.2 and MariaDB 12.3.2 agree, unlike the VARCHAR case above.
1999pub fn mysql_compare_fold_char(s: &str) -> String {
2000 mysql_ci_fold(s.trim_end_matches(' '))
2001}
2002
2003/// The base letter(s) a lower-cased Latin character folds to, or `None`
2004/// when it is already a base / has no fold. Expansions (`ß` → `ss`) are
2005/// why this returns a string.
2006fn fold_latin_base(c: char) -> Option<&'static str> {
2007 Some(match c {
2008 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
2009 'æ' => "ae",
2010 'ç' | 'ć' | 'č' | 'ĉ' | 'ċ' => "c",
2011 'ð' | 'ď' | 'đ' => "d",
2012 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => "e",
2013 'ĝ' | 'ğ' | 'ġ' | 'ģ' => "g",
2014 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' => "i",
2015 'ĵ' => "j",
2016 'ķ' => "k",
2017 'ł' | 'ĺ' | 'ļ' | 'ľ' => "l",
2018 'ñ' | 'ń' | 'ņ' | 'ň' => "n",
2019 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => "o",
2020 'œ' => "oe",
2021 'ŕ' | 'ŗ' | 'ř' => "r",
2022 'ś' | 'š' | 'ŝ' | 'ş' => "s",
2023 'ß' => "ss",
2024 'ţ' | 'ť' | 'ŧ' => "t",
2025 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => "u",
2026 'ý' | 'ÿ' => "y",
2027 'ź' | 'ž' | 'ż' => "z",
2028 _ => return None,
2029 })
2030}
2031
2032#[allow(clippy::derivable_impls)]
2033impl Default for Collation {
2034 fn default() -> Self {
2035 Self::Binary
2036 }
2037}
2038
2039impl Collation {
2040 /// Wire tag persisted in the FILE_VERSION 34+ catalog appendix.
2041 /// Stable: future variants append above the recognised range
2042 /// and unknown tags read back as `Binary` for forward-compat
2043 /// on rollback.
2044 pub const TAG_BINARY: u8 = 0;
2045 pub const TAG_CASE_INSENSITIVE: u8 = 1;
2046}
2047
2048/// v7.39 (RLS) — the command a policy applies to. `ALL` is the default and
2049/// covers every command; the others scope the policy to one statement kind.
2050/// Persisted as a single byte in the policy appendix (FILE_VERSION 59+).
2051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2052pub enum PolicyCmd {
2053 All,
2054 Select,
2055 Insert,
2056 Update,
2057 Delete,
2058}
2059
2060impl PolicyCmd {
2061 /// PG `pg_policy.polcmd` single-char encoding.
2062 #[must_use]
2063 pub const fn as_pg_char(self) -> char {
2064 match self {
2065 Self::All => '*',
2066 Self::Select => 'r',
2067 Self::Insert => 'a',
2068 Self::Update => 'w',
2069 Self::Delete => 'd',
2070 }
2071 }
2072
2073 /// PG `pg_policies.cmd` word form.
2074 #[must_use]
2075 pub const fn as_pg_word(self) -> &'static str {
2076 match self {
2077 Self::All => "ALL",
2078 Self::Select => "SELECT",
2079 Self::Insert => "INSERT",
2080 Self::Update => "UPDATE",
2081 Self::Delete => "DELETE",
2082 }
2083 }
2084
2085 #[must_use]
2086 pub const fn to_wire_byte(self) -> u8 {
2087 match self {
2088 Self::All => 0,
2089 Self::Select => 1,
2090 Self::Insert => 2,
2091 Self::Update => 3,
2092 Self::Delete => 4,
2093 }
2094 }
2095
2096 #[must_use]
2097 pub const fn from_wire_byte(b: u8) -> Option<Self> {
2098 match b {
2099 0 => Some(Self::All),
2100 1 => Some(Self::Select),
2101 2 => Some(Self::Insert),
2102 3 => Some(Self::Update),
2103 4 => Some(Self::Delete),
2104 _ => None,
2105 }
2106 }
2107}
2108
2109/// v7.39 (RLS) — one `CREATE POLICY` object, stored per table. The `using_expr`
2110/// / `with_check_expr` hold the qualifying expression's `Display` form
2111/// (re-parsed and evaluated per row at enforcement time, exactly like
2112/// `TableSchema.checks`); `None` means the clause was absent. `roles` empty =
2113/// PUBLIC. Persisted in the policy appendix (FILE_VERSION 59+).
2114#[derive(Debug, Clone, PartialEq)]
2115pub struct PolicyDef {
2116 pub name: String,
2117 pub cmd: PolicyCmd,
2118 /// `true` = PERMISSIVE (default, OR-combined), `false` = RESTRICTIVE
2119 /// (AND-combined).
2120 pub permissive: bool,
2121 pub roles: Vec<String>,
2122 pub using_expr: Option<String>,
2123 pub with_check_expr: Option<String>,
2124}
2125
2126#[derive(Debug, Clone, PartialEq)]
2127pub struct TableSchema {
2128 pub name: String,
2129 pub columns: Vec<ColumnSchema>,
2130 /// v6.7.2 — per-table hot-tier byte budget override. `None`
2131 /// falls through to the global `SPG_HOT_TIER_BYTES` setting;
2132 /// `Some(n)` overrides it for this specific table. Set via
2133 /// `ALTER TABLE t SET hot_tier_bytes = X`. Persisted in
2134 /// catalog FILE_VERSION 11+.
2135 pub hot_tier_bytes: Option<u64>,
2136 /// v7.6.1 — FOREIGN KEY constraints declared on this table.
2137 /// Engine maintains this in lock-step with `spg-sql`'s parser
2138 /// AST; the storage layer carries the on-disk shape so a
2139 /// catalog snapshot round-trips without external mapping.
2140 /// Persisted in catalog FILE_VERSION 13+. Older catalogs
2141 /// deserialise with an empty vec.
2142 pub foreign_keys: Vec<ForeignKeyConstraint>,
2143 /// v7.9.19 — composite UNIQUE / PRIMARY KEY constraints
2144 /// declared at the table level. Each entry's leading column
2145 /// has a BTree index (created via the constraint), and INSERT
2146 /// path enforces the full-tuple uniqueness via a scan keyed
2147 /// by the leading column. Persisted in catalog FILE_VERSION
2148 /// 15+. Older catalogs (≤ 14) deserialise with an empty vec.
2149 pub uniqueness_constraints: Vec<UniquenessConstraint>,
2150 /// v7.39 (round 210) — `EXCLUDE` constraints declared at the table level.
2151 /// Enforced on INSERT/UPDATE by a full live-row scan re-checking each
2152 /// element's operator (no equality index can answer overlap). Persisted
2153 /// in catalog FILE_VERSION 72+; older catalogs deserialise with an empty
2154 /// vec.
2155 pub exclusion_constraints: Vec<ExclusionConstraint>,
2156 /// v7.13.0 — `CHECK (<expr>)` predicates declared on this
2157 /// table. Both column-level inline `CHECK (…)` and
2158 /// table-level `CHECK (…)` fold into this list. Each entry
2159 /// is the AST Expr's `Display` form, re-parsed on every
2160 /// INSERT/UPDATE and evaluated against the candidate row.
2161 /// A false / NULL result rejects the mutation (PG semantics).
2162 /// Persisted in catalog FILE_VERSION 23+. Older catalogs
2163 /// deserialise with an empty vec. v7.39 (read01 round 48) — each entry
2164 /// now carries the user's constraint name too (FILE_VERSION 60+).
2165 pub checks: Vec<CheckConstraint>,
2166 /// v7.37.6-B — declarative partition role(sentori Epic 2 P0).
2167 /// `None` = 普通表(后向兼容,< v49 catalog 默认 None)。
2168 /// `Some(Parent { … })` = `CREATE TABLE p (...) PARTITION BY RANGE (key_col)` 父表 —
2169 /// 父表自己 `rows` 永远空,INSERT 在引擎层路由到命中的 child。
2170 /// `Some(Range { … })` = `CREATE TABLE c PARTITION OF p FOR VALUES FROM (a) TO (b)` 范围子表。
2171 /// `Some(Default { … })` = `CREATE TABLE c PARTITION OF p DEFAULT` 兜底子表。
2172 /// 持久化于 FILE_VERSION 49+。
2173 pub partition_role: Option<PartitionRole>,
2174 /// v7.39 (RLS) — `CREATE POLICY` objects on this table, independent of the
2175 /// `row_security` flag (PG stores policies even on non-RLS tables; they
2176 /// only take effect once RLS is enabled). Persisted in the policy appendix
2177 /// (FILE_VERSION 59+). Older catalogs deserialise with an empty vec.
2178 pub policies: Vec<PolicyDef>,
2179 /// v7.39 (RLS) — `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
2180 /// (PG `pg_class.relrowsecurity`). Fresh table = `false`.
2181 pub row_security: bool,
2182 /// v7.39 (RLS) — `ALTER TABLE … FORCE ROW LEVEL SECURITY`
2183 /// (PG `pg_class.relforcerowsecurity`); subjects the table owner to RLS
2184 /// too. Fresh table = `false`.
2185 pub force_row_security: bool,
2186 /// v7.39 (read01 round 57, ACL) — the role that owns this table: whoever
2187 /// ran CREATE TABLE (PG `pg_class.relowner`). The owner holds every
2188 /// privilege implicitly and is the only role that may ALTER / DROP it.
2189 /// `None` = an image written before FILE_VERSION 64, which predates roles
2190 /// entirely; those tables read back as owned by the login role.
2191 pub owner: Option<String>,
2192 /// v7.39 (read01 round 57, ACL) — explicit GRANTs on this table
2193 /// (PG `pg_class.relacl`). EMPTY means "never granted": PG leaves relacl
2194 /// NULL while only the owner's implicit privileges apply, and materialises
2195 /// the whole list — owner's default entry included — on the first GRANT.
2196 /// Once materialised it stays, even after every grant is revoked.
2197 pub acl: Vec<AclItem>,
2198}
2199
2200/// v7.39 (read01 round 57) — one PG `aclitem`: what `grantee` may do to a
2201/// table, and who granted it. Renders as `grantee=privs/grantor`, with an
2202/// EMPTY grantee meaning PUBLIC (`=r/owner`).
2203#[derive(Debug, Clone, PartialEq, Eq)]
2204pub struct AclItem {
2205 /// The role the privileges are held by. Empty string = PUBLIC.
2206 pub grantee: String,
2207 /// Bitmask over `priv_bits`: which privileges are held.
2208 pub privs: u16,
2209 /// Bitmask over `priv_bits`: which of them carry WITH GRANT OPTION
2210 /// (PG renders those with a trailing `*` — `r*`).
2211 pub grantable: u16,
2212 /// The role that ran the GRANT.
2213 pub grantor: String,
2214}
2215
2216/// v7.39 (read01 round 57) — the table-privilege bits, in PG's `aclitem`
2217/// rendering order (`arwdDxtm`). The order matters: `relacl` output is
2218/// byte-compared against PG.
2219pub mod priv_bits {
2220 pub const INSERT: u16 = 1 << 0; // a
2221 pub const SELECT: u16 = 1 << 1; // r
2222 pub const UPDATE: u16 = 1 << 2; // w
2223 pub const DELETE: u16 = 1 << 3; // d
2224 pub const TRUNCATE: u16 = 1 << 4; // D
2225 pub const REFERENCES: u16 = 1 << 5; // x
2226 pub const TRIGGER: u16 = 1 << 6; // t
2227 pub const MAINTAIN: u16 = 1 << 7; // m
2228 /// v7.39 (read01 round 60) — the non-table privileges. They share the
2229 /// bitmask because an aclitem is an aclitem whatever it hangs off; which
2230 /// bits are MEANINGFUL depends on the object (a sequence has r / w / U, a
2231 /// schema has U / C, a database has C / c / T).
2232 pub const USAGE: u16 = 1 << 8; // U
2233 pub const CREATE: u16 = 1 << 9; // C
2234 pub const CONNECT: u16 = 1 << 10; // c
2235 pub const TEMPORARY: u16 = 1 << 11; // T
2236 pub const EXECUTE: u16 = 1 << 12; // X
2237 /// Every TABLE privilege — what `GRANT ALL ON <table>` grants and what a
2238 /// table's owner holds.
2239 pub const ALL: u16 =
2240 INSERT | SELECT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN;
2241 /// `GRANT ALL ON SEQUENCE` — PG renders a sequence owner's default as `rwU`.
2242 pub const ALL_SEQUENCE: u16 = SELECT | UPDATE | USAGE;
2243 /// `GRANT ALL ON SCHEMA` — `UC`.
2244 pub const ALL_SCHEMA: u16 = USAGE | CREATE;
2245 /// `GRANT ALL ON DATABASE` — `CTc`.
2246 pub const ALL_DATABASE: u16 = CREATE | CONNECT | TEMPORARY;
2247 /// `GRANT ALL ON FUNCTION` — just `X`.
2248 pub const ALL_FUNCTION: u16 = EXECUTE;
2249}
2250
2251/// v7.37.6-B — partition 三态(parent / range child / default child)。
2252#[derive(Debug, Clone, PartialEq, Eq)]
2253pub enum PartitionRole {
2254 Parent {
2255 kind: PartitionKind,
2256 /// 父表 columns 中 key 列的下标(单列 v7.37.6-B,
2257 /// `Vec` 为将来扩多列预留)。
2258 key_column_positions: Vec<usize>,
2259 /// `CREATE INDEX ON parent (…)` 的 Display-form 源串。
2260 /// child 创建时再 parse + 在 child 上 execute,这样 future
2261 /// child 也自动继承父表索引。fan-out 实施在引擎层。
2262 index_template_sources: Vec<String>,
2263 },
2264 Range {
2265 parent_name: String,
2266 /// 半开区间下界(`>=`,SQL `FROM (lower)`).
2267 lower: PartitionBound,
2268 /// 半开区间上界(`<`,SQL `TO (upper)`).
2269 upper: PartitionBound,
2270 },
2271 /// v7.37.16 (16.1) — LIST child:行属于本 child iff key ∈ values。
2272 /// `values` 在 child 创建时从 SQL `FOR VALUES IN (lit, …)` 求值;
2273 /// 跟 PG 一样,显式 NULL ∈ values 由 caller 单独处理(不在
2274 /// PartitionBound 内表达 NULL)。
2275 List {
2276 parent_name: String,
2277 values: Vec<PartitionBound>,
2278 },
2279 /// v7.39 (round 645) — PG 表继承的 CHILD:`CREATE TABLE c (…)
2280 /// INHERITS (p1, p2)`。跟分区 child 的三个本质区别(实测 PG18):
2281 /// * 父表**自己有行**(分区父表永远空),所以父表的联合体要含自身;
2282 /// * `INSERT INTO 父表` **不路由**到 child(分区会路由);
2283 /// * `DROP TABLE 父表` 不带 CASCADE **报错**(分区父表连子表一起删)。
2284 /// 多父继承合法,故 `parent_names` 是 Vec;`pg_inherits.inhseqno`
2285 /// 正是父表在这个列表里的位置(1-based)。
2286 Inherits {
2287 parent_names: Vec<String>,
2288 },
2289 /// v7.37.16 (16.2) — HASH child:行属于本 child iff
2290 /// `pg_compatible_hash(key) mod modulus == remainder`。
2291 /// PG 强制 `0 ≤ remainder < modulus`;parser/DDL 层先 gate。
2292 Hash {
2293 parent_name: String,
2294 modulus: u32,
2295 remainder: u32,
2296 },
2297 Default {
2298 parent_name: String,
2299 },
2300}
2301
2302/// v7.37.6-B — 分区策略。
2303///
2304/// - `Range`:半开区间 `[lower, upper)`(v7.37.6-B 初始)
2305/// - `List` (v7.37.16):枚举集合 — 行属于 partition iff key ∈ children list
2306/// - `Hash` (v7.37.16):`hash(key) mod modulus == remainder`
2307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2308pub enum PartitionKind {
2309 Range,
2310 List,
2311 Hash,
2312}
2313
2314/// v7.37.6-B — partition 边界 literal。
2315///
2316/// v7.37.6-B 仅 `TimestampTz`(i64 microseconds since epoch);
2317/// v7.37.16 (16.6) 加全 PG 内建可比类型,匹配 `Value` 的对应 variant
2318/// 以避免 LIST membership 比较时的类型转换。
2319///
2320/// `MinValue` / `MaxValue` 对应 SQL `MINVALUE` / `MAXVALUE`,仅
2321/// Range 策略有意义(LIST 无 minvalue/maxvalue 概念,HASH 不
2322/// 使用 PartitionBound)。
2323#[derive(Debug, Clone, PartialEq, Eq)]
2324pub enum PartitionBound {
2325 MinValue,
2326 MaxValue,
2327 TimestampTz(i64),
2328 /// v7.37.16 (16.6) — BIGINT partition key.
2329 BigInt(i64),
2330 /// v7.37.16 (16.6) — INTEGER partition key (also covers
2331 /// `SERIAL` since SPG decomposes it to INTEGER + sequence).
2332 Int(i32),
2333 /// v7.37.16 (16.6) — SMALLINT partition key.
2334 SmallInt(i16),
2335 /// v7.37.16 (16.6) — DATE partition key. Stored as days
2336 /// since the Unix epoch (matches `Value::Date`).
2337 Date(i32),
2338 /// v7.37.16 (16.6) — TEXT / VARCHAR partition key.
2339 Text(alloc::string::String),
2340}
2341
2342impl PartitionBound {
2343 /// v7.37.16 (16.6) — true iff this bound's underlying value
2344 /// equals `other`'s. Used for LIST partition membership
2345 /// checks. Returns false for `MinValue` / `MaxValue`
2346 /// (sentinels — never literal equality).
2347 #[must_use]
2348 pub fn equals_value(&self, other: &Value<'_>) -> bool {
2349 match (self, other) {
2350 (PartitionBound::TimestampTz(a), Value::Timestamp(b)) => a == b,
2351 (PartitionBound::BigInt(a), Value::BigInt(b)) => a == b,
2352 (PartitionBound::Int(a), Value::Int(b)) => a == b,
2353 (PartitionBound::SmallInt(a), Value::SmallInt(b)) => a == b,
2354 (PartitionBound::Date(a), Value::Date(b)) => a == b,
2355 (PartitionBound::Text(a), Value::Text(b)) => a.as_str() == b.as_ref(),
2356 _ => false,
2357 }
2358 }
2359}
2360
2361/// v7.9.19 — composite UNIQUE / PRIMARY KEY constraint persisted
2362/// on the table schema. The leading column always has a BTree
2363/// index (created at CREATE TABLE time); INSERT enforcement
2364/// scans that index for collisions on the full column tuple.
2365/// v7.39 (read01 round 48) — a `CHECK` constraint: the SQL name the user
2366/// gave it (via `ADD CONSTRAINT <name> CHECK (...)` or the inline
2367/// `CONSTRAINT <name> CHECK (...)` form) plus the predicate source. `None`
2368/// name = unnamed, in which case `pg_constraint` synthesises PG's
2369/// `<table>_<col>_check` form. Names are persisted in the constraint-name
2370/// appendix (FILE_VERSION 60+); older catalogs deserialise with `None`.
2371#[derive(Debug, Clone, PartialEq, Eq)]
2372pub struct CheckConstraint {
2373 pub name: Option<String>,
2374 /// The AST Expr's `Display` form, re-parsed on every INSERT/UPDATE.
2375 pub expr: String,
2376 /// v7.39 (round 652) — `false` for a constraint added `NOT VALID`: the
2377 /// rows already in the table were never scanned against it, and
2378 /// `pg_constraint.convalidated` says so. It does NOT weaken the check on
2379 /// new rows — INSERT and UPDATE enforce it either way, as in PG.
2380 /// `VALIDATE CONSTRAINT` does the deferred scan and flips it. Persisted
2381 /// by the FILE_VERSION 87 appendix; older catalogs deserialise as `true`,
2382 /// which is what every constraint they could hold actually was.
2383 pub validated: bool,
2384}
2385
2386#[derive(Debug, Clone, PartialEq, Eq)]
2387pub struct UniquenessConstraint {
2388 /// `true` when this constraint was declared as `PRIMARY KEY`
2389 /// (vs `UNIQUE`). Semantically PK implies NOT NULL on all
2390 /// referenced columns; the engine enforces that at CREATE
2391 /// TABLE time.
2392 pub is_primary_key: bool,
2393 /// Column positions on the parent table. ≥ 1 element. For
2394 /// single-column UNIQUE this is exactly one position; the
2395 /// BTree index alone enforces it.
2396 pub columns: Vec<usize>,
2397 /// v7.13.0 — `UNIQUE NULLS NOT DISTINCT` modifier
2398 /// (mailrs round-5 G10; PG 15+ surface). When `true`, two
2399 /// rows whose constrained columns are all NULL collide on
2400 /// the constraint. Default (`false`) is the SQL-standard
2401 /// `NULLS DISTINCT` behaviour where any NULL passes.
2402 /// Persisted in catalog FILE_VERSION 23+.
2403 pub nulls_not_distinct: bool,
2404 /// v7.39 (read01 round 48) — the constraint's SQL name when the user
2405 /// supplied one (`ADD CONSTRAINT <name> PRIMARY KEY/UNIQUE (...)`, or
2406 /// the inline `CONSTRAINT <name>` form). `None` = unnamed, in which
2407 /// case `pg_constraint` synthesises PG's `<table>_pkey` /
2408 /// `<table>_<col>_key` form. DROP CONSTRAINT resolves the stored name
2409 /// first and falls back to the synthesised one, so catalogs written
2410 /// before this field (< FILE_VERSION 60) keep working unchanged.
2411 pub name: Option<String>,
2412 /// v7.39 (round 711) — `[NOT] DEFERRABLE`. Round 621 taught the parser
2413 /// to CONSUME the clause on PK/UNIQUE (the FK path had stored it since
2414 /// round 288); this is the storing half. Persisted in the v89 timing
2415 /// appendix.
2416 pub deferrable: bool,
2417 /// `INITIALLY DEFERRED`: the check belongs to COMMIT, not the
2418 /// statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2419 pub initially_deferred: bool,
2420}
2421
2422/// v7.39 (round 210) — an `EXCLUDE` constraint. Forbids two distinct live
2423/// rows from satisfying, for EVERY element, `new.col <op> existing.col`
2424/// (e.g. `EXCLUDE USING gist (during WITH &&)` = no two `during` ranges
2425/// overlap). Unlike a uniqueness constraint the operator is not equality,
2426/// so enforcement is a full live-row scan re-checking the operator (a real
2427/// GiST index that answers overlap in O(log n) is a later perf phase). A
2428/// NULL in any element column exempts the row (matching PG / UNIQUE NULL
2429/// semantics). Persisted in catalog FILE_VERSION 72+.
2430#[derive(Debug, Clone, PartialEq, Eq)]
2431pub struct ExclusionConstraint {
2432 /// The constraint's SQL name. PG auto-names an unnamed EXCLUDE
2433 /// `<table>_<leading-col>_excl`; the engine synthesises that at CREATE
2434 /// TABLE time so this is always populated.
2435 pub name: String,
2436 /// Access method spelled after `USING` (`gist`, `spgist`, …), lower-cased.
2437 /// `None` = no `USING` clause. Purely cosmetic for enforcement; it round-
2438 /// trips into `pg_get_constraintdef`.
2439 pub method: Option<String>,
2440 /// One `(column-position, operator-spelling)` pair per element, in
2441 /// declaration order. The operator spelling is the wire token (`&&`,
2442 /// `=`, `@>`, `<@`, `&<`, `&>`) evaluated against each existing row.
2443 pub elements: Vec<(usize, String)>,
2444}
2445
2446/// v7.6.1 — Storage-layer mirror of `spg_sql::ast::ForeignKeyConstraint`.
2447/// The engine's CREATE TABLE path translates between the two; keeping
2448/// them separate preserves the no-deps boundary between
2449/// `spg-storage` and `spg-sql`.
2450#[derive(Debug, Clone, PartialEq, Eq)]
2451pub struct ForeignKeyConstraint {
2452 /// Optional user-supplied constraint name (`CONSTRAINT <name>`
2453 /// prefix). Used by `ALTER TABLE DROP CONSTRAINT <name>` in
2454 /// v7.6.8; ignored by enforcement.
2455 pub name: Option<String>,
2456 /// Positions of local columns in this table's column list.
2457 /// Same arity as `parent_columns`.
2458 pub local_columns: Vec<usize>,
2459 /// Referenced parent table name.
2460 pub parent_table: String,
2461 /// Positions of parent columns in the parent's column list.
2462 /// Engine resolves these at CREATE TABLE time (after the parent
2463 /// schema is known) so enforcement paths can skip the name
2464 /// lookup on every row.
2465 pub parent_columns: Vec<usize>,
2466 /// Referential action when a parent row is deleted.
2467 pub on_delete: FkAction,
2468 /// Referential action when a parent row's referenced columns
2469 /// are updated.
2470 pub on_update: FkAction,
2471 /// v7.38 (read01, T29) — `MATCH SIMPLE | FULL`. Defaults to `Simple`.
2472 pub match_type: MatchType,
2473 /// v7.39 (round 288) — `[NOT] DEFERRABLE`.
2474 pub deferrable: bool,
2475 /// `INITIALLY DEFERRED`: the check runs at COMMIT rather than at
2476 /// the statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2477 pub initially_deferred: bool,
2478}
2479
2480/// v7.38 (read01, T29) — FK MATCH type. Mirrors `spg_sql::ast::MatchType`.
2481#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2482pub enum MatchType {
2483 #[default]
2484 Simple,
2485 Full,
2486}
2487
2488impl MatchType {
2489 /// On-disk tag byte (catalog appendix, `FILE_VERSION` 55+).
2490 pub const fn tag(self) -> u8 {
2491 match self {
2492 Self::Simple => 0,
2493 Self::Full => 1,
2494 }
2495 }
2496 pub const fn from_tag(b: u8) -> Option<Self> {
2497 Some(match b {
2498 0 => Self::Simple,
2499 1 => Self::Full,
2500 _ => return None,
2501 })
2502 }
2503}
2504
2505/// v7.6.1 — referential action tag. Mirrors `spg_sql::ast::FkAction`.
2506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2507pub enum FkAction {
2508 Restrict,
2509 Cascade,
2510 SetNull,
2511 SetDefault,
2512 NoAction,
2513}
2514
2515impl FkAction {
2516 /// On-disk tag byte (v13 catalog appendix).
2517 pub const fn tag(self) -> u8 {
2518 match self {
2519 Self::Restrict => 0,
2520 Self::Cascade => 1,
2521 Self::SetNull => 2,
2522 Self::SetDefault => 3,
2523 Self::NoAction => 4,
2524 }
2525 }
2526 pub const fn from_tag(b: u8) -> Option<Self> {
2527 Some(match b {
2528 0 => Self::Restrict,
2529 1 => Self::Cascade,
2530 2 => Self::SetNull,
2531 3 => Self::SetDefault,
2532 4 => Self::NoAction,
2533 _ => return None,
2534 })
2535 }
2536}
2537
2538impl TableSchema {
2539 pub fn column_position(&self, name: &str) -> Option<usize> {
2540 self.columns.iter().position(|c| c.name == name)
2541 }
2542}
2543
2544/// Key type accepted by secondary indices. Float / NULL / Vector values
2545/// can't participate in a B-tree index — `f64` is only `PartialOrd`, NULL
2546/// has SQL-three-valued semantics, and Vector belongs to the (future) HNSW
2547/// path. Index lookups on those columns fall back to full scan.
2548#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2549pub enum IndexKey {
2550 Int(i64),
2551 Text(String),
2552 Bool(bool),
2553 /// v7.17.0 — `Value::Uuid` index key. Comparison is byte-wise
2554 /// (RFC 4122 byte order) so PRIMARY KEY UUID lookups land on
2555 /// the same fast-path as Int / Text.
2556 Uuid([u8; 16]),
2557 /// r1039 — `Value::Bytes` (bytea). PG orders bytea by plain byte
2558 /// comparison, shorter-prefix first (`'' < \x00 < \x0000 < \x01ff <
2559 /// \xff`, measured on 18.4), which is exactly `Vec<u8>`'s `Ord`.
2560 Bytes(Vec<u8>),
2561 /// r1039 — exact decimal, in the canonical form described on
2562 /// [`NumericKey`].
2563 ///
2564 /// r1040 — BOXED, and the box is load-bearing for every OTHER index.
2565 /// A `NumericKey` is 48 bytes against `Text(String)`'s 24, so inline
2566 /// it set the size of the whole enum and every B-tree node in every
2567 /// index grew with it: 32 bytes per key to 48, align 8 to 16.
2568 /// Measured through the release sweep, `SELECT pad FROM t ORDER BY
2569 /// id` over 400,000 rows — a walk of the primary key's index — went
2570 /// 39.4-40.6 ms to 42.3-44.1, in both leg orders. The indirection is
2571 /// charged to numeric keys, which are new, instead of to every index
2572 /// that existed already.
2573 Numeric(alloc::boxed::Box<NumericKey>),
2574 /// v7.38.1 (L12) — a NULL component INSIDE a composite key, and
2575 /// nothing else. `IndexKey::from_value(Value::Null)` still returns
2576 /// `None`, so single-column B-trees never hold one, and no probe
2577 /// path ever BUILDS one (`col = NULL` is not a match in SQL) — the
2578 /// variant is only reachable through a composite key's component
2579 /// list, where it exists so that a row like `(2, 3, NULL)` stays
2580 /// findable by a PREFIX probe on `(w, d)`. Declared last: slice
2581 /// `Ord` then sorts NULL components after every value, PG's
2582 /// NULLS LAST.
2583 Null,
2584}
2585
2586/// r1039 — an exact-decimal index key, canonical so that representation
2587/// equality IS value equality.
2588///
2589/// That property is the whole reason this is a struct rather than the
2590/// `(scaled, scale)` pair the value carries. `1.5` and `1.50` are the
2591/// same NUMERIC (PG18.4: `1.5::numeric = 1.50::numeric` is true) and
2592/// arrive here as `(15, 1)` and `(150, 2)`. A B-tree keyed on the raw
2593/// pair would file them apart, so `WHERE n = 1.5` would miss a row stored
2594/// as `1.50` — an index changing the answer, which is the one thing an
2595/// index may never do. `BigNumeric::cmp` carries the same warning and
2596/// declines to implement `Ord` for exactly this reason; a KEY cannot
2597/// decline, so it normalizes instead.
2598///
2599/// Canonical form: significant decimal digits with no leading and no
2600/// trailing zeros, most significant first, plus the decimal exponent of
2601/// the leading digit. Zero is the empty digit vector with `neg == false`
2602/// and `exp == 0`, so there is no `-0`.
2603///
2604/// Ordering is PG's, measured: `-Infinity < -1 < 0 < 1 < Infinity < NaN`,
2605/// and `NaN = NaN`.
2606#[derive(Debug, Clone, PartialEq, Eq)]
2607pub struct NumericKey {
2608 /// 0 = -Infinity, 1 = finite, 2 = +Infinity, 3 = NaN. Ordering the
2609 /// classes by this byte is what puts NaN on top, where PG keeps it.
2610 class: u8,
2611 /// Finite only, and never set for zero.
2612 neg: bool,
2613 /// Decimal exponent of the leading significant digit; 0 for zero.
2614 exp: i32,
2615 /// r1040 — the first [`HEAD_DIGITS`] significant digits, LEFT-ALIGNED
2616 /// (multiplied up so the leading digit always sits at 10^36). That
2617 /// alignment is what makes an integer comparison of two heads the same
2618 /// answer as a digit-by-digit one: `12` and `1` become 1.2e36 and
2619 /// 1.0e36, which order the way the digit strings do, where the bare
2620 /// integers 12 and 1 would not.
2621 ///
2622 /// Zero for the value zero and for every special.
2623 ///
2624 /// This started as a `Vec<u8>` of digits, which is correct and cost
2625 /// an allocation per key and a slice comparison per sort comparison.
2626 /// `ORDER BY <numeric>` builds one key per row and compares n log n
2627 /// times: 200,000 rows measured 65.4 ms against 39.6 for the f64
2628 /// projection that had been returning rows in the wrong order.
2629 head: u128,
2630 /// Significant digits past the 37th, one per byte, no trailing zeros.
2631 /// Empty for everything an `i128` mantissa can hold with room to
2632 /// spare — and an empty `Vec` does not allocate, which is the point.
2633 tail: Vec<u8>,
2634}
2635
2636/// Significant digits carried in [`NumericKey::head`]. 37 is the most
2637/// that can be left-aligned inside a `u128`: the largest such value is
2638/// 9.99…e36, and `u128::MAX` is 3.4e38.
2639const HEAD_DIGITS: u32 = 37;
2640/// `10^36` — where a left-aligned leading digit sits.
2641const HEAD_SCALE: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
2642
2643/// The `class` byte of [`NumericKey`], in PG's order.
2644const NUM_CLASS_NEG_INF: u8 = 0;
2645const NUM_CLASS_FINITE: u8 = 1;
2646const NUM_CLASS_POS_INF: u8 = 2;
2647const NUM_CLASS_NAN: u8 = 3;
2648
2649impl NumericKey {
2650 /// The key for a `Value::Numeric`'s three fields.
2651 ///
2652 /// Public because the ORDER BY key wants the same canonical form the
2653 /// index key uses: two sort keys that disagree about which of two
2654 /// NUMERICs is larger is the same class of defect as an index that
2655 /// disagrees with a scan, and one definition is how they stay honest.
2656 #[must_use]
2657 pub fn from_numeric(scaled: i128, scale: u16, kind: NumericKind) -> Self {
2658 match kind {
2659 NumericKind::Finite => {
2660 let mut buf = [0u8; 40];
2661 let n = digits_of_u128(scaled.unsigned_abs(), &mut buf);
2662 Self::finite(scaled < 0, &buf[..n], i32::from(scale))
2663 }
2664 NumericKind::NaN => Self::special(NUM_CLASS_NAN),
2665 NumericKind::PosInf => Self::special(NUM_CLASS_POS_INF),
2666 NumericKind::NegInf => Self::special(NUM_CLASS_NEG_INF),
2667 }
2668 }
2669
2670 /// The key for an exact integer — no scale, so no rounding.
2671 #[must_use]
2672 pub fn from_i128(n: i128) -> Self {
2673 let mut buf = [0u8; 40];
2674 let len = digits_of_u128(n.unsigned_abs(), &mut buf);
2675 Self::finite(n < 0, &buf[..len], 0)
2676 }
2677
2678 /// The key for a mantissa that overflowed `i128`. The two
2679 /// representations of one value land on one key.
2680 #[must_use]
2681 pub fn from_big(b: &crate::bignum::BigNumeric) -> Self {
2682 let (neg, limbs, scale) = b.parts();
2683 Self::finite(neg, &digits_of_limbs(limbs), i32::from(scale))
2684 }
2685
2686 /// The `f64` this key means, for the one comparison PG defines that
2687 /// way: `numeric` against `float8` demotes the numeric.
2688 ///
2689 /// Lossy by construction — that is the point, and it is why nothing
2690 /// else uses it.
2691 #[must_use]
2692 #[allow(clippy::cast_precision_loss)]
2693 pub fn to_f64(&self) -> f64 {
2694 match self.class {
2695 NUM_CLASS_NAN => return f64::NAN,
2696 NUM_CLASS_POS_INF => return f64::INFINITY,
2697 NUM_CLASS_NEG_INF => return f64::NEG_INFINITY,
2698 _ => {}
2699 }
2700 if self.head == 0 {
2701 return 0.0;
2702 }
2703 // `head` is `d.ddd… × 10^36`; the value is that leading digit and
2704 // its followers at `exp`. The tail is below f64's resolution by
2705 // construction (it starts at the 38th significant digit).
2706 let mantissa = self.head as f64 / HEAD_SCALE as f64;
2707 let out = mantissa * pow10_f64(self.exp);
2708 if self.neg { -out } else { out }
2709 }
2710
2711 /// The significant decimal digits, most significant first — the form
2712 /// the catalog codec writes, and the one `from_parts` reads back.
2713 #[must_use]
2714 pub fn digits(&self) -> Vec<u8> {
2715 let mut out = Vec::new();
2716 if self.head != 0 {
2717 let mut h = self.head;
2718 for _ in 0..HEAD_DIGITS {
2719 let d = u8::try_from(h / HEAD_SCALE).unwrap_or(0);
2720 out.push(d);
2721 h = (h % HEAD_SCALE) * 10;
2722 }
2723 while out.last() == Some(&0) {
2724 out.pop();
2725 }
2726 }
2727 out.extend_from_slice(&self.tail);
2728 out
2729 }
2730
2731 /// The wire parts, for the catalog codec.
2732 #[must_use]
2733 pub fn parts(&self) -> (u8, bool, i32) {
2734 (self.class, self.neg, self.exp)
2735 }
2736
2737 /// Rebuild from the wire parts. Returns `None` on parts that are not
2738 /// canonical, so a corrupt catalog cannot smuggle in a key whose `Eq`
2739 /// and `Ord` disagree.
2740 #[must_use]
2741 pub fn from_parts(class: u8, neg: bool, exp: i32, digits: &[u8]) -> Option<Self> {
2742 if class > NUM_CLASS_NAN || digits.iter().any(|d| *d > 9) {
2743 return None;
2744 }
2745 if class != NUM_CLASS_FINITE && (neg || exp != 0 || !digits.is_empty()) {
2746 return None;
2747 }
2748 if digits.is_empty() {
2749 if neg || exp != 0 {
2750 return None;
2751 }
2752 return Some(Self::special(class));
2753 }
2754 if digits[0] == 0 || digits[digits.len() - 1] == 0 {
2755 return None;
2756 }
2757 Some(Self {
2758 class,
2759 neg,
2760 exp,
2761 head: head_of(digits),
2762 tail: digits.iter().skip(HEAD_DIGITS as usize).copied().collect(),
2763 })
2764 }
2765
2766 /// Canonicalize `(-1)^neg · <digits as an integer> · 10^-scale`.
2767 ///
2768 /// `digits` is most-significant-first and may carry leading and
2769 /// trailing zeros; both are stripped, which is what makes `1.5` and
2770 /// `1.50` land on the same key.
2771 fn finite(neg: bool, digits: &[u8], scale: i32) -> Self {
2772 let lead = digits.iter().position(|d| *d != 0).unwrap_or(digits.len());
2773 let digits = &digits[lead..];
2774 if digits.is_empty() {
2775 return Self::special(NUM_CLASS_FINITE);
2776 }
2777 // The leading digit's exponent, taken BEFORE trailing zeros go:
2778 // dropping low-order digits does not move the leading one.
2779 let exp = i32::try_from(digits.len()).unwrap_or(i32::MAX) - 1 - scale;
2780 let mut end = digits.len();
2781 while end > 0 && digits[end - 1] == 0 {
2782 end -= 1;
2783 }
2784 let digits = &digits[..end];
2785 Self {
2786 class: NUM_CLASS_FINITE,
2787 neg,
2788 exp,
2789 head: head_of(digits),
2790 tail: digits.iter().skip(HEAD_DIGITS as usize).copied().collect(),
2791 }
2792 }
2793
2794 fn special(class: u8) -> Self {
2795 Self {
2796 class,
2797 neg: false,
2798 exp: 0,
2799 head: 0,
2800 tail: Vec::new(),
2801 }
2802 }
2803}
2804
2805/// The first [`HEAD_DIGITS`] of `digits`, left-aligned so the leading one
2806/// sits at `10^36`.
2807fn head_of(digits: &[u8]) -> u128 {
2808 let mut head: u128 = 0;
2809 let take = (HEAD_DIGITS as usize).min(digits.len());
2810 for d in &digits[..take] {
2811 head = head * 10 + u128::from(*d);
2812 }
2813 for _ in take..HEAD_DIGITS as usize {
2814 head *= 10;
2815 }
2816 head
2817}
2818
2819/// Decimal digits of `mag` into `buf`, most significant first; returns how
2820/// many were written. Zero writes none.
2821///
2822/// r1040 — split at `u64` on purpose. A `u128` divide is a called routine,
2823/// not an instruction, and this loop runs once per digit per key.
2824fn digits_of_u128(mag: u128, buf: &mut [u8; 40]) -> usize {
2825 if mag == 0 {
2826 return 0;
2827 }
2828 let mut rev = [0u8; 40];
2829 let mut n = 0usize;
2830 let mut big = mag;
2831 // Peel nineteen digits at a time — the most a `u64` holds — so the
2832 // wide divide runs at most twice.
2833 while big > u128::from(u64::MAX) {
2834 let mut chunk = u64::try_from(big % 10_000_000_000_000_000_000_u128).unwrap_or(0);
2835 big /= 10_000_000_000_000_000_000_u128;
2836 for _ in 0..19 {
2837 rev[n] = u8::try_from(chunk % 10).unwrap_or(0);
2838 chunk /= 10;
2839 n += 1;
2840 }
2841 }
2842 let mut small = u64::try_from(big).unwrap_or(0);
2843 while small > 0 {
2844 rev[n] = u8::try_from(small % 10).unwrap_or(0);
2845 small /= 10;
2846 n += 1;
2847 }
2848 for i in 0..n {
2849 buf[i] = rev[n - 1 - i];
2850 }
2851 n
2852}
2853
2854/// Decimal digits of a base-10^9 little-endian limb vector, most
2855/// significant first. Every limb but the leading one is padded to its
2856/// full nine digits — that padding is the whole point, since a limb of 5
2857/// in the middle of a number means `000000005`.
2858fn digits_of_limbs(limbs: &[u32]) -> Vec<u8> {
2859 let mut out = Vec::new();
2860 let mut buf = [0u8; 40];
2861 for (i, limb) in limbs.iter().enumerate().rev() {
2862 let n = digits_of_u128(u128::from(*limb), &mut buf);
2863 if i + 1 == limbs.len() {
2864 out.extend_from_slice(&buf[..n]);
2865 } else {
2866 out.extend(core::iter::repeat_n(0u8, 9 - n));
2867 out.extend_from_slice(&buf[..n]);
2868 }
2869 }
2870 out
2871}
2872
2873/// `10^e` as an `f64`, for any `e` a canonical key can carry.
2874#[allow(clippy::cast_precision_loss)]
2875fn pow10_f64(e: i32) -> f64 {
2876 let mut out = 1.0_f64;
2877 let mag = e.unsigned_abs();
2878 for _ in 0..mag {
2879 out *= 10.0;
2880 }
2881 if e < 0 { 1.0 / out } else { out }
2882}
2883
2884impl Ord for NumericKey {
2885 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
2886 use core::cmp::Ordering;
2887 if self.class != other.class {
2888 return self.class.cmp(&other.class);
2889 }
2890 if self.class != NUM_CLASS_FINITE {
2891 // Each of the three specials is a single value, and PG holds
2892 // `'NaN'::numeric = 'NaN'::numeric` true.
2893 return Ordering::Equal;
2894 }
2895 // Zero first: it is stored with `neg == false` and `exp == 0`, so
2896 // the magnitude comparison below would put it above every value
2897 // smaller than 1 rather than between the negatives and positives.
2898 match (self.head == 0, other.head == 0) {
2899 (true, true) => return Ordering::Equal,
2900 (true, false) => {
2901 return if other.neg {
2902 Ordering::Greater
2903 } else {
2904 Ordering::Less
2905 };
2906 }
2907 (false, true) => {
2908 return if self.neg {
2909 Ordering::Less
2910 } else {
2911 Ordering::Greater
2912 };
2913 }
2914 (false, false) => {}
2915 }
2916 match (self.neg, other.neg) {
2917 (false, true) => return Ordering::Greater,
2918 (true, false) => return Ordering::Less,
2919 _ => {}
2920 }
2921 // Same sign, both non-zero: more integer digits is bigger, and at
2922 // equal exponent the left-aligned heads compare as one integer —
2923 // the alignment is what makes that the same answer as comparing
2924 // the digit strings. The tail only speaks when the first 37
2925 // significant digits are identical.
2926 let mag = self
2927 .exp
2928 .cmp(&other.exp)
2929 .then_with(|| self.head.cmp(&other.head))
2930 .then_with(|| self.tail.cmp(&other.tail));
2931 if self.neg { mag.reverse() } else { mag }
2932 }
2933}
2934
2935impl PartialOrd for NumericKey {
2936 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
2937 Some(self.cmp(other))
2938 }
2939}
2940
2941/// v7.39.13 — the ONE definition of how two `timetz` values order.
2942///
2943/// PostgreSQL 18.6 orders them by a PAIR: the UTC-equivalent instant,
2944/// then the OFFSET DESCENDING. Values naming one instant in different
2945/// zones are DISTINCT there — `'07:00:00+00' = '02:00:00-05'` is FALSE
2946/// — and the offset half was missing from every surface that had an
2947/// answer at all.
2948///
2949/// A B-tree over the instant alone does not merely sort badly: it
2950/// CHANGES ANSWERS. Measured on this engine with the six-row fixture in
2951/// `e2e_timetz_order_v73913`, `WHERE k > '07:00:00+00'`:
2952///
2953/// ```text
2954/// no index 2, 6 (PostgreSQL 18.6: 2, 6)
2955/// index <nothing>
2956/// ```
2957///
2958/// The range starts above one instant, and the two rows that share that
2959/// instant while sorting ABOVE the bound live below it in a key space
2960/// that has dropped the zone. A superset and a re-check cannot save a
2961/// seek that returns too FEW.
2962///
2963/// The instant shifts left by 17 bits and the offset sits underneath —
2964/// an offset is at most ±57,600 seconds and an instant at most about
2965/// 1.44e11 microseconds, so the two never meet and the whole key stays
2966/// inside `i64`.
2967pub fn timetz_sort_key(us: i64, offset_secs: i32) -> i64 {
2968 let utc = us - i64::from(offset_secs) * 1_000_000;
2969 (utc << 17) - i64::from(offset_secs)
2970}
2971
2972impl IndexKey {
2973 /// v7.37.43 (INSUBQ B-4) — inline-friendly BigInt fast path.
2974 /// `try_count_star_pk_in_subquery_fast` (and any other hot loop
2975 /// probing an integer PK) already holds an `i64`; this builds the
2976 /// `IndexKey` without going through the generic `from_value`
2977 /// dispatch tree.
2978 #[inline]
2979 pub fn from_i64(n: i64) -> Self {
2980 Self::Int(n)
2981 }
2982
2983 /// r1039 — the key a value takes when the INDEXED COLUMN is `ty`, or
2984 /// `None` when it takes none (→ the caller falls back to a scan).
2985 ///
2986 /// Every key under one index comes from one column, so they all live
2987 /// in one key SPACE. A probe built in a different space finds nothing
2988 /// — and "nothing" is indistinguishable from "no matching rows",
2989 /// which is how round 564 and r1037 both turned an index into a wrong
2990 /// answer (a TEXT key sought against a DATE-keyed and a UUID-keyed
2991 /// index).
2992 ///
2993 /// The two spaces this round adds make that trap reachable again from
2994 /// a new direction: `WHERE n = 2` on a NUMERIC column produces
2995 /// `Value::Int`, and an integer key would look in a space nothing
2996 /// lives in. So NUMERIC columns take integers by converting them
2997 /// exactly, and refuse anything they cannot convert; BYTEA columns
2998 /// take only `Value::Bytes`; and no other column may be keyed in
2999 /// either of the two new spaces.
3000 ///
3001 /// Use this wherever the key comes from a LITERAL or from another
3002 /// table's value. [`IndexKey::from_value`] stays right for building
3003 /// the index itself, where the value is the column's own.
3004 pub fn from_value_for_column(v: &Value<'_>, ty: DataType) -> Option<Self> {
3005 match ty {
3006 DataType::Numeric { .. } => match v {
3007 Value::SmallInt(n) => Some(Self::exact_int_key(i128::from(*n))),
3008 Value::Int(n) => Some(Self::exact_int_key(i128::from(*n))),
3009 Value::BigInt(n) => Some(Self::exact_int_key(i128::from(*n))),
3010 Value::Numeric { .. } | Value::NumericBig(_) => Self::from_value(v),
3011 // Float included: `2.0::float8` and `2.0::numeric` are not
3012 // the same value to a B-tree, and rounding one into the
3013 // other's space is how a seek reaches the wrong row.
3014 _ => None,
3015 },
3016 DataType::Bytes => match v {
3017 Value::Bytes(b) => Some(Self::Bytes(b.to_vec())),
3018 _ => None,
3019 },
3020 _ => match Self::from_value(v) {
3021 Some(Self::Numeric(_) | Self::Bytes(_)) => None,
3022 other => other,
3023 },
3024 }
3025 }
3026
3027 /// An integer as a NUMERIC key. Exact by construction — no scale, no
3028 /// rounding — which is why the conversion is allowed at all.
3029 fn exact_int_key(n: i128) -> Self {
3030 Self::Numeric(alloc::boxed::Box::new(NumericKey::from_i128(n)))
3031 }
3032
3033 pub fn from_value(v: &Value<'_>) -> Option<Self> {
3034 match v {
3035 // v7.37.43 (INSUBQ B-4) — BigInt hits first (the dominant
3036 // INSUBQ shape probes PK as BigInt). Tiny micro-win.
3037 Value::BigInt(n) => Some(Self::Int(*n)),
3038 Value::SmallInt(n) => Some(Self::Int(i64::from(*n))),
3039 Value::Int(n) => Some(Self::Int(i64::from(*n))),
3040 Value::Text(s) => Some(Self::Text(s.clone().into_owned())),
3041 // v7.38 (read01, T11) — bpchar keys compare blank-insensitively.
3042 Value::BpChar(s) => Some(Self::Text(s.trim_end_matches(' ').to_string())),
3043 Value::Bool(b) => Some(Self::Bool(*b)),
3044 // Date/Timestamp use their integer storage repr as the
3045 // index key — same order semantics, same comparison.
3046 Value::Date(d) => Some(Self::Int(i64::from(*d))),
3047 Value::Timestamp(t) => Some(Self::Int(*t)),
3048 // v7.17.0: UUID indexable via byte-wise ordering. Lookup
3049 // on `id = '...'::uuid` resolves through the secondary
3050 // index rather than full-scan.
3051 Value::Uuid(b) => Some(Self::Uuid(*b)),
3052 // v7.17.0 Phase 3.P0-32: TIME indexable via i64 — same
3053 // order semantics as Date/Timestamp.
3054 Value::Time(us) => Some(Self::Int(*us)),
3055 // v7.17.0 Phase 3.P0-33: YEAR indexable as i64 — u16
3056 // widens losslessly and gives the natural calendar
3057 // ordering.
3058 Value::Year(y) => Some(Self::Int(i64::from(*y))),
3059 // v7.17.0 Phase 3.P0-34: TIMETZ indexable by its
3060 // UTC-equivalent microseconds (local wall - offset).
3061 // Without normalising, two values for the same
3062 // physical instant in different zones would sort
3063 // wrong. Matches PG's TIMETZ index behaviour.
3064 Value::TimeTz { us, offset_secs } => {
3065 Some(Self::Int(timetz_sort_key(*us, *offset_secs)))
3066 }
3067 // v7.17.0 Phase 3.P0-35: MONEY indexable as i64 cents
3068 // (no scaling needed — natural numeric ordering).
3069 Value::Money(c) => Some(Self::Int(*c)),
3070 // v7.17.0 Phase 3.P0-38: ranges are NOT indexable in
3071 // v7.17.0 — they'd need a custom comparator (PG uses
3072 // SP-GiST for this). Skip.
3073 Value::Range { .. } => None,
3074 // v7.17.0 Phase 3.P0-39: hstore is NOT indexable in
3075 // v7.17.0 — map columns need GIN with bespoke ops.
3076 Value::Hstore(_) => None,
3077 // r1039 — exact decimals index through the canonical
3078 // [`NumericKey`], which is what makes `1.5` and `1.50` one key.
3079 Value::NumericBig(b) => Some(Self::Numeric(alloc::boxed::Box::new(NumericKey::from_big(b)))),
3080 Value::Numeric {
3081 scaled,
3082 scale,
3083 kind,
3084 } => Some(Self::Numeric(alloc::boxed::Box::new(
3085 NumericKey::from_numeric(*scaled, *scale, *kind),
3086 ))),
3087 // r1039 — bytea orders by plain byte comparison, which is
3088 // `Vec<u8>`'s own.
3089 Value::Bytes(b) => Some(Self::Bytes(b.to_vec())),
3090 // v7.17.0 Phase 3.P0-40: 2D arrays aren't indexable.
3091 Value::IntArray2D(_)
3092 | Value::BigIntArray2D(_)
3093 | Value::TextArray2D(_)
3094 | Value::BoolArray2D(_) => None,
3095 // v7.37.5 β-P4: INTERVAL[] isn't indexable (PG uses
3096 // GIN/intarray for array-contains queries; SPG plans
3097 // that as a separate axis under v7.37.8 GIN-on-jsonb).
3098 Value::IntervalArray(_) => None,
3099 // v7.37.5 γ — none of the array-of-scalar family is
3100 // B-tree indexable. Same reason as IntervalArray: PG
3101 // serves array-contains / array-overlap queries via
3102 // GIN, and SPG's GIN axis lands in v7.37.8.
3103 Value::BoolArray(_)
3104 | Value::SmallIntArray(_)
3105 | Value::Int2Vector(_)
3106 | Value::OidVector(_)
3107 | Value::FloatArray(_)
3108 | Value::NumericArray(_)
3109 | Value::DateArray(_)
3110 | Value::TimestampArray(_)
3111 | Value::TimestamptzArray(_)
3112 | Value::UuidArray(_)
3113 | Value::JsonArray(_)
3114 | Value::JsonbArray(_)
3115 | Value::BytesArray(_)
3116 | Value::VarcharArray(_)
3117 | Value::CharArray(_)
3118 // v7.37.5 δ — multirange not indexable (PG uses GiST/
3119 // SP-GiST + a custom operator class; SPG plans the same
3120 // axis under v7.37.8 with ranges).
3121 | Value::Multirange { .. }
3122 // v7.37.5 ε — geometric scalars not B-tree indexable
3123 // (PG uses GiST/SP-GiST for these too; SPG plans the
3124 // same axis under v7.37.8).
3125 | Value::Point(_)
3126 | Value::Lseg(_, _)
3127 | Value::Path { .. }
3128 | Value::PgBox(_, _)
3129 | Value::Polygon(_)
3130 | Value::Line { .. }
3131 | Value::Circle { .. }
3132 // v7.37.5 ζ-A — network / bit / xml / "char" / money[].
3133 // INET / CIDR / MACADDR / MACADDR8 could be B-tree
3134 // indexable (PG does this), but the byte-wise compare
3135 // family-blind would mis-order IPv4 vs IPv6; left as
3136 // a follow-up under v7.37.8 GIN window.
3137 | Value::Inet { .. }
3138 | Value::Cidr { .. }
3139 | Value::Macaddr(_)
3140 | Value::Macaddr8(_)
3141 | Value::PgLsn(_)
3142 | Value::BitString { .. }
3143 | Value::Xml(_)
3144 | Value::Char1(_)
3145 | Value::MoneyArray(_)
3146 | Value::Composite(_)
3147 | Value::Tid(..)
3148 | Value::Xid(_)
3149 | Value::Cid(_)
3150 | Value::RegClass(..)
3151 | Value::RegProc(..)
3152 | Value::RegType(..) => None,
3153 // Interval isn't index-eligible (and can't reach this path
3154 // through column storage anyway). Float / Real stay out
3155 // because `f64` is only `PartialOrd`.
3156 Value::Null
3157 | Value::Float(_)
3158 | Value::Vector(_)
3159 | Value::Sq8Vector(_)
3160 | Value::HalfVector(_)
3161 | Value::Interval { .. }
3162 | Value::Json(_)
3163 | Value::TextArray(_)
3164 | Value::IntArray(_)
3165 | Value::BigIntArray(_)
3166 | Value::TsVector(_)
3167 | Value::TsQuery(_)
3168 | Value::Real(_) => None,
3169 }
3170 }
3171}
3172
3173/// A single-column secondary index. v2.0 carries either a B-tree map
3174/// (the default — used for equality / range lookups on scalar columns)
3175/// or a navigable-small-world graph (used for kNN over vector
3176/// columns).
3177#[derive(Debug, Clone)]
3178pub struct Index {
3179 pub name: String,
3180 pub column_position: usize,
3181 pub kind: IndexKind,
3182 /// v6.8.0 — column positions of `INCLUDE (col1, col2, …)`
3183 /// non-key columns. Carries the planner's "this query is
3184 /// covered by the index" signal; lookup paths still resolve
3185 /// via the `RowLocator` to fetch the row body, but EXPLAIN
3186 /// surfaces the covered-scan annotation so operators can
3187 /// confirm the planner sees the coverage.
3188 ///
3189 /// Empty `Vec` = no `INCLUDE` clause (the legacy shape). v12
3190 /// catalog snapshots deserialise with an empty vec.
3191 pub included_columns: Vec<usize>,
3192 /// v6.8.1 — partial-index predicate stored as its canonical
3193 /// Display form (the engine re-parses it on the maintenance
3194 /// path). `None` = unconditional index (the legacy shape).
3195 /// Persisted as `[u8 has_pred][u16 LE len][bytes]` on the
3196 /// catalog snapshot (FILE_VERSION 12, appended after
3197 /// `included_columns`).
3198 /// v7.39.13 — `true` when SPG built this index to serve probes on a
3199 /// constraint's non-leading columns, rather than because anyone
3200 /// asked for it.
3201 ///
3202 /// A multi-column `PRIMARY KEY (a, b)` becomes one composite B-tree
3203 /// over the whole key PLUS one single-column B-tree per remaining
3204 /// column, because a composite cannot answer a probe that does not
3205 /// start at its front. PostgreSQL has one index per constraint and
3206 /// no others, so those extras appeared in `pg_index` as indexes a
3207 /// schema reader never created and PostgreSQL would never show —
3208 /// and for an INLINE composite key the catalog listed two of them
3209 /// and no primary key at all.
3210 ///
3211 /// Recorded rather than guessed. Deciding it from the name is what
3212 /// v7.39.11 removed and v7.39.12 reintroduced as a prefix match, in
3213 /// both cases because nothing in storage said so.
3214 pub constraint_internal: bool,
3215 /// v7.39.13 — `true` when this IS a constraint's own index: the one
3216 /// PostgreSQL creates for a `PRIMARY KEY` / `UNIQUE`, and the only
3217 /// one it shows.
3218 ///
3219 /// Recorded, because the alternative is matching an index's columns
3220 /// against a constraint's and calling a hit the constraint's index.
3221 /// v7.39.12 did that by prefix and mislabelled a user's own index;
3222 /// doing it by EXACT columns still renames `CREATE INDEX idx_d_a ON
3223 /// d (a)` to the name of the `UNIQUE (a)` beside it, and still
3224 /// claims an expression index on `(a + 1)` is the key.
3225 pub constraint_backing: bool,
3226 pub partial_predicate: Option<String>,
3227 /// v6.8.2 — expression-index key, stored as the expression's
3228 /// canonical Display form. `None` = bare column-reference
3229 /// index (the legacy shape). Persisted alongside
3230 /// `partial_predicate` on the v12 catalog snapshot.
3231 pub expression: Option<String>,
3232 /// v7.39 (read01 round 52) — `CREATE UNIQUE INDEX … NULLS NOT DISTINCT`
3233 /// (PG 15+): a NULL in the key no longer exempts the row, so two
3234 /// all-NULL keys collide. Default `false` = SQL-standard NULLS DISTINCT.
3235 /// Persisted in the index appendix (FILE_VERSION 62+); older catalogs
3236 /// deserialise with `false`.
3237 pub nulls_not_distinct: bool,
3238 /// v7.39 (round 537) — the key column's ordering clause, as written.
3239 ///
3240 /// SPG's index does not scan in a direction, so this changes no
3241 /// lookup; `pg_indexes.indexdef` is a reproduction of the DDL and
3242 /// dropping the clause made `CREATE INDEX i ON t (a DESC NULLS
3243 /// LAST)` read back as `(a)` — a dump lost it and a schema diff saw
3244 /// drift every run. `nulls_first` is `None` when the statement did
3245 /// not say, in which case PG's default applies and neither word is
3246 /// rendered.
3247 pub descending: bool,
3248 pub nulls_first: Option<bool>,
3249 /// v7.39 (round 538) — an explicit `COLLATE` on the key, as written.
3250 /// SPG orders text by bytes, so it changes no comparison; PG prints
3251 /// it because a named collation and an inherited one are different
3252 /// objects even where they sort identically.
3253 pub collation: Option<String>,
3254 /// v7.9.29 — `CREATE UNIQUE INDEX …`. When true the engine
3255 /// rejects INSERTs whose key already appears in this index
3256 /// (combined with `partial_predicate` when present — only
3257 /// rows matching the predicate enter the uniqueness check).
3258 /// Catalog FILE_VERSION 16+; older snapshots deserialise
3259 /// with `false`. mailrs K1.
3260 pub is_unique: bool,
3261 /// v7.9.29 — extra (non-leading) column positions for
3262 /// multi-column indexes (`CREATE INDEX … (a, b, c)`). The
3263 /// planner today still only uses the leading
3264 /// `column_position` for index seeks, but UNIQUE INDEX
3265 /// enforcement walks the full tuple so partial-unique
3266 /// invariants like CalDAV `(calendar_id, uid,
3267 /// recurrence_id)` are enforced correctly. Catalog
3268 /// FILE_VERSION 16+; older snapshots deserialise empty.
3269 pub extra_column_positions: Vec<usize>,
3270 /// v7.39.11 — each extra key column's `DESC` / `NULLS FIRST`,
3271 /// positionally aligned with `extra_column_positions`. An empty
3272 /// vec, and any position past its end, means the PG default:
3273 /// ascending, nulls last.
3274 ///
3275 /// SPG's index does not scan in a per-column direction, so this
3276 /// changes no lookup — the same reason `descending` exists for the
3277 /// LEADING column. `pg_get_indexdef` is a reproduction of the DDL,
3278 /// and without this `CREATE INDEX i ON t (a, b DESC)` read back as
3279 /// `(a, b)`: a dump lost the clause and a schema diff saw drift
3280 /// every run. Reported by sentori against 7.39.10; round 537 fixed
3281 /// the identical thing for the leading column.
3282 pub extra_orders: Vec<KeyOrder>,
3283}
3284
3285/// v7.39.11 — one index key column's ordering clause, as written.
3286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3287pub struct KeyOrder {
3288 pub descending: bool,
3289 /// `None` when the statement did not say, in which case PG's
3290 /// default applies and neither word is rendered.
3291 pub nulls_first: Option<bool>,
3292}
3293
3294/// Default neighbor degree (M) for the NSW graph. Picked at construction
3295/// time and persisted with the index.
3296pub const NSW_DEFAULT_M: usize = 16;
3297
3298/// v5.2.2: outcome of a successful [`Catalog::freeze_oldest_to_cold`]
3299/// call. The catalog state has already been mutated by the time this
3300/// is returned (hot rows dropped + segment registered + Cold locators
3301/// flipped). The caller's only remaining concern is `segment_bytes` —
3302/// persist them to disk under `<db>.spg/segments/seg_<id>.spg` so a
3303/// future restart can reload via the v5.1 `SPG_PRELOAD_COLD_SEGMENT`
3304/// path. (v5.3's manifest will subsume this manual step.)
3305#[derive(Debug, Clone)]
3306pub struct FreezeReport {
3307 /// Id allocated by [`Catalog::load_segment_bytes`] for the new
3308 /// cold-tier segment. Stable across the call's success path.
3309 pub segment_id: u32,
3310 /// Number of rows that moved hot → cold. Equals the `max_rows`
3311 /// the caller asked for (the API is strict on the count).
3312 pub frozen_rows: usize,
3313 /// Hot-tier bytes reclaimed by the freeze — the
3314 /// [`Table::hot_bytes`] delta before vs after. Useful to feed
3315 /// back into the freezer's budget check on the next tick.
3316 pub bytes_freed: u64,
3317 /// Encoded segment bytes, byte-identical to what
3318 /// [`encode_segment`] produced. The catalog already owns a
3319 /// copy inside `cold_segments`; this hand-off lets the caller
3320 /// persist them without re-encoding.
3321 pub segment_bytes: Vec<u8>,
3322}
3323
3324/// v6.7.4 — read-only output of [`Catalog::prepare_freeze_slice`].
3325/// Carries every row body + key in a contiguous hot-row range,
3326/// already encoded and sorted by PK so the coordinator's merge
3327/// step is a k-way merge over already-sorted streams.
3328///
3329/// `Vec<FreezeSlice>` from N independent workers feeds
3330/// [`Catalog::commit_freeze_slices`], which concats + encodes the
3331/// merged segment + atomically swaps the catalog state.
3332#[derive(Debug, Clone)]
3333pub struct FreezeSlice {
3334 /// Hot-row index range this slice covered (half-open, in the
3335 /// table's `rows: PersistentVec` ordering at call time). The
3336 /// commit step uses this to compute the union range that
3337 /// gets passed to [`Table::delete_rows`].
3338 pub row_range: core::ops::Range<usize>,
3339 /// `(pk_u64, encoded_row_body, IndexKey)` triples, sorted
3340 /// ascending by `pk_u64`. Per-slice sort happens inside
3341 /// `prepare_freeze_slice`; the coordinator does only a
3342 /// k-way merge to reach the global PK ordering
3343 /// [`encode_segment`] requires.
3344 pub rows: Vec<(u64, Vec<u8>, IndexKey)>,
3345}
3346
3347/// v6.7.3 — outcome of a [`Catalog::compact_cold_segments`] call.
3348/// The catalog state has already been mutated when this is returned:
3349/// the merged segment is loaded into `cold_segments`, the source
3350/// segment slots are tombstoned (`None`), and every BTree-index
3351/// `RowLocator::Cold` that previously pointed at a source now
3352/// points at the merged segment. The caller's remaining job is to
3353/// persist `merged_segment_bytes` under
3354/// `<db>.spg/segments/seg_<merged_segment_id>.spg` and update the
3355/// in-memory `segment_id → path` map (remove the source ids, add
3356/// the merged id) so the next CHECKPOINT writes a manifest that
3357/// no longer lists the retired sources.
3358///
3359/// On a no-op (fewer than 2 candidate segments under the threshold),
3360/// `merged_segment_id` is `None` and `sources` is empty; the
3361/// catalog was not mutated.
3362#[derive(Debug, Clone)]
3363pub struct CompactReport {
3364 /// Source segment ids that were merged + tombstoned.
3365 pub sources: Vec<u32>,
3366 /// Id allocated for the merged segment. `None` on no-op.
3367 pub merged_segment_id: Option<u32>,
3368 /// Encoded merged-segment bytes (empty on no-op).
3369 pub merged_segment_bytes: Vec<u8>,
3370 /// Number of rows that landed in the merged segment.
3371 pub merged_rows: usize,
3372 /// `Σ source.num_rows − merged_rows`. Rows present in source
3373 /// segment payloads but unreferenced by any live BTree
3374 /// `Cold` locator — DELETE'd-but-still-frozen rows that
3375 /// compaction GC'd during the merge.
3376 pub deleted_rows_pruned: usize,
3377 /// `Σ source.bytes() − merged.bytes()`. Estimate of on-disk
3378 /// space the merge will reclaim once the source segment files
3379 /// are GC'd. Saturating subtract — never negative.
3380 pub bytes_reclaimed_estimate: u64,
3381}
3382
3383#[derive(Debug, Clone)]
3384pub enum IndexKind {
3385 /// v4.40: structural-sharing B-tree over `IndexKey`. Replaces the v0.8
3386 /// `BTreeMap<IndexKey, Vec<usize>>` — `Index::clone` is now an `Arc`
3387 /// bump regardless of index size, so `Catalog::clone` inside the
3388 /// v4.34 auto-commit wrap stays O(1) even for tables with secondary
3389 /// indices (the case that bottlenecked v4.39 at 1M rows in the
3390 /// sweep).
3391 ///
3392 /// v5.1: value type widened from `Vec<usize>` to `Vec<RowLocator>` so
3393 /// a single key can point to a mix of hot-tier rows (`RowLocator::Hot`,
3394 /// equivalent to the pre-v5 `usize` row index) and cold-tier rows
3395 /// (`RowLocator::Cold { segment_id, page_offset }`) once the v5.2
3396 /// freezer starts producing them. Pre-v5.2 only `Hot` entries appear
3397 /// — the on-disk encoding stays at `FILE_VERSION` 8 (raw u64 row index)
3398 /// because every locator round-trips through `RowLocator::from_legacy_v8_u64`
3399 /// without information loss. `FILE_VERSION` 9 with tagged encoding lands
3400 /// alongside the first freezer commit (v5.1 step 2b / v5.2).
3401 BTree(PersistentBTreeMap<IndexKey, crate::posting::PostingList>),
3402 /// Navigable-small-world graph for vector kNN search.
3403 Nsw(NswGraph),
3404 /// v6.7.1 — BRIN (Block Range INdex). Pure metadata: BRIN
3405 /// indexes carry NO in-memory key→locator map. The (min,
3406 /// max) summaries live in each cold-tier segment's v2
3407 /// envelope sidecar; the BRIN entry in `Table.indices` only
3408 /// records THAT a BRIN index exists on this column so the
3409 /// segment encoder + planner can opt into the summary path.
3410 Brin {
3411 /// The cell type at `column_position` at CREATE INDEX time.
3412 /// Used by the planner to type-check WHERE-clause range
3413 /// predicates against the BRIN-indexed column.
3414 column_type: DataType,
3415 /// v7.38.11 — one `(min, max)` per [`BRIN_RANGE_ROWS`] slots of
3416 /// the hot tier, so a range predicate can skip the ranges that
3417 /// cannot contain a match.
3418 ///
3419 /// Maintenance is WIDEN-ONLY and that is the whole safety
3420 /// argument: an insert widens its range, an update widens, and
3421 /// a delete leaves the range alone. A range left wider than the
3422 /// rows it now covers is correct and merely less selective —
3423 /// which is exactly PG's contract for a lossy index, since the
3424 /// predicate is re-checked on every row the summary lets
3425 /// through. A summary may over-report; it can never
3426 /// under-report, so no matching row can be skipped.
3427 ///
3428 /// `None` for a range whose rows carry no comparable key (all
3429 /// NULL, say), and such a range is never skipped.
3430 summaries: alloc::vec::Vec<Option<(i64, i64)>>,
3431 },
3432 /// v7.12.3 — GIN inverted index over a `tsvector` column.
3433 ///
3434 /// Storage shape: `lexeme word → Vec<RowLocator>`. The posting
3435 /// list per word is appended in row-order, so range scans are
3436 /// O(matching rows) once the per-word lookup is done. Multi-
3437 /// term queries intersect / union posting lists.
3438 ///
3439 /// `IndexKey::from_value(TsVector)` returns `None` — GIN doesn't
3440 /// participate in `try_index_seek` (which is BTree-equality-keyed).
3441 /// The engine consults this index through `try_gin_lookup` on
3442 /// `WHERE col @@ tsquery` predicates instead.
3443 ///
3444 /// Backed by a `PersistentBTreeMap` so `Catalog::clone` (the
3445 /// per-write snapshot) stays O(1) — same structural-sharing
3446 /// invariant as BTree.
3447 Gin(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3448 /// v7.15.0 — `USING gin (col gin_trgm_ops)` over a `TEXT`
3449 /// column. Posting lists map `trigram` (PG-compatible 3-byte
3450 /// shingle on the lower-cased + space-padded input) to row
3451 /// locators. The planner uses this index to accelerate
3452 /// `WHERE col LIKE '…'` / `ILIKE '…'` / `similarity(col, q) >
3453 /// t` — every literal run of length ≥ 1 in the pattern
3454 /// produces a trigram set, the engine intersects the posting
3455 /// lists, and the LIKE / similarity predicate is re-evaluated
3456 /// per candidate row to filter the over-approximation.
3457 /// Persisted via tag-4 index payload in `FILE_VERSION` 24+.
3458 GinTrgm(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3459 /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY (col)` over a
3460 /// `TEXT` / `VARCHAR` column. Posting lists map
3461 /// `tsvector('simple') lexeme` to row locators. At insert /
3462 /// build time the engine derives the lexemes from the cell
3463 /// via the same lower-case tokenisation rule as
3464 /// `to_tsvector('simple', ...)` — the column itself stays a
3465 /// plain text type on disk (mysqldump round-trips would be
3466 /// broken otherwise). The planner uses this index to
3467 /// accelerate MySQL-shape `MATCH(col) AGAINST('term')`
3468 /// queries by mapping them onto the existing tsquery `@@`
3469 /// walker. Persisted via tag-5 index payload in
3470 /// `FILE_VERSION` 33+.
3471 GinFulltext(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3472 /// v7.37.8(sentori Epic 5 P2)— `USING gin (col)` over a
3473 /// `JSON` / `JSONB` column. Posting lists map a canonical
3474 /// `(path, leaf)` token(see [`crate::jsonb_gin::extract_tokens`])
3475 /// to row locators so the planner can resolve
3476 /// `<col> @> <jsonb_literal>` to a candidate row set via
3477 /// posting-list intersection + per-row `json::contains`
3478 /// re-verification. Pre-7.37.8 the same DDL loaded as a
3479 /// BTree fallback so `pg_dump` JSONB-GIN scripts kept loading
3480 /// without query-time acceleration. Persisted via tag-6 index
3481 /// payload in `FILE_VERSION` 51+.
3482 GinJsonb(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3483 /// v7.38.1 (L12) — a REAL multi-column B-tree: the key is the whole
3484 /// column tuple, `[leading, extras…]`, ordered lexicographically by
3485 /// slice `Ord`. That ordering is the entire design: every key
3486 /// sharing a prefix is contiguous, so an equality on a PREFIX of
3487 /// the columns is one `O(log N)` descent plus a bounded walk, and a
3488 /// full-tuple equality is a point `get`. The single-column `BTree`
3489 /// kind used to stand in for multi-column DDL by keying on the
3490 /// leading column only and carrying the rest as metadata — TPC-C's
3491 /// `customer (c_w_id, c_d_id, c_last, c_first)` then answered a
3492 /// three-column equality with every row of one warehouse and a
3493 /// per-row filter over 30 000 candidates.
3494 ///
3495 /// Rows where any component column is NULL (or of an unkeyable
3496 /// type) are NOT entered: this index serves `=` probes, and in SQL
3497 /// `col = v` never selects a NULL. Uniqueness keeps its own
3498 /// full-tuple walk with NULLS-DISTINCT semantics on the
3499 /// enforcement path, exactly as before.
3500 ///
3501 /// Persisted via tag-7 index payload in `FILE_VERSION` 91+.
3502 BTreeMulti(PersistentBTreeMap<alloc::boxed::Box<[IndexKey]>, crate::posting::PostingList>),
3503}
3504
3505impl IndexKind {
3506 /// v7.31 (memory campaign, C2) — bytes this index variant holds
3507 /// resident in RAM, computed by walking its OWN structure rather
3508 /// than a parametric guess made by the engine. Replaces the old
3509 /// `spg_admin::memory_stats` inline match, which charged NSW with
3510 /// a stale `m_max_0 * 8` per node (neighbour slots are `u32` = 4 B
3511 /// since v6.1.x, and most nodes never fill `m_max_0`) and lumped
3512 /// every GIN family index into a flat 1 KiB token — a gross
3513 /// undercount for the text-heavy posting lists that dominate
3514 /// mailrs' footprint. Per-entry container overhead uses the
3515 /// 3-word (24 B on 64-bit) `Vec`/`String` header as the charge.
3516 ///
3517 /// O(index entries): operator/monitoring surface (`memory_stats` /
3518 /// `spg_memory_stats`), not a query path.
3519 #[must_use]
3520 pub fn approx_resident_bytes(&self) -> u64 {
3521 const HEADER: usize = 24; // Vec/String 3-word header on 64-bit.
3522 let loc = core::mem::size_of::<RowLocator>();
3523 match self {
3524 IndexKind::BTree(map) => {
3525 let key = core::mem::size_of::<IndexKey>();
3526 map.iter()
3527 .map(|(_, locs)| (key + HEADER + locs.len() * loc) as u64)
3528 .sum()
3529 }
3530 // v7.38.1 (L12) — multi keys own a boxed slice of components.
3531 IndexKind::BTreeMulti(map) => {
3532 let key = core::mem::size_of::<IndexKey>();
3533 map.iter()
3534 .map(|(k, locs)| (HEADER + k.len() * key + HEADER + locs.len() * loc) as u64)
3535 .sum()
3536 }
3537 IndexKind::Nsw(g) => {
3538 // `levels` is one byte per node; each layer's adjacency
3539 // is a `Vec<u32>` per node whose actual length we walk
3540 // (the dense layer-0 list dominates, but upper layers
3541 // are sparse — the old estimate ignored that).
3542 let mut b = g.levels.len() as u64;
3543 for layer in &g.layers {
3544 for nbrs in layer.iter() {
3545 b += (HEADER + nbrs.len() * core::mem::size_of::<u32>()) as u64;
3546 }
3547 }
3548 b
3549 }
3550 // BRIN carries NO in-memory key→locator map (the (min,max)
3551 // summaries live in cold-segment sidecars on disk); the
3552 // resident footprint is just the column-type token.
3553 IndexKind::Brin { .. } => core::mem::size_of::<DataType>() as u64,
3554 IndexKind::Gin(map)
3555 | IndexKind::GinTrgm(map)
3556 | IndexKind::GinFulltext(map)
3557 | IndexKind::GinJsonb(map) => map
3558 .iter()
3559 .map(|(word, postings)| {
3560 (word.len() + HEADER + HEADER + postings.len() * loc) as u64
3561 })
3562 .sum(),
3563 }
3564 }
3565}
3566
3567/// Multi-layer HNSW graph (v2.13). Each node is assigned a `top_level`;
3568/// it appears in layers `0..=top_level`. Higher layers are sparser, so
3569/// search starts from the entry at the top layer, greedy-descends to
3570/// layer 0, and beam-searches there. Layer 0 keeps a larger neighbour
3571/// budget (`m_max_0 = 2 * m` per the HNSW paper); upper layers cap at
3572/// `m`. The struct name stays `NswGraph` so external users / on-disk
3573/// callers don't have to track a rename — the algorithm changed, the
3574/// data slot didn't.
3575#[derive(Debug, Clone)]
3576pub struct NswGraph {
3577 /// Max neighbours per node on layers ≥ 1.
3578 pub m: usize,
3579 /// Max neighbours on layer 0 (the dense bottom layer). HNSW
3580 /// convention: `m_max_0 = 2 * m`.
3581 pub m_max_0: usize,
3582 /// Entry point — the node that sits on the topmost layer. Search
3583 /// always starts here.
3584 pub entry: Option<usize>,
3585 /// Top layer of the entry node (== `layers.len() - 1` when populated).
3586 pub entry_level: u8,
3587 /// `levels[i]` = top layer of node `i`. Nodes whose vector cell is
3588 /// NULL / non-Vector have `levels[i] = 0` and no neighbour entries.
3589 ///
3590 /// v5.5.0: backed by `PersistentVec` so `NswGraph::clone` (and the
3591 /// `Catalog::clone` on every group-commit write that contains it) is O(1)
3592 /// structural-sharing instead of an O(N) element copy.
3593 pub levels: PersistentVec<u8>,
3594 /// `layers[l][i]` = neighbours of node `i` at layer `l`. Inner vec
3595 /// is empty when node `i` doesn't reach layer `l`.
3596 ///
3597 /// v5.5.0: the per-node middle dimension (the O(N) one) is a
3598 /// `PersistentVec`; the outer layer dimension stays a plain `Vec`
3599 /// (layer count ≤ 8, so its clone is O(1) in practice) and the inner
3600 /// neighbour list stays a `Vec` (bounded by `m_max_0`).
3601 ///
3602 /// v6.1.x: neighbour slot widened from `usize` (8 B on 64-bit) to
3603 /// `u32` (4 B). Row indices are catalog-bounded by `u32::MAX` (4G
3604 /// rows per table); the cast at the NSW boundary asserts this. At
3605 /// 1M dim-128 SQ8, layer 0 adjacency alone shrinks by ~128 MiB
3606 /// — the largest single contribution to the v6.0.5-measured
3607 /// 624 MiB ambition gap. On-disk format already used u32 LE, so
3608 /// this is a pure in-memory layout change; no `FILE_VERSION` bump.
3609 pub layers: Vec<PersistentVec<Vec<u32>>>,
3610}
3611
3612impl NswGraph {
3613 fn new(m: usize) -> Self {
3614 Self {
3615 m,
3616 m_max_0: m.saturating_mul(2),
3617 entry: None,
3618 entry_level: 0,
3619 levels: PersistentVec::new(),
3620 layers: alloc::vec![PersistentVec::new()],
3621 }
3622 }
3623
3624 /// Max-neighbour budget for layer `l`.
3625 pub const fn cap_for_layer(&self, layer: u8) -> usize {
3626 if layer == 0 { self.m_max_0 } else { self.m }
3627 }
3628}
3629
3630/// Deterministic level assignment, seeded on the row index so the same
3631/// insert order reproduces the same topology. Distribution is roughly
3632/// HNSW-flavoured with `mL ≈ 1/ln(M) ≈ 0.36` for M=16: each 4-bit
3633/// chunk that comes up zero promotes the node one layer (so P(level ≥
3634/// L) ≈ (1/16)^L).
3635#[allow(clippy::verbose_bit_mask)] // clippy suggests trailing_zeros(); we need an explicit MAX cap and a stable distribution shape.
3636pub fn nsw_assign_level(row_idx: usize) -> u8 {
3637 const MAX_LEVEL: u8 = 7; // 7 ⇒ ~16^7 ≈ 2.7e8 expected nodes between promotions; ample.
3638 // SplitMix-style mixer — cheap and seedable.
3639 let mut x = (row_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
3640 x ^= x >> 30;
3641 x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
3642 x ^= x >> 27;
3643 x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
3644 x ^= x >> 31;
3645 // Count contiguous low-end zero nibbles (4-bit chunks). Each zero
3646 // nibble has probability 1/16, mirroring HNSW's `mL ≈ 1/ln(M)` for
3647 // M=16. `trailing_zeros / 4` would lose the ordering when x = 0, so
3648 // a plain loop with a cap is clearer.
3649 let mut level: u8 = 0;
3650 while x & 0xF == 0 && level < MAX_LEVEL {
3651 level += 1;
3652 x >>= 4;
3653 }
3654 level
3655}
3656
3657/// v7.38.1 (L12) — the composite key `values` takes in a multi-column
3658/// B-tree over `[lead, extras…]`. A NULL component keys as
3659/// [`IndexKey::Null`] (declared to sort last, PG's NULLS LAST) so the
3660/// row stays findable by prefix probes on the columns before it. `None`
3661/// = some non-null component has no key form; the row is then not
3662/// entered, which is why creation gates every component column's type
3663/// through [`multi_component_type_ok`].
3664///
3665/// v7.39.13 — this keys by the VALUE while every probe keys by the
3666/// COLUMN ([`IndexKey::from_value_for_column`]), and that is safe
3667/// because a third thing makes the two agree: `Table::insert_keyed`
3668/// refuses a value whose type is not the column's, so a `NUMERIC`
3669/// column cannot hold the `Value::Int(2)` that would key as `Int(2)`
3670/// where the probe built `Numeric(2)`.
3671///
3672/// Written down because the possibility looks live and is not. Keying
3673/// by column here was implemented and then reverted: it added a schema
3674/// lookup per component per row to the write path to re-check a
3675/// contract insert already enforces, and the test written to make it
3676/// bite could not construct the divergent row at all —
3677/// `TypeMismatch { column: "n", expected: Numeric, actual: Int }`.
3678pub(crate) fn compose_multi_key(
3679 values: &[Value<'_>],
3680 lead: usize,
3681 extras: &[usize],
3682) -> Option<alloc::boxed::Box<[IndexKey]>> {
3683 let mut comps: Vec<IndexKey> = Vec::with_capacity(1 + extras.len());
3684 for pos in core::iter::once(lead).chain(extras.iter().copied()) {
3685 let v = values.get(pos)?;
3686 if matches!(v, Value::Null) {
3687 comps.push(IndexKey::Null);
3688 } else {
3689 comps.push(IndexKey::from_value(v)?);
3690 }
3691 }
3692 Some(comps.into_boxed_slice())
3693}
3694
3695/// v7.38.1 (L12) — component-type gate for multi-column B-trees: every
3696/// NON-NULL value of these types keys through
3697/// [`IndexKey::from_value_for_column`], so a row can only be absent
3698/// from the index when creation raced a type this answer does not
3699/// allow. A type answering `false` simply keeps its index on the
3700/// leading-column path — a slower plan, never a wrong answer.
3701///
3702/// v7.39.13 — EXHAUSTIVE, and that is the whole point of rewriting it.
3703///
3704/// It was a `matches!` over eleven names, so every one of the other
3705/// sixty-three `DataType`s answered `false` by falling off the end, and
3706/// nothing in the tree could say which of them meant it. Two of the
3707/// misses were reported from production as separate defects and were
3708/// one hole: `timestamptz` (v7.39.13, sentori's access path) and
3709/// `numeric`, which this version's own perf gate caught the same day
3710/// with a composite index over `(n numeric, id)` that never became a
3711/// composite tree —
3712///
3713/// ```text
3714/// 10,000 rows WHERE n = 1.23 ORDER BY id DESC LIMIT 20
3715/// SPG 0.497-0.520 ms PG 18.6 0.183-0.234 ms
3716/// 50,000 rows the same query
3717/// SPG 0.975-0.991 ms PG 18.6 0.195-0.439 ms
3718/// ```
3719///
3720/// Twenty rows behind a seek do not cost twice as much on five times
3721/// the table. It was a scan and a sort, exactly as `timestamptz` was.
3722///
3723/// Written as a match with no wildcard, a new `DataType` does not
3724/// compile until someone answers for it. That is the mechanical part;
3725/// the arms are grouped by the reason, so the answer is also readable.
3726pub(crate) fn multi_component_type_ok(ty: DataType) -> bool {
3727 match ty {
3728 // Integers, and everything whose storage IS an i64 with the
3729 // same order: dates, both timestamps, times, money, year.
3730 DataType::SmallInt
3731 | DataType::Int
3732 | DataType::BigInt
3733 | DataType::Date
3734 | DataType::Timestamp
3735 // `timestamptz` keys exactly as `timestamp` does: both hold the
3736 // same i64 of UTC microseconds, and the zone lives in the
3737 // column's type rather than in the value.
3738 | DataType::Timestamptz
3739 | DataType::Time
3740 | DataType::TimeTz
3741 | DataType::Year
3742 | DataType::Money => true,
3743 // Text, in every declared width. `bpchar` keys blank-trimmed,
3744 // which is how it compares.
3745 DataType::Text | DataType::Varchar(_) | DataType::Char(_) => true,
3746 DataType::Bool | DataType::Uuid => true,
3747 // Exact decimal, through the canonical `NumericKey` that makes
3748 // `1.5` and `1.50` one key. Safe as a component only since
3749 // `compose_multi_key` began keying by COLUMN TYPE.
3750 DataType::Numeric { .. } => true,
3751 // bytea orders by plain byte comparison, which is `Vec<u8>`'s.
3752 DataType::Bytes => true,
3753 // `f64` is `PartialOrd` and nothing else: a B-tree cannot hold
3754 // a key whose comparison may decline to answer.
3755 DataType::Float | DataType::Real => false,
3756
3757 // Object identifiers and `name` reach storage as values
3758 // `IndexKey::from_value` returns `None` for. Not a decision
3759 // about the type — a statement about the key form it has.
3760 DataType::Name | DataType::Xid | DataType::Xid8 | DataType::Oid => false,
3761 // Documents and the semi-structured family: PostgreSQL serves
3762 // these with GIN, not with a B-tree over the whole value.
3763 DataType::Json | DataType::Jsonb | DataType::Hstore | DataType::Xml => false,
3764 // Full-text.
3765 DataType::TsVector | DataType::TsQuery => false,
3766 // Vectors: ordered by distance to a query, which is not an
3767 // order at all until the query exists.
3768 DataType::Vector { .. } => false,
3769 // Intervals, ranges and multiranges have no total order that
3770 // a B-tree probe could use; PostgreSQL uses GiST/SP-GiST.
3771 DataType::Interval | DataType::Range(_) | DataType::Multirange(_) => false,
3772 // Geometry: GiST/SP-GiST there too.
3773 DataType::Point
3774 | DataType::Lseg
3775 | DataType::Path
3776 | DataType::PgBox
3777 | DataType::Polygon
3778 | DataType::Line
3779 | DataType::Circle => false,
3780 // Network and bit strings. `inet`/`cidr` COULD be B-tree keyed
3781 // — PostgreSQL does — but a family-blind byte compare would
3782 // mis-order IPv4 against IPv6, so the key form does not exist
3783 // here yet.
3784 DataType::Inet
3785 | DataType::Cidr
3786 | DataType::Macaddr
3787 | DataType::Macaddr8
3788 | DataType::PgLsn
3789 | DataType::Bit(_)
3790 | DataType::BitVarying(_)
3791 | DataType::Char1 => false,
3792 // Arrays, of every element type and both dimensionalities.
3793 // PostgreSQL answers containment over these with GIN.
3794 DataType::TextArray
3795 | DataType::IntArray
3796 | DataType::BigIntArray
3797 | DataType::OidArray
3798 | DataType::Int2Vector
3799 | DataType::OidVector
3800 | DataType::IntervalArray
3801 | DataType::BoolArray
3802 | DataType::SmallIntArray
3803 | DataType::FloatArray
3804 | DataType::NumericArray
3805 | DataType::DateArray
3806 | DataType::TimestampArray
3807 | DataType::TimestamptzArray
3808 | DataType::UuidArray
3809 | DataType::JsonArray
3810 | DataType::JsonbArray
3811 | DataType::BytesArray
3812 | DataType::VarcharArray
3813 | DataType::CharArray
3814 | DataType::MoneyArray
3815 | DataType::IntArray2D
3816 | DataType::BigIntArray2D
3817 | DataType::TextArray2D
3818 | DataType::BoolArray2D => false,
3819 }
3820}
3821
3822impl Index {
3823 /// Any key this B-tree currently holds, or `None` if it holds none.
3824 ///
3825 /// A probe built from a query literal has to be the same SHAPE as the
3826 /// keys the maintenance side made, or `lookup_eq` misses every row and
3827 /// the caller reads the empty answer as "no rows match". One stored
3828 /// key settles it: an index keys one expression, whose values are one
3829 /// type.
3830 pub fn sample_key(&self) -> Option<&IndexKey> {
3831 match &self.kind {
3832 IndexKind::BTree(map) => map.iter().next().map(|(k, _)| k),
3833 _ => None,
3834 }
3835 }
3836
3837 /// v7.38.19 — the largest integer key this index holds.
3838 ///
3839 /// For the one question it answers — what number comes next for a
3840 /// `serial` column — a tree already knows, and knew all along.
3841 /// [`Table::next_auto_value`] read every row instead:
3842 ///
3843 /// ```text
3844 /// rows in the table one INSERT PostgreSQL 18
3845 /// 1,000 1.831 ms 1.245
3846 /// 10,000 1.814 1.289
3847 /// 50,000 2.703 1.386
3848 /// 200,000 3.666 1.375
3849 /// ```
3850 ///
3851 /// Theirs is flat because a sequence is a counter. Ours grew with
3852 /// the table, so an ingest workload got slower the longer it ran.
3853 ///
3854 /// A dead row version's key is still in the tree, so this can be
3855 /// HIGHER than the maximum over live rows. That is the safe
3856 /// direction — it hands out a value no row has ever held — and it
3857 /// is the direction PostgreSQL goes too, which never reuses a
3858 /// number a deleted row was given.
3859 ///
3860 /// `None` = no B-tree, or its keys are not integers, and the caller
3861 /// falls back to the scan.
3862 pub fn max_int_key(&self) -> Option<i64> {
3863 let IndexKind::BTree(map) = &self.kind else {
3864 return None;
3865 };
3866 match map.iter_rev().next()? {
3867 (IndexKey::Int(n), _) => Some(*n),
3868 _ => None,
3869 }
3870 }
3871
3872 fn new_btree(name: String, column_position: usize) -> Self {
3873 Self {
3874 name,
3875 column_position,
3876 kind: IndexKind::BTree(PersistentBTreeMap::new()),
3877 included_columns: Vec::new(),
3878 constraint_internal: false,
3879 constraint_backing: false,
3880 partial_predicate: None,
3881 expression: None,
3882 is_unique: false,
3883 nulls_not_distinct: false,
3884 descending: false,
3885 nulls_first: None,
3886 collation: None,
3887 extra_column_positions: Vec::new(),
3888 extra_orders: Vec::new(),
3889 }
3890 }
3891
3892 /// v7.38.1 (L12) — a real multi-column B-tree shell. The caller
3893 /// sets `extra_column_positions` before the first row enters; the
3894 /// key arity is `1 + extras` from then on.
3895 fn new_btree_multi(name: String, column_position: usize) -> Self {
3896 Self {
3897 kind: IndexKind::BTreeMulti(PersistentBTreeMap::new()),
3898 ..Self::new_btree(name, column_position)
3899 }
3900 }
3901
3902 /// v7.38.1 (L12) — the composite key this row takes in a
3903 /// [`IndexKind::BTreeMulti`] index. NULL components key as
3904 /// [`IndexKey::Null`] so prefix probes still find the row; `None`
3905 /// only when a non-null component produces no key, which creation's
3906 /// component-type gate makes unreachable for well-formed indexes.
3907 pub fn multi_key_for_row(&self, values: &[Value<'_>]) -> Option<alloc::boxed::Box<[IndexKey]>> {
3908 compose_multi_key(values, self.column_position, &self.extra_column_positions)
3909 }
3910
3911 fn new_nsw(name: String, column_position: usize, m: usize) -> Self {
3912 Self {
3913 name,
3914 column_position,
3915 kind: IndexKind::Nsw(NswGraph::new(m)),
3916 included_columns: Vec::new(),
3917 constraint_internal: false,
3918 constraint_backing: false,
3919 partial_predicate: None,
3920 expression: None,
3921 is_unique: false,
3922 nulls_not_distinct: false,
3923 descending: false,
3924 nulls_first: None,
3925 collation: None,
3926 extra_column_positions: Vec::new(),
3927 extra_orders: Vec::new(),
3928 }
3929 }
3930
3931 /// v6.7.1 — BRIN index constructor. BRIN carries no in-memory
3932 /// data; the `column_type` snapshot is used by the segment
3933 /// encoder + planner for type-checking range predicates.
3934 fn new_brin(name: String, column_position: usize, column_type: DataType) -> Self {
3935 Self {
3936 name,
3937 column_position,
3938 kind: IndexKind::Brin {
3939 column_type,
3940 summaries: alloc::vec::Vec::new(),
3941 },
3942 included_columns: Vec::new(),
3943 constraint_internal: false,
3944 constraint_backing: false,
3945 partial_predicate: None,
3946 expression: None,
3947 is_unique: false,
3948 nulls_not_distinct: false,
3949 descending: false,
3950 nulls_first: None,
3951 collation: None,
3952 extra_column_positions: Vec::new(),
3953 extra_orders: Vec::new(),
3954 }
3955 }
3956
3957 /// v7.12.3 — GIN inverted-index constructor. Empty posting-list
3958 /// map; caller (typically [`Table::add_gin_index`] or
3959 /// [`Table::restore_gin_index`]) populates it from existing rows
3960 /// or from a deserialised snapshot.
3961 fn new_gin(name: String, column_position: usize) -> Self {
3962 Self {
3963 name,
3964 column_position,
3965 kind: IndexKind::Gin(PersistentBTreeMap::new()),
3966 included_columns: Vec::new(),
3967 constraint_internal: false,
3968 constraint_backing: false,
3969 partial_predicate: None,
3970 expression: None,
3971 is_unique: false,
3972 nulls_not_distinct: false,
3973 descending: false,
3974 nulls_first: None,
3975 collation: None,
3976 extra_column_positions: Vec::new(),
3977 extra_orders: Vec::new(),
3978 }
3979 }
3980
3981 /// v7.15.0 — `gin_trgm_ops`-flavoured GIN constructor. Same
3982 /// shape as `new_gin` but the posting-list keys are 3-byte
3983 /// trigram shingles (`pg_trgm`-compatible) and the column
3984 /// type is `TEXT` / `VARCHAR` (not `TSVECTOR`).
3985 fn new_gin_trgm(name: String, column_position: usize) -> Self {
3986 Self {
3987 name,
3988 column_position,
3989 kind: IndexKind::GinTrgm(PersistentBTreeMap::new()),
3990 included_columns: Vec::new(),
3991 constraint_internal: false,
3992 constraint_backing: false,
3993 partial_predicate: None,
3994 expression: None,
3995 is_unique: false,
3996 nulls_not_distinct: false,
3997 descending: false,
3998 nulls_first: None,
3999 collation: None,
4000 extra_column_positions: Vec::new(),
4001 extra_orders: Vec::new(),
4002 }
4003 }
4004
4005 /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN constructor.
4006 /// Same shape as `new_gin_trgm` but the posting-list keys
4007 /// are lower-cased word lexemes (`to_tsvector('simple', col)`
4008 /// equivalent) instead of trigrams, and the column type is
4009 /// `TEXT` / `VARCHAR` (not `TSVECTOR`).
4010 fn new_gin_fulltext(name: String, column_position: usize) -> Self {
4011 Self {
4012 name,
4013 column_position,
4014 kind: IndexKind::GinFulltext(PersistentBTreeMap::new()),
4015 included_columns: Vec::new(),
4016 constraint_internal: false,
4017 constraint_backing: false,
4018 partial_predicate: None,
4019 expression: None,
4020 is_unique: false,
4021 nulls_not_distinct: false,
4022 descending: false,
4023 nulls_first: None,
4024 collation: None,
4025 extra_column_positions: Vec::new(),
4026 extra_orders: Vec::new(),
4027 }
4028 }
4029
4030 /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN constructor. Same
4031 /// shape as the other GIN-family indexes; posting-list keys
4032 /// are the canonical `(path, leaf)` tokens emitted by
4033 /// `crate::jsonb_gin::extract_tokens`. Maintains posting
4034 /// lists from `Value::Json` cells(JSONB is a synonym for the
4035 /// same in-memory string-backed Value).
4036 fn new_gin_jsonb(name: String, column_position: usize) -> Self {
4037 Self {
4038 name,
4039 column_position,
4040 kind: IndexKind::GinJsonb(PersistentBTreeMap::new()),
4041 included_columns: Vec::new(),
4042 constraint_internal: false,
4043 constraint_backing: false,
4044 partial_predicate: None,
4045 expression: None,
4046 is_unique: false,
4047 nulls_not_distinct: false,
4048 descending: false,
4049 nulls_first: None,
4050 collation: None,
4051 extra_column_positions: Vec::new(),
4052 extra_orders: Vec::new(),
4053 }
4054 }
4055
4056 /// v7.34.4 — descending-order iterator over `(IndexKey, locators)`
4057 /// pairs for a BTree index, with O(log N) descent to the rightmost
4058 /// leaf and lazy emission thereafter. Returns an empty iterator
4059 /// for non-BTree index kinds — callers handle both uniformly.
4060 /// Used by the ORDER BY `<indexed col>` DESC + LIMIT N executor
4061 /// path: walking only the first N matches off the rightmost leaf
4062 /// avoids the per-row materialisation + partial-sort cost on
4063 /// large tables (mailrs `content_worker` at 250 k rows).
4064 pub fn iter_desc(
4065 &self,
4066 ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
4067 {
4068 match &self.kind {
4069 IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter_rev()),
4070 // v7.38.1 (L12) — projecting the leading component of a
4071 // composite key preserves order: keys sort by the whole
4072 // tuple, so the leading component is non-increasing here
4073 // (non-decreasing in iter_asc), exactly what an ORDER BY
4074 // on the leading column needs.
4075 IndexKind::BTreeMulti(m) => {
4076 alloc::boxed::Box::new(m.iter_rev().map(|(k, l)| (&k[0], l)))
4077 }
4078 IndexKind::Nsw(_)
4079 | IndexKind::Brin { .. }
4080 | IndexKind::Gin(_)
4081 | IndexKind::GinTrgm(_)
4082 | IndexKind::GinFulltext(_)
4083 | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
4084 }
4085 }
4086
4087 /// v7.34.4 — ascending-order iterator over `(IndexKey, locators)`
4088 /// pairs. Mirror of `iter_desc` for ORDER BY ... ASC + LIMIT N.
4089 pub fn iter_asc(
4090 &self,
4091 ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
4092 {
4093 match &self.kind {
4094 IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter()),
4095 // v7.38.1 (L12) — see iter_desc: the leading component of
4096 // a tuple-sorted walk is itself in order.
4097 IndexKind::BTreeMulti(m) => alloc::boxed::Box::new(m.iter().map(|(k, l)| (&k[0], l))),
4098 IndexKind::Nsw(_)
4099 | IndexKind::Brin { .. }
4100 | IndexKind::Gin(_)
4101 | IndexKind::GinTrgm(_)
4102 | IndexKind::GinFulltext(_)
4103 | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
4104 }
4105 }
4106
4107 /// Look up the locators stored under `key` (B-tree only). Returns
4108 /// an empty slice when the key is absent or the index isn't a
4109 /// BTree — callers can treat both cases uniformly.
4110 ///
4111 /// v5.1: return type widened from `&[usize]` to `&[RowLocator]`.
4112 /// Pre-v5.2 callers can read the slice and `.as_hot().unwrap()`
4113 /// each entry (no `Cold` variants exist until the freezer lands);
4114 /// post-v5.2 callers dispatch hot vs. cold per locator.
4115 pub fn lookup_eq(&self, key: &IndexKey) -> &crate::posting::PostingList {
4116 match &self.kind {
4117 IndexKind::BTree(m) => m.get(key).map_or(&EMPTY_POSTINGS, |l| l),
4118 // BRIN / NSW / GIN / trigram-GIN / fulltext-GIN have
4119 // no IndexKey-keyed map; lookup is a no-op. GIN uses
4120 // [`Index::gin_lookup_word`] instead.
4121 IndexKind::Nsw(_)
4122 | IndexKind::Brin { .. }
4123 | IndexKind::Gin(_)
4124 | IndexKind::GinTrgm(_)
4125 | IndexKind::GinFulltext(_)
4126 | IndexKind::GinJsonb(_)
4127 | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4128 }
4129 }
4130
4131 /// v7.37.43 (INSUBQ B-2) — specialised lookup for integer-PK probes.
4132 /// `try_count_star_pk_in_subquery_fast` already holds an `i64` (the
4133 /// inner survivor key); skip the `IndexKey::from_value` enum-dispatch
4134 /// trip and build the key inline. ~20 ns × N_survivors saved on
4135 /// the INSUBQ hot loop.
4136 #[inline]
4137 pub fn lookup_eq_i64(&self, n: i64) -> &crate::posting::PostingList {
4138 match &self.kind {
4139 IndexKind::BTree(m) => m.get(&IndexKey::Int(n)).map_or(&EMPTY_POSTINGS, |l| l),
4140 IndexKind::Nsw(_)
4141 | IndexKind::Brin { .. }
4142 | IndexKind::Gin(_)
4143 | IndexKind::GinTrgm(_)
4144 | IndexKind::GinFulltext(_)
4145 | IndexKind::GinJsonb(_)
4146 | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4147 }
4148 }
4149
4150 /// v7.38 (perf, index range scan) — flatten the row locators for every key
4151 /// in `[lo, hi]` (bounds per `core::ops::Bound`) via the BTree's `O(log N +
4152 /// k)` range walk. Returns `None` once more than `cap` locators accumulate
4153 /// — a "this range isn't selective enough, seq-scan instead" signal that
4154 /// stops a wide range from materialising a near-full table's worth of rows
4155 /// through the index. BTree only (other kinds → None).
4156 pub fn lookup_range_capped(
4157 &self,
4158 lo: core::ops::Bound<&IndexKey>,
4159 hi: core::ops::Bound<&IndexKey>,
4160 cap: usize,
4161 ) -> Option<Vec<RowLocator>> {
4162 self.lookup_range_capped_by(lo, hi, cap, |_| true)
4163 }
4164
4165 /// v7.39 (round 490) — the same range walk, but the caller decides
4166 /// which locators are worth carrying, and the cap counts only those.
4167 ///
4168 /// A BTree index holds one locator per row VERSION. On a churned table
4169 /// the dead versions are still in there: round 490 measured a
4170 /// 1000-row range handing back 61 000 locators after 60
4171 /// delete-and-reinsert cycles with the background vacuum switched off.
4172 /// Every caller then dropped the dead ones — the mutation paths and the
4173 /// SELECT range path all test `is_row_visible` and `continue` — but only
4174 /// after they had been collected into a `Vec`, sorted, and walked.
4175 ///
4176 /// Handing the predicate down means the walk keeps ~1000, and the cap
4177 /// (which exists so an index walk never costs more than the scan it
4178 /// replaces) is once again measured in rows a caller will actually look
4179 /// at. Round 461 had to add the dead count to the budget to stop the
4180 /// seek being refused outright; with the filter here that compensation
4181 /// is no longer needed.
4182 pub fn lookup_range_capped_by(
4183 &self,
4184 lo: core::ops::Bound<&IndexKey>,
4185 hi: core::ops::Bound<&IndexKey>,
4186 cap: usize,
4187 keep: impl Fn(RowLocator) -> bool,
4188 ) -> Option<Vec<RowLocator>> {
4189 match &self.kind {
4190 IndexKind::BTree(m) => {
4191 let mut out: Vec<RowLocator> = Vec::new();
4192 for (_, locs) in m.range(lo, hi) {
4193 out.extend(locs.iter().copied().filter(|l| keep(*l)));
4194 if out.len() > cap {
4195 return None;
4196 }
4197 }
4198 Some(out)
4199 }
4200 IndexKind::Nsw(_)
4201 | IndexKind::Brin { .. }
4202 | IndexKind::Gin(_)
4203 | IndexKind::GinTrgm(_)
4204 | IndexKind::GinFulltext(_)
4205 | IndexKind::GinJsonb(_)
4206 | IndexKind::BTreeMulti(_) => None,
4207 }
4208 }
4209
4210 /// v7.38.1 (L12) — full-tuple point lookup on a [`IndexKind::BTreeMulti`]
4211 /// index. `key` must carry exactly as many components as the index
4212 /// has columns; anything else (including a probe against a
4213 /// non-multi index) finds nothing, and "nothing" here is safe
4214 /// because the caller falls back to a scan, never to an answer.
4215 pub fn lookup_eq_multi(&self, key: &[IndexKey]) -> &crate::posting::PostingList {
4216 match &self.kind {
4217 IndexKind::BTreeMulti(m) if key.len() == 1 + self.extra_column_positions.len() => {
4218 m.get_by(key).map_or(&EMPTY_POSTINGS, |l| l)
4219 }
4220 _ => &EMPTY_POSTINGS,
4221 }
4222 }
4223
4224 /// v7.38.1 (L12) — locators for every key whose leading components
4225 /// equal `prefix`, on a [`IndexKind::BTreeMulti`] index. Slice
4226 /// ordering keeps a prefix's keys contiguous, so this is one
4227 /// descent to `[prefix]` and a walk that stops at the first key
4228 /// leaving the prefix. Same cap/keep contract as
4229 /// [`Index::lookup_range_capped_by`]: `None` = not selective
4230 /// enough (or not a multi index), fall back.
4231 /// v7.39.13 — the keys under a composite index's PREFIX, in the
4232 /// tree's order, lazily.
4233 ///
4234 /// `WHERE project_id = ? ORDER BY received_at DESC LIMIT 20` behind
4235 /// an index on `(project_id, received_at)` is one seek and twenty
4236 /// steps. SPG had no way to express it: `lookup_prefix_capped_by`
4237 /// materialises the whole group and caps, and `iter_desc` starts at
4238 /// the tree's own end, so the walk would cross every later project
4239 /// first. Sentori measured that shape as `Seq Scan -> Sort` against
4240 /// PostgreSQL's `Limit -> Index Scan`.
4241 ///
4242 /// The bound is a prefix, not a key: a tuple `[p]` sorts BELOW every
4243 /// longer tuple starting with `p`, so no single key names the
4244 /// group's top. `range_rev_by` takes the two predicates instead.
4245 ///
4246 /// `None` for anything that is not a composite B-tree, or a prefix
4247 /// longer than the key.
4248 pub fn iter_prefix_desc<'a>(
4249 &'a self,
4250 prefix: &'a [IndexKey],
4251 ) -> Option<impl Iterator<Item = (&'a [IndexKey], &'a crate::posting::PostingList)> + 'a> {
4252 let IndexKind::BTreeMulti(m) = &self.kind else {
4253 return None;
4254 };
4255 if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4256 return None;
4257 }
4258 let p = prefix.len();
4259 Some(
4260 m.range_rev_by(
4261 move |k: &alloc::boxed::Box<[IndexKey]>| k[..core::cmp::min(k.len(), p)] > *prefix,
4262 move |k: &alloc::boxed::Box<[IndexKey]>| k[..core::cmp::min(k.len(), p)] < *prefix,
4263 )
4264 .map(|(k, v)| (&k[..], v)),
4265 )
4266 }
4267
4268 /// The ascending mirror of [`Self::iter_prefix_desc`]. Forward
4269 /// `range` can express this one with a key bound — every tuple in
4270 /// the group sorts at or after the prefix tuple itself — so it
4271 /// takes that road and stops on the same predicate.
4272 pub fn iter_prefix_asc<'a>(
4273 &'a self,
4274 prefix: &'a [IndexKey],
4275 ) -> Option<impl Iterator<Item = (&'a [IndexKey], &'a crate::posting::PostingList)> + 'a> {
4276 let IndexKind::BTreeMulti(m) = &self.kind else {
4277 return None;
4278 };
4279 if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4280 return None;
4281 }
4282 let p = prefix.len();
4283 let lo: alloc::boxed::Box<[IndexKey]> = prefix.to_vec().into_boxed_slice();
4284 Some(
4285 m.range(core::ops::Bound::Included(&lo), core::ops::Bound::Unbounded)
4286 .take_while(move |(k, _)| k.len() >= p && k[..p] == *prefix)
4287 .map(|(k, v)| (&k[..], v))
4288 .collect::<Vec<_>>()
4289 .into_iter(),
4290 )
4291 }
4292
4293 pub fn lookup_prefix_capped_by(
4294 &self,
4295 prefix: &[IndexKey],
4296 cap: usize,
4297 keep: impl Fn(RowLocator) -> bool,
4298 ) -> Option<Vec<RowLocator>> {
4299 let IndexKind::BTreeMulti(m) = &self.kind else {
4300 return None;
4301 };
4302 if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4303 return None;
4304 }
4305 let lo: alloc::boxed::Box<[IndexKey]> = prefix.to_vec().into_boxed_slice();
4306 let mut out: Vec<RowLocator> = Vec::new();
4307 for (k, locs) in m.range(core::ops::Bound::Included(&lo), core::ops::Bound::Unbounded) {
4308 if k.len() < prefix.len() || k[..prefix.len()] != *prefix {
4309 break;
4310 }
4311 out.extend(locs.iter().copied().filter(|l| keep(*l)));
4312 if out.len() > cap {
4313 return None;
4314 }
4315 }
4316 Some(out)
4317 }
4318
4319 /// v7.38.19 — a RANGE on the composite tree's leading column.
4320 ///
4321 /// Tuples order lexicographically, so every key whose first
4322 /// component is `x` sorts at or after the one-element tuple `[x]`
4323 /// and before `[x']` for any larger `x'`. That makes a leading-
4324 /// column range one contiguous run, walked exactly like the
4325 /// single-column range walk — the only difference is that the
4326 /// comparison is against `k[0]` rather than the whole key.
4327 ///
4328 /// Without this, `WHERE project_id > 90` on a table whose only
4329 /// index was `(project_id, kind)` read every row: 4.067 ms against
4330 /// PostgreSQL 18's 0.220, on a predicate matching nothing. The same
4331 /// query with a single-column index took 0.165, which is what says
4332 /// the range was never the problem.
4333 pub fn lookup_leading_range_capped_by(
4334 &self,
4335 lo: core::ops::Bound<&IndexKey>,
4336 hi: core::ops::Bound<&IndexKey>,
4337 cap: usize,
4338 keep: impl Fn(RowLocator) -> bool,
4339 ) -> Option<Vec<RowLocator>> {
4340 let IndexKind::BTreeMulti(m) = &self.kind else {
4341 return None;
4342 };
4343 // The start of the run. An EXCLUDED lower bound cannot be
4344 // handed to the map as-is: `[x]` sorts BEFORE `[x, y]`, so
4345 // excluding `[x]` would still admit every tuple that begins
4346 // with `x`. Start at `[x]` included and drop those tuples by
4347 // the per-key test below, which compares the component.
4348 let lo_key: Option<alloc::boxed::Box<[IndexKey]>> = match lo {
4349 core::ops::Bound::Included(k) | core::ops::Bound::Excluded(k) => {
4350 Some(alloc::vec![k.clone()].into_boxed_slice())
4351 }
4352 core::ops::Bound::Unbounded => None,
4353 };
4354 let start = match &lo_key {
4355 Some(k) => core::ops::Bound::Included(k),
4356 None => core::ops::Bound::Unbounded,
4357 };
4358 let mut out: Vec<RowLocator> = Vec::new();
4359 for (k, locs) in m.range(start, core::ops::Bound::Unbounded) {
4360 let Some(first) = k.first() else { continue };
4361 match lo {
4362 core::ops::Bound::Excluded(b) if first == b => continue,
4363 _ => {}
4364 }
4365 match hi {
4366 core::ops::Bound::Included(b) if first > b => break,
4367 core::ops::Bound::Excluded(b) if first >= b => break,
4368 _ => {}
4369 }
4370 out.extend(locs.iter().copied().filter(|l| keep(*l)));
4371 if out.len() > cap {
4372 return None;
4373 }
4374 }
4375 Some(out)
4376 }
4377
4378 /// v7.39 (round 560) — the index range as (key, locator) pairs.
4379 ///
4380 /// `lookup_range_capped_by` throws the KEY away and returns only
4381 /// locators, so a query whose projection is exactly the indexed
4382 /// column still goes to the row store for a value the walk already
4383 /// had in hand — paying per row for something the index knows.
4384 ///
4385 /// Uncapped on purpose: an index-only walk touches no row, so the
4386 /// selectivity ceiling that keeps a seek from being worse than the
4387 /// scan it replaces does not apply to it.
4388 ///
4389 /// v7.39 (round 562) — and it does not collect, either. This
4390 /// returned a `Vec<(IndexKey, RowLocator)>`: for a 100k-row range,
4391 /// 100k key clones into a `Vec::new()` that doubles its way up to
4392 /// several MB, all to be walked once and dropped. A profile of the
4393 /// server serving that query put 20% of the connection thread's CPU
4394 /// on the collect alone, with another 18% in the allocator beside
4395 /// it. The caller consumes the pairs in order and needs the key
4396 /// only by reference, so it can have the walk itself.
4397 pub fn range_keyed(
4398 &self,
4399 lo: core::ops::Bound<&IndexKey>,
4400 hi: core::ops::Bound<&IndexKey>,
4401 ) -> Option<impl Iterator<Item = (&IndexKey, RowLocator)> + '_> {
4402 match &self.kind {
4403 IndexKind::BTree(m) => Some(
4404 m.range(lo, hi)
4405 .flat_map(|(k, locs)| locs.iter().map(move |l| (k, *l))),
4406 ),
4407 IndexKind::Nsw(_)
4408 | IndexKind::Brin { .. }
4409 | IndexKind::Gin(_)
4410 | IndexKind::GinTrgm(_)
4411 | IndexKind::GinFulltext(_)
4412 | IndexKind::GinJsonb(_)
4413 | IndexKind::BTreeMulti(_) => None,
4414 }
4415 }
4416
4417 /// v7.12.3 — GIN posting-list lookup. Returns the row locators
4418 /// whose `tsvector` cell contains `word`. Empty when the word is
4419 /// absent from the index or this isn't a GIN index.
4420 pub fn gin_lookup_word(&self, word: &str) -> &crate::posting::PostingList {
4421 match &self.kind {
4422 // v7.17.0 Phase 2.2 — fulltext-GIN shares the same
4423 // lexeme-keyed posting list shape as the
4424 // tsvector-typed GIN, so the same lookup applies.
4425 IndexKind::Gin(m) | IndexKind::GinFulltext(m) => {
4426 m.get(&String::from(word)).map_or(&EMPTY_POSTINGS, |l| l)
4427 }
4428 IndexKind::BTree(_)
4429 | IndexKind::Nsw(_)
4430 | IndexKind::Brin { .. }
4431 | IndexKind::GinTrgm(_)
4432 | IndexKind::GinJsonb(_)
4433 | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4434 }
4435 }
4436
4437 /// v7.15.0 — trigram-GIN posting-list lookup. Returns the row
4438 /// locators whose indexed `TEXT` cell contains the trigram
4439 /// `tri`. Empty when the trigram is absent or this isn't a
4440 /// trigram-GIN index.
4441 pub fn gin_trgm_lookup(&self, tri: &str) -> &crate::posting::PostingList {
4442 match &self.kind {
4443 IndexKind::GinTrgm(m) => m.get(&String::from(tri)).map_or(&EMPTY_POSTINGS, |l| l),
4444 IndexKind::BTree(_)
4445 | IndexKind::Nsw(_)
4446 | IndexKind::Brin { .. }
4447 | IndexKind::Gin(_)
4448 | IndexKind::GinFulltext(_)
4449 | IndexKind::GinJsonb(_)
4450 | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4451 }
4452 }
4453
4454 /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN posting-list lookup.
4455 /// Returns the row locators whose indexed JSONB cell carries
4456 /// the canonical `token`(see [`crate::jsonb_gin::extract_tokens`]).
4457 /// Empty when the token is absent or this isn't a JSONB-GIN
4458 /// index. Planners drive `<col> @> <jsonb_literal>` through here.
4459 pub fn gin_jsonb_lookup(&self, token: &str) -> &crate::posting::PostingList {
4460 match &self.kind {
4461 IndexKind::GinJsonb(m) => m.get(&String::from(token)).map_or(&EMPTY_POSTINGS, |l| l),
4462 IndexKind::BTree(_)
4463 | IndexKind::Nsw(_)
4464 | IndexKind::Brin { .. }
4465 | IndexKind::Gin(_)
4466 | IndexKind::GinTrgm(_)
4467 | IndexKind::GinFulltext(_)
4468 | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4469 }
4470 }
4471
4472 /// Borrow the NSW graph (if this is an NSW index). Callers that need
4473 /// the graph for a kNN search go through here.
4474 pub const fn nsw(&self) -> Option<&NswGraph> {
4475 match &self.kind {
4476 IndexKind::Nsw(g) => Some(g),
4477 IndexKind::BTree(_)
4478 | IndexKind::Brin { .. }
4479 | IndexKind::Gin(_)
4480 | IndexKind::GinTrgm(_)
4481 | IndexKind::GinFulltext(_)
4482 | IndexKind::GinJsonb(_)
4483 | IndexKind::BTreeMulti(_) => None,
4484 }
4485 }
4486
4487 /// v6.7.1 — true when this index is a BRIN (block range) index.
4488 /// Used by the segment encoder to opt into BRIN sidecar emission
4489 /// at freeze time, and by the planner to opt into page-skipping
4490 /// on range predicates.
4491 pub const fn is_brin(&self) -> bool {
4492 matches!(self.kind, IndexKind::Brin { .. })
4493 }
4494
4495 /// v7.15.0 — true when this index is a trigram GIN
4496 /// (`gin_trgm_ops`-flavoured). Used by the LIKE planner to
4497 /// opt into trigram acceleration.
4498 pub const fn is_gin_trgm(&self) -> bool {
4499 matches!(self.kind, IndexKind::GinTrgm(_))
4500 }
4501
4502 /// v7.12.3 — true when this index is a GIN inverted index.
4503 /// Used by the planner to opt into posting-list acceleration on
4504 /// `WHERE col @@ tsquery` predicates.
4505 pub const fn is_gin(&self) -> bool {
4506 matches!(self.kind, IndexKind::Gin(_))
4507 }
4508
4509 /// v7.17.0 Phase 2.2 — true when this index is a fulltext
4510 /// GIN over a TEXT / VARCHAR column (MySQL `FULLTEXT KEY`
4511 /// surface). Used by the planner to opt the FULLTEXT-indexed
4512 /// column into MATCH AGAINST acceleration.
4513 pub const fn is_gin_fulltext(&self) -> bool {
4514 matches!(self.kind, IndexKind::GinFulltext(_))
4515 }
4516
4517 /// v7.37.8(sentori Epic 5 P2)— true when this index is a
4518 /// real JSONB-GIN(posting-list backed). Used by the planner
4519 /// to opt `<col> @> <jsonb_literal>` into posting-list seek.
4520 pub const fn is_gin_jsonb(&self) -> bool {
4521 matches!(self.kind, IndexKind::GinJsonb(_))
4522 }
4523}
4524
4525/// In-memory table: schema + a persistent row vector + secondary indices.
4526///
4527/// v4.39: `rows` is a [`PersistentVec`] (Bitmapped Vector Trie, 32-way) so
4528/// `Table::clone()` is `O(1)` — the whole reason for v4.39's existence is
4529/// to make `Catalog::clone()` cheap inside the v4.34 auto-commit wrap.
4530///
4531/// v5.2.1: `hot_bytes` tracks the encoded byte size of every row currently
4532/// in [`Self::rows`], summed over rows. Updated incrementally by `insert`
4533/// (+= encoded row size), `delete_rows` (-= removed rows' encoded sizes),
4534/// and `update_row` (-= old size, += new size). The value is what the
4535/// v5.2 freezer reads to decide when to demote cold rows — when the
4536/// catalog-wide sum crosses `SPG_HOT_TIER_BYTES` (default 4 GiB) the
4537/// freezer thread wakes. v5.2.1 ships measurement only; the freezer
4538/// itself lands in v5.2.2. Stored as `u64` so a single field clone in
4539/// `Catalog::clone` stays at the O(1) invariant v4.39 built.
4540/// v7.34 (crash-recovery P0 #2) — one row-level physical redo record.
4541/// Row-level redo replaces statement-based WAL replay (which re-executes
4542/// each SQL through the full engine — O(records × catalog_rows), the
4543/// superlinear recovery hang root-caused on the mailrs crash-recovery
4544/// P0). A `RowChange` is the exact storage mutation the engine applied
4545/// (`Table::insert` / `update_row` / `delete_rows`); replaying it on a
4546/// catalog restored from the matching checkpoint reproduces the state
4547/// WITHOUT re-validating uniqueness/FK/parse/plan — O(changed rows).
4548///
4549/// Positions are physical, not key-based: `serialize`/`deserialize`
4550/// preserve row order exactly (rows written + read back in `self.rows`
4551/// order) and the mutation ops are deterministic, so the same op sequence
4552/// replayed from the same checkpoint reproduces the same positions. This
4553/// matches PostgreSQL's physical redo and supports tables with no primary
4554/// key. (Caveat handled at replay integration: a post-checkpoint cold-tier
4555/// freeze shifts hot positions and must itself be logged or fenced by a
4556/// checkpoint — see `row-level-redo-design`.)
4557/// ## v7.37.15 (Epic W slice 1) — additive MVCC identity metadata
4558///
4559/// Each variant now also carries, additively, the stable
4560/// [`RowId`](row_header::RowId) of the affected row(s) and the
4561/// **writer version** (`xmin` for an insert, `xmax` for a
4562/// delete/update). This is the codec foundation for making
4563/// in-place MVCC tombstones durable across crash/upgrade recovery.
4564///
4565/// Two important properties for the durability path:
4566///
4567/// 1. **Replay resolution is UNCHANGED.** `apply_redo_run_on_table`
4568/// still resolves every change by physical `pos`/`positions`
4569/// exactly as before. The new metadata is *carried but unused*
4570/// by replay in this slice; resolving-by-`RowId` and
4571/// header-preserving replay are later slices.
4572/// 2. **Backward compatibility.** A redo payload written by
4573/// pre-Epic-W code carries no metadata; [`decode_redo_log`]
4574/// fills `rowid`/`rowids` with [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED)
4575/// (empty for `Delete`) and `writer_version` with `0`. See the
4576/// codec version gate in [`encode_redo_log`]/[`decode_redo_log`].
4577///
4578/// The `writer_version` is captured as `0` at the storage layer
4579/// (`Table::insert`/`delete_rows`/`update_row` don't have the
4580/// committing `TxId`), then **stamped with the real committing
4581/// version by the engine** after it drains the statement's changes
4582/// (Epic W slice 2 — [`RowChange::set_writer_version`], driven from
4583/// `Engine::writer_version_for_current_stmt`). All changes from one
4584/// statement share the one version. Replay still resolves by
4585/// physical position and does not read `writer_version` — that is a
4586/// later slice (header-preserving replay).
4587#[derive(Debug, Clone, PartialEq)]
4588pub enum RowChange {
4589 /// Append `row` to `table`.
4590 Insert {
4591 table: String,
4592 row: Row<'static>,
4593 /// Epic W: stable id the appended row will receive.
4594 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
4595 /// decoded from a pre-Epic-W redo payload.
4596 rowid: row_header::RowId,
4597 /// Epic W: writer version (`xmin`). `0` until the writing
4598 /// `TxId` is threaded to the storage layer (later slice).
4599 writer_version: u64,
4600 },
4601 /// Replace the row at physical `pos` in `table` with `new_row`.
4602 Update {
4603 table: String,
4604 pos: usize,
4605 new_row: Vec<Value<'static>>,
4606 /// Epic W: stable id of the row at `pos`.
4607 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
4608 /// decoded from a pre-Epic-W redo payload.
4609 rowid: row_header::RowId,
4610 /// Epic W: writer version (`xmax` of the superseded tuple).
4611 /// `0` until the writing `TxId` is threaded (later slice).
4612 writer_version: u64,
4613 },
4614 /// Remove the rows at the given physical `positions` from `table`.
4615 Delete {
4616 table: String,
4617 positions: Vec<usize>,
4618 /// Epic W: stable ids parallel to `positions` (same length,
4619 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) for an
4620 /// out-of-bounds input position). **Empty** when decoded from
4621 /// a pre-Epic-W redo payload (no metadata was recorded).
4622 rowids: Vec<row_header::RowId>,
4623 /// Epic W: writer version (`xmax`). `0` until the writing
4624 /// `TxId` is threaded to the storage layer (later slice).
4625 writer_version: u64,
4626 },
4627 /// v7.37.15 (Epic W durable-tombstone slice) — an **in-place MVCC
4628 /// delete**: the row(s) named by `rowids` are NOT physically
4629 /// removed; their header `xmax` is stamped so newer snapshots stop
4630 /// seeing them (vacuum reclaims later). This is the redo shape of
4631 /// the gate-on (`SPG_MVCC_INPLACE`) DELETE / UPDATE-old-version /
4632 /// ON-CONFLICT paths, which call [`Table::mark_row_deleted`]
4633 /// instead of `delete_rows`.
4634 ///
4635 /// Unlike `Delete`, the target is named by **stable `RowId`**, not
4636 /// physical position: a tombstone keeps the slot, so position would
4637 /// be ambiguous after later compaction, and the header-preserving
4638 /// replay must re-find the exact row the writer tombstoned. On
4639 /// replay the id is matched against the ids the same redo run
4640 /// produced (an `Insert`'s `rowid`, or the table's ids snapshotted
4641 /// at run start); an id that cannot be resolved is skipped and
4642 /// counted (see `apply_redo_run_on_table`) — this is the documented
4643 /// cross-checkpoint limitation until the V6 envelope persists ids.
4644 Tombstone {
4645 table: String,
4646 /// Stable ids of the tombstoned rows (from `self.rowids()[pos]`
4647 /// at capture). Never empty for a recorded tombstone.
4648 rowids: Vec<row_header::RowId>,
4649 /// The version stamped into each target row's header `xmax`
4650 /// (the deleting statement's writer version).
4651 xmax: u64,
4652 },
4653}
4654
4655impl RowChange {
4656 /// v7.39 (round 736) — which table this change applies to.
4657 #[must_use]
4658 pub fn table_name(&self) -> &str {
4659 match self {
4660 Self::Insert { table, .. }
4661 | Self::Update { table, .. }
4662 | Self::Delete { table, .. }
4663 | Self::Tombstone { table, .. } => table,
4664 }
4665 }
4666
4667 /// v7.37.15 (Epic W slice 2) — stamp the committing writer
4668 /// version onto this change. Every change drained from a single
4669 /// statement shares one version (the statement's `xmin`/`xmax`),
4670 /// so the engine calls this on each drained change with the value
4671 /// from [`Engine::writer_version_for_current_stmt`]. Additive
4672 /// metadata only: replay still resolves by physical position and
4673 /// does not read `writer_version` (that is a later slice).
4674 pub fn set_writer_version(&mut self, v: u64) {
4675 match self {
4676 RowChange::Insert { writer_version, .. }
4677 | RowChange::Update { writer_version, .. }
4678 | RowChange::Delete { writer_version, .. } => *writer_version = v,
4679 // A tombstone captures `xmax` directly from the deleting
4680 // statement's version at record time (via
4681 // `mark_row_deleted`), so it already equals `v`. Keep the
4682 // "one statement, one version" invariant mechanical by
4683 // asserting agreement in debug builds rather than silently
4684 // overwriting a possibly-different value.
4685 RowChange::Tombstone { xmax, .. } => {
4686 debug_assert_eq!(
4687 *xmax, v,
4688 "tombstone xmax must match the statement writer version"
4689 );
4690 *xmax = v;
4691 }
4692 }
4693 }
4694}
4695
4696/// v7.37.15 (Epic W slice 1) — leading marker byte of the
4697/// metadata-carrying redo layout. A **pre-Epic-W** redo payload leads
4698/// with `FILE_VERSION` (8..=52 today, rising ~1 per release); this
4699/// marker is `0xFF` and can therefore never collide with a real
4700/// `FILE_VERSION`, so [`decode_redo_log`] tells the two layouts apart
4701/// by inspecting the first byte alone. The compile-time assertion
4702/// below makes the "never collide" invariant a hard build gate: if
4703/// `FILE_VERSION` ever climbs toward `0xFF` the build breaks and forces
4704/// a redesign long before an ambiguity could ship.
4705const REDO_META_MARKER: u8 = 0xFF;
4706/// v7.37.15 (Epic W slice 1) — version of the metadata-carrying redo
4707/// layout that follows [`REDO_META_MARKER`]. Bumped when the per-change
4708/// metadata shape changes; an unknown value is a hard decode error.
4709const REDO_META_VERSION: u8 = 1;
4710
4711/// v7.37.15 (Epic W durable-tombstone slice) — process-wide count of
4712/// [`RowChange::Tombstone`] targets that `apply_redo` could NOT resolve
4713/// to a row by `RowId`. A non-zero value is expected only across a
4714/// checkpoint boundary (the table's ids are reassigned on deserialize
4715/// and the V6 envelope does not yet persist them), where a tombstone
4716/// naming a pre-checkpoint row is left visible rather than mis-applied.
4717/// Surfaced for observability; never affects correctness of the resolved
4718/// tombstones. Read via [`unresolved_tombstone_count`].
4719static UNRESOLVED_TOMBSTONES: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
4720
4721/// v7.39 (flip crash-replay P0) — observability read for the replay
4722/// tombstones that could not be resolved to a row (each one is a
4723/// resurrected delete).
4724#[must_use]
4725pub fn unresolved_tombstones() -> u64 {
4726 UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
4727}
4728
4729/// v7.37.15 (Epic W durable-tombstone slice) — read the process-wide
4730/// count of redo tombstones that could not be resolved to a row by
4731/// `RowId` during `apply_redo`. See [`UNRESOLVED_TOMBSTONES`].
4732#[must_use]
4733pub fn unresolved_tombstone_count() -> u64 {
4734 UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
4735}
4736// Provably-unambiguous old/new distinction: the pre-Epic-W layout's
4737// first byte is `FILE_VERSION`, which must stay strictly below the
4738// marker forever.
4739const _: () = assert!(FILE_VERSION < REDO_META_MARKER);
4740
4741/// v7.34 (crash-recovery P0 #2), extended v7.37.15 (Epic W slice 1) —
4742/// encode a row-level redo log to bytes for a WAL record.
4743///
4744/// ## Layout (Epic W metadata-carrying form, always emitted now)
4745///
4746/// `[u8 REDO_META_MARKER=0xFF][u8 REDO_META_VERSION][u8 FILE_VERSION]
4747/// [u32 count]` then per change `[u8 op][str table]` and, per op:
4748/// - `Insert [u32 n][value×n][u64 rowid][u64 writer_version]`
4749/// - `Update [u32 pos][u32 n][value×n][u64 rowid][u64 writer_version]`
4750/// - `Delete [u32 n][u32 pos×n][u64 rowid×n][u64 writer_version]`
4751/// - `Tombstone [u32 n][u64 rowid×n][u64 xmax]` (op byte 3; only ever
4752/// emitted under the metadata-carrying layout — the pre-Epic-W layout
4753/// had no in-place tombstone, so a legacy stream can never carry it)
4754///
4755/// Positions are physical (u32 ≤ 4 G rows). The `FILE_VERSION` byte
4756/// still rides along (now the 3rd byte) so the value codec decodes
4757/// string / BYTEA escapes exactly as before.
4758///
4759/// ## Backward compatibility
4760///
4761/// The **pre-Epic-W** layout was `[u8 FILE_VERSION][u32 count]…` with
4762/// no per-change metadata. [`decode_redo_log`] still decodes that form
4763/// (first byte < `0xFF`) byte-for-byte identically — every WAL file
4764/// written by released code replays unchanged.
4765#[must_use]
4766pub fn encode_redo_log(changes: &[RowChange]) -> Vec<u8> {
4767 let mut out = Vec::new();
4768 out.push(REDO_META_MARKER);
4769 out.push(REDO_META_VERSION);
4770 out.push(FILE_VERSION);
4771 codec::write_u32(&mut out, changes.len() as u32);
4772 let write_values = |out: &mut Vec<u8>, vals: &[Value<'static>]| {
4773 codec::write_u32(out, vals.len() as u32);
4774 for v in vals {
4775 codec::write_value(out, v);
4776 }
4777 };
4778 for change in changes {
4779 match change {
4780 RowChange::Insert {
4781 table,
4782 row,
4783 rowid,
4784 writer_version,
4785 } => {
4786 out.push(0);
4787 codec::write_str(&mut out, table);
4788 write_values(&mut out, &row.values);
4789 codec::write_u64(&mut out, rowid.0);
4790 codec::write_u64(&mut out, *writer_version);
4791 }
4792 RowChange::Update {
4793 table,
4794 pos,
4795 new_row,
4796 rowid,
4797 writer_version,
4798 } => {
4799 out.push(1);
4800 codec::write_str(&mut out, table);
4801 codec::write_u32(&mut out, *pos as u32);
4802 write_values(&mut out, new_row);
4803 codec::write_u64(&mut out, rowid.0);
4804 codec::write_u64(&mut out, *writer_version);
4805 }
4806 RowChange::Delete {
4807 table,
4808 positions,
4809 rowids,
4810 writer_version,
4811 } => {
4812 out.push(2);
4813 codec::write_str(&mut out, table);
4814 codec::write_u32(&mut out, positions.len() as u32);
4815 for p in positions {
4816 codec::write_u32(&mut out, *p as u32);
4817 }
4818 // Epic W: one RowId per position (parallel). Capture
4819 // sites always produce `rowids.len() == positions.len()`;
4820 // this assertion pins that invariant at encode time so a
4821 // mismatch is a loud bug, not a silently short payload.
4822 debug_assert_eq!(
4823 rowids.len(),
4824 positions.len(),
4825 "redo Delete: rowids must be parallel to positions"
4826 );
4827 for rid in rowids {
4828 codec::write_u64(&mut out, rid.0);
4829 }
4830 codec::write_u64(&mut out, *writer_version);
4831 }
4832 RowChange::Tombstone {
4833 table,
4834 rowids,
4835 xmax,
4836 } => {
4837 out.push(3);
4838 codec::write_str(&mut out, table);
4839 codec::write_u32(&mut out, rowids.len() as u32);
4840 for rid in rowids {
4841 codec::write_u64(&mut out, rid.0);
4842 }
4843 codec::write_u64(&mut out, *xmax);
4844 }
4845 }
4846 }
4847 out
4848}
4849
4850/// v7.34, extended v7.37.15 (Epic W slice 1) — decode a row-level redo
4851/// log written by [`encode_redo_log`].
4852///
4853/// Decodes **both** the Epic W metadata-carrying layout (first byte
4854/// `REDO_META_MARKER = 0xFF`) and the pre-Epic-W layout (first byte is
4855/// `FILE_VERSION`, always `< 0xFF`). For the old layout the per-change
4856/// metadata is absent, so `rowid`/`rowids` come back
4857/// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) (empty for
4858/// `Delete`) and `writer_version` comes back `0`.
4859///
4860/// A truncated / corrupt buffer is a hard error — never a panic — the
4861/// embedding layer frames each record with its own length + CRC, so a
4862/// frame that decodes short is corruption, not a torn tail.
4863pub fn decode_redo_log(bytes: &[u8]) -> Result<Vec<RowChange>, StorageError> {
4864 let first = *bytes
4865 .first()
4866 .ok_or_else(|| StorageError::Corrupt("redo log: empty".into()))?;
4867 // Epic W: `0xFF` marker ⇒ metadata-carrying layout; anything else
4868 // is a pre-Epic-W `FILE_VERSION` byte (old layout, no metadata).
4869 let has_meta = first == REDO_META_MARKER;
4870 let (codec_version, header_len) = if has_meta {
4871 let meta_version = *bytes
4872 .get(1)
4873 .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
4874 if meta_version != REDO_META_VERSION {
4875 return Err(StorageError::Corrupt(alloc::format!(
4876 "redo log: unknown metadata version {meta_version}"
4877 )));
4878 }
4879 let file_version = *bytes
4880 .get(2)
4881 .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
4882 // header = [marker][meta_version][file_version]
4883 (file_version, 3usize)
4884 } else {
4885 // Old layout: the first byte IS the FILE_VERSION.
4886 (first, 1usize)
4887 };
4888 let mut cur = codec::Cursor::new(bytes).with_codec_version(codec_version);
4889 for _ in 0..header_len {
4890 cur.read_u8()?;
4891 }
4892 let count = cur.read_u32()? as usize;
4893 let mut read_values =
4894 |cur: &mut codec::Cursor<'_>| -> Result<Vec<Value<'static>>, StorageError> {
4895 let n = cur.read_u32()? as usize;
4896 let mut vals = Vec::with_capacity(n);
4897 for _ in 0..n {
4898 vals.push(cur.read_value()?);
4899 }
4900 Ok(vals)
4901 };
4902 let mut changes = Vec::with_capacity(count);
4903 for _ in 0..count {
4904 let op = cur.read_u8()?;
4905 let table = cur.read_str()?;
4906 let change = match op {
4907 0 => {
4908 let row = Row::new(read_values(&mut cur)?);
4909 let (rowid, writer_version) = if has_meta {
4910 (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
4911 } else {
4912 (row_header::RowId::UNASSIGNED, 0)
4913 };
4914 RowChange::Insert {
4915 table,
4916 row,
4917 rowid,
4918 writer_version,
4919 }
4920 }
4921 1 => {
4922 let pos = cur.read_u32()? as usize;
4923 let new_row = read_values(&mut cur)?;
4924 let (rowid, writer_version) = if has_meta {
4925 (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
4926 } else {
4927 (row_header::RowId::UNASSIGNED, 0)
4928 };
4929 RowChange::Update {
4930 table,
4931 pos,
4932 new_row,
4933 rowid,
4934 writer_version,
4935 }
4936 }
4937 2 => {
4938 let n = cur.read_u32()? as usize;
4939 let mut positions = Vec::with_capacity(n);
4940 for _ in 0..n {
4941 positions.push(cur.read_u32()? as usize);
4942 }
4943 let (rowids, writer_version) = if has_meta {
4944 let mut rowids = Vec::with_capacity(n);
4945 for _ in 0..n {
4946 rowids.push(row_header::RowId(cur.read_u64()?));
4947 }
4948 (rowids, cur.read_u64()?)
4949 } else {
4950 // Old layout carried no RowId metadata.
4951 (Vec::new(), 0)
4952 };
4953 RowChange::Delete {
4954 table,
4955 positions,
4956 rowids,
4957 writer_version,
4958 }
4959 }
4960 // Op 3 is the Epic W in-place tombstone — it only exists in
4961 // the metadata-carrying layout. Guarding on `has_meta` means
4962 // a legacy stream that happens to contain a `3` byte here is
4963 // reported as an unknown op (corruption), never mis-decoded.
4964 3 if has_meta => {
4965 let n = cur.read_u32()? as usize;
4966 let mut rowids = Vec::with_capacity(n);
4967 for _ in 0..n {
4968 rowids.push(row_header::RowId(cur.read_u64()?));
4969 }
4970 let xmax = cur.read_u64()?;
4971 RowChange::Tombstone {
4972 table,
4973 rowids,
4974 xmax,
4975 }
4976 }
4977 other => {
4978 return Err(StorageError::Corrupt(alloc::format!(
4979 "redo log: unknown op {other}"
4980 )));
4981 }
4982 };
4983 changes.push(change);
4984 }
4985 Ok(changes)
4986}
4987
4988/// v7.39 (pg_stat knife B) — per-table scan counters, bumped from
4989/// `&self` read paths. Clone (tx shadow catalogs clone tables) copies
4990/// the current values; the counters are volatile like PG's cumulative
4991/// stats.
4992#[derive(Debug, Default)]
4993pub struct ScanStats {
4994 pub seq_scan: core::sync::atomic::AtomicU64,
4995 pub seq_tup_read: core::sync::atomic::AtomicU64,
4996 pub idx_scan: core::sync::atomic::AtomicU64,
4997 pub idx_tup_fetch: core::sync::atomic::AtomicU64,
4998}
4999
5000impl Clone for ScanStats {
5001 fn clone(&self) -> Self {
5002 use core::sync::atomic::{AtomicU64, Ordering};
5003 Self {
5004 seq_scan: AtomicU64::new(self.seq_scan.load(Ordering::Relaxed)),
5005 seq_tup_read: AtomicU64::new(self.seq_tup_read.load(Ordering::Relaxed)),
5006 idx_scan: AtomicU64::new(self.idx_scan.load(Ordering::Relaxed)),
5007 idx_tup_fetch: AtomicU64::new(self.idx_tup_fetch.load(Ordering::Relaxed)),
5008 }
5009 }
5010}
5011
5012/// v7.39 (round 215) — the lower-bound sort key for a range value, used by
5013/// the range-exclusion index. The bound as an `i128` (unbounded lower =
5014/// `i128::MIN`, sorting first) plus an inclusivity rank (inclusive lower
5015/// sorts before exclusive at the same value, `[3` before `(3`). Returns
5016/// `None` for range kinds whose bound isn't an integer scalar (numrange's
5017/// numeric/bignum), for empty ranges, and for non-range values — the caller
5018/// then keeps the O(n) scan rather than risk an unsound order. Int4/Int8/
5019/// Date/Ts/TsTz all reduce here (tstzrange bounds are `Value::Timestamp`).
5020/// Maintenance (index build) and query (overlap probe) MUST agree on this
5021/// key, so both sides call exactly this function.
5022#[must_use]
5023pub fn range_excl_index_key(v: &Value<'_>) -> Option<(i128, u8)> {
5024 let Value::Range {
5025 lower,
5026 lower_inc,
5027 empty,
5028 ..
5029 } = v
5030 else {
5031 return None;
5032 };
5033 if *empty {
5034 return None;
5035 }
5036 let key = match lower {
5037 None => i128::MIN,
5038 Some(b) => match b.as_ref() {
5039 Value::SmallInt(n) => i128::from(*n),
5040 Value::Int(n) => i128::from(*n),
5041 Value::BigInt(n) => i128::from(*n),
5042 Value::Date(n) => i128::from(*n),
5043 Value::Timestamp(n) => i128::from(*n),
5044 _ => return None,
5045 },
5046 };
5047 Some((key, u8::from(!*lower_inc)))
5048}
5049
5050/// v7.39 (round 215) — a per-table range-exclusion index: an incrementally
5051/// maintained map from a range column's lower-bound key
5052/// ([`range_excl_index_key`]) to the physical row locators carrying that
5053/// bound. Lets EXCLUDE enforcement find the few candidate rows a new range
5054/// might overlap in O(log n) instead of scanning every row (measured O(N²),
5055/// r213). Because the stored ranges under a valid `EXCLUDE (col WITH &&)`
5056/// are pairwise disjoint, a candidate overlaps only its predecessor or the
5057/// successors whose lower bound precedes its upper — a handful of probes.
5058///
5059/// NOT persisted: rebuilt from the (persisted) exclusion constraints + rows
5060/// on catalog load, exactly like BRIN re-derives. Backed by a
5061/// `PersistentBTreeMap` so `Table::clone` (the per-write snapshot) stays
5062/// O(1). Locators to tombstoned rows are left in place and filtered by the
5063/// consumer via `is_deleted()` at query time — the established index pattern.
5064#[derive(Debug, Clone)]
5065pub struct ExclRangeIndex {
5066 /// The constrained range column's position in the table.
5067 pub column_position: usize,
5068 /// Lower-bound key → row locators. A key maps to a `Vec` because a
5069 /// tombstoned-then-reinserted bound can transiently collide; live rows
5070 /// under the constraint are disjoint so each key has one live locator.
5071 pub map: PersistentBTreeMap<(i128, u8), crate::posting::PostingList>,
5072}
5073
5074/// v7.38.2 (R2) — see [`Table::tx_write_track`]. Positions are the
5075/// insert-time slots (verified against the header's version at
5076/// extraction, so a shifted slot falls back to the scan); tombstones
5077/// carry the stable RowId, which is what the write-set wants anyway.
5078#[derive(Debug, Clone, Default)]
5079struct TxWriteTrack {
5080 version: u64,
5081 inserted: Vec<(usize, row_header::RowId)>,
5082 tombstoned: Vec<row_header::RowId>,
5083}
5084
5085/// v7.38.11 — hot-tier BRIN granularity: slots per summarised range.
5086///
5087/// 1024 keeps the summary vector three orders of magnitude smaller
5088/// than the table while staying fine enough that a one-day window over
5089/// a 90-day table skips ~99 % of it. A tuning constant, not a format:
5090/// summaries are rebuilt from the rows on load, so changing it costs
5091/// nothing on disk.
5092pub const BRIN_RANGE_ROWS: usize = 1024;
5093
5094/// The comparable scalar a BRIN summary tracks, or `None` for a value
5095/// with no ordering this index can use.
5096///
5097/// Deliberately narrow: only types whose ordering IS the i64 ordering
5098/// of this number. A type added here whose comparison is not that —
5099/// text under a collation, say — would make the summary under-report
5100/// and skip matching rows, which is the one failure this design must
5101/// not have.
5102#[must_use]
5103pub fn brin_scalar(v: &Value<'_>) -> Option<i64> {
5104 match v {
5105 Value::SmallInt(n) => Some(i64::from(*n)),
5106 Value::Int(n) => Some(i64::from(*n)),
5107 Value::BigInt(n) | Value::Timestamp(n) => Some(*n),
5108 Value::Date(d) => Some(i64::from(*d)),
5109 Value::Bool(b) => Some(i64::from(*b)),
5110 _ => None,
5111 }
5112}
5113
5114#[derive(Debug, Clone)]
5115pub struct Table {
5116 schema: TableSchema,
5117 /// v7.38.18 (S2) — the DATABASE's collation, copied in by the
5118 /// catalog that owns this table.
5119 ///
5120 /// A text column that declares no collation inherits it, which is
5121 /// what PostgreSQL does and what `information_schema.columns`
5122 /// reports as NULL. Runtime only, never serialised: it belongs to
5123 /// the catalog, and a table that has been handed around outside one
5124 /// falls back to `C`, which is the answer for every database written
5125 /// before this existed.
5126 db_collation: Option<String>,
5127 /// v7.38.16 — names of the expression indexes whose B-tree currently
5128 /// holds keys derived from the EXPRESSION.
5129 ///
5130 /// Every catalog written before this version stored, under an
5131 /// expression index, the values of its leading column — keys no
5132 /// lookup could ever match, which is why every read path guarded
5133 /// itself with `expression.is_none()` and the index bought nothing
5134 /// while costing 1.9x a plain insert to maintain.
5135 ///
5136 /// Deliberately NOT persisted: a table read off disk starts with the
5137 /// set empty, so those old wrong keys can never answer a query. The
5138 /// engine, which owns the expression evaluator, refills it.
5139 expr_index_complete: alloc::collections::BTreeSet<String>,
5140 /// v7.37.15 (Phase C.1) — stable per-catalog relation identity.
5141 /// [`RelId::UNASSIGNED`](row_header::RelId::UNASSIGNED) until
5142 /// `Catalog::create_table` (or the deserialize dense-assign pass)
5143 /// stamps a real id. Keys the Phase C.4 row-lock table and the
5144 /// Phase C.5 `RelationStore`; survives `DROP TABLE` slot shifts.
5145 rel_id: row_header::RelId,
5146 rows: PersistentVec<Row<'static>>,
5147 /// v7.37.15 (Phase A.2) — per-row MVCC visibility headers
5148 /// parallel to `rows`. `headers.len() == rows.len()` is the
5149 /// load-bearing invariant; debug builds assert it on every
5150 /// scan boundary, release builds rely on it from
5151 /// disciplined insert / delete / update paths.
5152 ///
5153 /// Pre-v7.37.15-loaded tables (every row currently in the
5154 /// fleet) start as `RowHeader::frozen()` — `is_all_visible_fast()`
5155 /// returns `true`, so the per-row visibility gate Phase B
5156 /// adds is a no-op against any snapshot.
5157 ///
5158 /// Headers are NOT yet serialised into the envelope at this
5159 /// commit — on snapshot deserialize every row gets a fresh
5160 /// `RowHeader::frozen()`. Phase D adds the visibility-map
5161 /// + segment-freeze story which makes serialisation
5162 /// meaningful; until then the on-disk story is "the catalog
5163 /// is the set of visible rows."
5164 headers: PersistentVec<row_header::RowHeader>,
5165 /// v7.37.15 (Phase C.1) — stable per-relation row identity
5166 /// parallel to `rows` / `headers`. `rowids[i]` is the never-
5167 /// reused [`RowId`](row_header::RowId) of the row physically at
5168 /// slot `i`; `rowids.len() == rows.len()` joins the same load-
5169 /// bearing lock-step invariant as `headers`. Compaction (delete
5170 /// / vacuum) rebuilds all three vecs together so the id travels
5171 /// with the row while the slot shifts.
5172 ///
5173 /// Introduced additively: allocated + kept lock-step, but index
5174 /// locators still address rows by physical slot at this commit.
5175 /// Later phases migrate the lock table (C.4), HOT chains (D),
5176 /// and the WAL (Epic W) to address by `RowId`.
5177 ///
5178 /// Not yet serialised into the envelope — on load every row is
5179 /// assigned a fresh dense id `1..=len` (see `next_rowid`), which
5180 /// is sufficient while the id is process-local bookkeeping. The
5181 /// V6 envelope (Phase C.6) will persist ids so a WAL redo can
5182 /// name a row across restart.
5183 rowids: PersistentVec<row_header::RowId>,
5184 /// v7.37.15 (Phase C.1) — per-relation monotonic allocator for
5185 /// `rowids`. Starts at 1 (0 is the `RowId::UNASSIGNED` sentinel);
5186 /// every append takes `next_rowid` then increments. Never reused
5187 /// even after the row is deleted / vacuumed, so a stale lock /
5188 /// redo reference can be detected rather than silently aliasing a
5189 /// later row that reused the slot.
5190 ///
5191 /// 7.38.1 (S2.4, MATRIX #20 root cause) — the allocator is SHARED
5192 /// across every `clone()` of the relation (`Arc`), because the
5193 /// monotonic-never-reused promise is a LINEAGE invariant: each
5194 /// open transaction's shadow catalog is a clone, and when clones
5195 /// carried private counters two concurrent shadows minted the
5196 /// same id — duplicate rids in the base after both committed,
5197 /// aliasing every rid-addressed mechanism (locks, tombstones,
5198 /// redo, the rebase unique pre-check).
5199 next_rowid: alloc::sync::Arc<core::sync::atomic::AtomicU64>,
5200 /// v7.37.16 (autovacuum) — live count of tombstoned-but-present hot
5201 /// rows (`headers[i].xmax != XMAX_ALIVE`). Maintained incrementally:
5202 /// `mark_row_deleted` / `mark_rows_deleted` increment (the only
5203 /// tombstone producers), `delete_rows_no_index` recomputes over the
5204 /// survivors (it is the compaction hub every physical removal —
5205 /// including vacuum — flows through), and the v53 snapshot loader
5206 /// recounts verbatim-restored headers. Drives the engine's
5207 /// autovacuum threshold; not persisted (recomputed on load).
5208 dead_rows: u64,
5209 /// v7.39 (pg_stat knife A) — volatile per-table write counters
5210 /// backing `pg_stat_user_tables.n_tup_ins/upd/del`. Not persisted
5211 /// (PG's cumulative stats are shared-memory-volatile too — a
5212 /// restart zeroes them).
5213 stat_tup_ins: u64,
5214 stat_tup_upd: u64,
5215 stat_tup_del: u64,
5216 /// v7.39 (pg_stat knife B) — volatile scan counters
5217 /// (`seq_scan/seq_tup_read/idx_scan/idx_tup_fetch`). Atomics: the
5218 /// read paths that bump them hold only `&Table`.
5219 scan_stats: ScanStats,
5220 /// v7.39 (pg_stat knife C) — wall-clock stamps (unix µs, from the
5221 /// host ClockFn) for pg_stat_user_tables' last_autovacuum /
5222 /// last_analyze. Volatile, like PG's cumulative stats. SPG has no
5223 /// manual-VACUUM statement semantics, so last_vacuum stays NULL.
5224 last_autovacuum_us: Option<i64>,
5225 last_analyze_us: Option<i64>,
5226 indices: Vec<Index>,
5227 hot_bytes: u64,
5228 /// v6.7.0 — cached count of rows currently materialised in the
5229 /// cold tier via `RowLocator::Cold` entries across THIS table's
5230 /// indices. Populated by `ANALYZE` (walks every BTree index and
5231 /// counts Cold locators); the count survives until the next
5232 /// ANALYZE recomputes it. Surfaced via `spg_statistic.cold_row_count`
5233 /// and `spg_stat_segment.table_name`.
5234 ///
5235 /// Honest scope: this is a CACHED count, not a live one.
5236 /// Freezer / promote / DELETE don't currently update the cache
5237 /// incrementally — they invalidate it by setting the
5238 /// `cold_row_count_stale` flag, and the next ANALYZE re-walks.
5239 /// Incremental maintenance is a v6.7.x candidate if observation
5240 /// shows the ANALYZE walk cost dominates.
5241 cold_row_count: u64,
5242 /// v6.7.0 — set when the cached `cold_row_count` may be wrong
5243 /// because rows moved into / out of the cold tier since the last
5244 /// ANALYZE. The virtual-table surface reports the cached value
5245 /// regardless (operators run ANALYZE to refresh).
5246 cold_row_count_stale: bool,
5247 /// v7.34 (crash-recovery P0 #2) — row-level redo capture buffer.
5248 /// `None` (default, in-memory mode) captures nothing — zero overhead.
5249 /// `Some` (set by the engine when persistence is on, before a
5250 /// mutating call) makes `insert` / `update_row` / `delete_rows`
5251 /// record the physical [`RowChange`] they applied, which the engine
5252 /// drains after the statement and writes to the WAL in place of the
5253 /// SQL text. Transient: never serialized; a `Catalog::clone` between
5254 /// enable and drain copies it (cheap — empty in the steady state).
5255 redo_log: Option<Vec<RowChange>>,
5256 /// v7.39 (round 215) — per-`EXCLUDE`-constraint range-overlap indexes,
5257 /// one per single-`&&` constraint on an integer-keyable range column.
5258 /// Maintained incrementally on insert / update / rebuild (mirroring the
5259 /// BTree secondary indexes); NOT serialized — rebuilt from the schema's
5260 /// exclusion constraints on load. Empty for tables with no EXCLUDE
5261 /// constraint (the common case), so `Table::clone` pays nothing.
5262 excl_indexes: Vec<ExclRangeIndex>,
5263 /// v7.38.2 (R2) — incremental write-set track for the RC rebase.
5264 /// `extract_tx_writeset` used to full-scan every header per call —
5265 /// ~200 µs on a 20k-row table, per in-transaction statement, every
5266 /// time a concurrent COMMIT moved the epoch; on tpcb's 100k-row
5267 /// accounts that scan was the c2 concurrency cliff itself. The
5268 /// three version-marking funnels (`insert_with_xmin`,
5269 /// `mark_row_deleted`, `mark_rows_deleted`) record here instead.
5270 ///
5271 /// One track per table, keyed by the LAST writer version: a shadow
5272 /// belongs to one transaction, so a different version claiming the
5273 /// table simply replaces the track (on the committed base that
5274 /// makes memory bounded by the last writer's footprint). Extraction
5275 /// verifies every recorded position still carries the version —
5276 /// any mismatch (compaction, inherited track, pre-track rows)
5277 /// falls back to the full scan, so the fast path can be wrong
5278 /// about NOTHING, only slow.
5279 tx_write_track: Option<TxWriteTrack>,
5280 /// v7.39 (round 493) — the snapshot floor below which a deleted row
5281 /// version is invisible to everyone, as of the statement now running.
5282 ///
5283 /// Runtime only: never serialised, and `0` (the default) prunes
5284 /// nothing, so any path that forgets to set it is merely slower, not
5285 /// wrong. The engine sets it from `vacuum_oldest_active()` — the same
5286 /// floor `vacuum` itself takes — before the statement's inserts.
5287 prune_horizon: u64,
5288}
5289
5290/// Catalog: insertion-ordered `Vec<Table>` for stable iter / serialize,
5291/// plus a `BTreeMap<String, usize>` sidecar index so `get` / `get_mut`
5292/// run in O(log n) instead of the old linear scan with per-element
5293/// string compares.
5294///
5295/// A pure `BTreeMap<String, Table>` was tried in an interim version
5296/// of v3.1.2 and regressed the single-table catalog benches by ~10%
5297/// (the per-element `BTreeMap` overhead outweighs the lookup win
5298/// when n is small). The sidecar shape preserves the insertion-order
5299/// iteration the on-disk encoding relies on and keeps `last_mut`
5300/// (used by the deserialize hot path) cheap.
5301/// v7.39 (pg_stat blks knife) — catalog-wide cold-tier read counter
5302/// backing pg_stat_database.blks_read. Row-granular (SPG has no 8 KB
5303/// page notion): one cold-segment row resolution = one "block read",
5304/// one hot row access = one "block hit" — the hit RATIO monitoring
5305/// dashboards compute keeps its meaning. Volatile like PG's stats.
5306#[derive(Debug, Default)]
5307pub struct ColdReadStats {
5308 pub cold_reads: core::sync::atomic::AtomicU64,
5309}
5310
5311impl Clone for ColdReadStats {
5312 fn clone(&self) -> Self {
5313 Self {
5314 cold_reads: core::sync::atomic::AtomicU64::new(
5315 self.cold_reads.load(core::sync::atomic::Ordering::Relaxed),
5316 ),
5317 }
5318 }
5319}
5320
5321/// 7.38.1 S3.1 (D4) — the non-table catalog families that carry a
5322/// per-transaction dirty window (see `Catalog::dirty_nontable`). One
5323/// entry class per side-map the poisoned-commit merge reconciles.
5324#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5325pub enum NonTableKind {
5326 Sequence,
5327 View,
5328 MaterializedView,
5329 EnumType,
5330 DomainType,
5331 CompositeType,
5332}
5333
5334#[derive(Debug, Clone, Default)]
5335pub struct Catalog {
5336 /// v7.39 (pg_stat blks knife) — see [`ColdReadStats`].
5337 pub cold_read_stats: ColdReadStats,
5338 tables: Vec<Table>,
5339 /// `name → tables[index]`. Kept in lock-step with `tables`.
5340 /// `create_table` is the only write path.
5341 by_name: BTreeMap<String, usize>,
5342 /// v7.39 (round 436) — the current session's temporary-table namespace.
5343 /// A temp table is stored under `<prefix><name>`, and every lookup tries
5344 /// that first: exactly PG's `pg_temp` search-path rule, and MySQL's
5345 /// "a TEMPORARY table shadows a permanent one of the same name".
5346 ///
5347 /// Process-local, never serialised: the engine sets it per session, and
5348 /// a catalog read back from disk starts with none. Kept here rather than
5349 /// at each of the ~170 engine call sites because `by_name` is private —
5350 /// this is the ONE place a table name becomes an index.
5351 temp_prefix: Option<String>,
5352 /// v7.39.2 — see [`Catalog::set_case_insensitive_names`].
5353 case_insensitive_names: bool,
5354 /// v7.39 (round 496) — the names of tables this catalog handle has had
5355 /// changed since the set was last cleared.
5356 ///
5357 /// Runtime only, never serialised. A transaction's shadow catalog
5358 /// clears it at BEGIN, so at COMMIT the set is exactly the tables the
5359 /// transaction changed — which is what lets a commit that cannot use
5360 /// the row-level merge install only those tables instead of the whole
5361 /// catalog, leaving another session's concurrent work in place.
5362 ///
5363 /// Recorded where the change actually happens (`get_mut`,
5364 /// `create_table`, `drop_table`) rather than from the statement
5365 /// classifier: round 494 tried classification for a correctness gate
5366 /// and it was wrong, because `SELECT lo_write(…)` reads as read-only.
5367 dirty_tables: alloc::collections::BTreeSet<String>,
5368 /// 7.38.1 S3.1 (D4) — the non-table twin of `dirty_tables`: which
5369 /// sequences / views / matviews / enum / domain / composite types
5370 /// THIS window created, altered, renamed or dropped. Counter
5371 /// advances (`nextval`) deliberately do NOT record — counter
5372 /// values merge via `sequence_counters` / `restore_sequence_
5373 /// counters`, and a tx that only consumed ids must not shadow a
5374 /// neighbour's ALTER SEQUENCE. Cleared by `clear_dirty_tables`
5375 /// (one window, both records).
5376 dirty_nontable: alloc::collections::BTreeSet<(NonTableKind, String)>,
5377 /// v7.37.15 (Phase C.1) — monotonic allocator for stable
5378 /// [`RelId`](row_header::RelId)s. Pre-incremented on each
5379 /// `create_table` so real ids start at 1 (0 is `UNASSIGNED`);
5380 /// never reused even after `DROP TABLE`, so a stale lock / redo
5381 /// reference is detectable. Process-local bookkeeping — not yet
5382 /// serialised; `deserialize` re-assigns dense ids on load (the
5383 /// V6 envelope, Phase C.6, will round-trip real ids).
5384 next_rel_id: u64,
5385 /// v5.1: in-memory cold-tier segments. Side-loaded via
5386 /// [`Catalog::load_segment_bytes`] — they live outside the
5387 /// catalog snapshot (caller persists them as separate files
5388 /// and re-loads on boot, until v5.3's `CatalogManifest` makes
5389 /// that wiring automatic). `RowLocator::Cold { segment_id, .. }`
5390 /// indexes this `Vec`. Cleared on `Catalog::new` / fresh
5391 /// `deserialize`.
5392 ///
5393 /// `Arc` wrap keeps `Catalog::clone` at O(N segments) bumps
5394 /// (rather than O(total segment bytes) memcpy) so the v4.42
5395 /// group-commit pre-image rollback invariant — clone is
5396 /// effectively free — survives the cold-tier addition.
5397 ///
5398 /// v6.7.3 — slots became `Option<…>` so cold-segment compaction
5399 /// can tombstone merged sources without breaking the
5400 /// `segment_id = index_into_vec` contract that on-disk
5401 /// `RowLocator::Cold { segment_id }` already serialized.
5402 /// `None` slot = the segment was retired by compaction; the
5403 /// physical file may still be on disk (next CHECKPOINT writes
5404 /// a manifest that no longer lists it, and the file becomes
5405 /// an orphan eligible for offline cleanup).
5406 cold_segments: Vec<Option<Arc<OwnedSegment>>>,
5407 /// v7.12.4 — user-defined functions (PL/pgSQL + SQL).
5408 /// Keyed by function name (PG overloading is out of scope).
5409 /// Bodies are stored as the raw source text the parser saw
5410 /// between `$$ ... $$`; the engine re-parses on each
5411 /// invocation. This keeps `spg-storage` free of `spg-sql`
5412 /// dependency — same pattern as partial-index predicates.
5413 functions: BTreeMap<String, FunctionDef>,
5414 /// v7.12.4 — triggers in insertion order. PG18-measured (round
5415 /// 753): PG fires same-event triggers in NAME order (a_trig
5416 /// before z_trig regardless of creation order); SPG fires in
5417 /// insertion order — a real divergence, ledgered as F31-B2.
5418 triggers: Vec<TriggerDef>,
5419 /// v7.39 (round 139) — query-rewrite RULEs, flat like triggers.
5420 rules: Vec<RuleDef>,
5421 /// v7.39 (round 280) — extended-statistics objects. Recorded so a
5422 /// pg_dump restores them and reflection reports them; the planner
5423 /// does not consult them yet.
5424 statistics_ext: Vec<StatisticsExtDef>,
5425 /// v7.39 (round 287) — server-side large objects, keyed by OID.
5426 /// PG stores them as 2 KB pages in `pg_largeobject`; the page split
5427 /// is a storage detail of ITS heap, so SPG holds the whole byte
5428 /// string and renders the pages on read. What must match is the
5429 /// observable surface: the OIDs, the bytes, and the page rows.
5430 large_objects: alloc::collections::BTreeMap<u32, Vec<u8>>,
5431 /// v7.17.0 — catalogued SEQUENCE objects (Phase 1.1). Each
5432 /// `nextval(name)` reaches in here, atomically increments
5433 /// `last_value` / flips `is_called`, returns the new value.
5434 /// Persisted in catalog FILE_VERSION 26+; older catalogs
5435 /// deserialise with an empty map.
5436 sequences: BTreeMap<String, SequenceDef>,
5437 /// v7.39 (read01 round 60) — the `public` schema's ACL (PG
5438 /// `pg_namespace.nspacl`). EMPTY = PG's default, which is not "nothing":
5439 /// PUBLIC holds USAGE and the owner holds USAGE + CREATE. Materialised on
5440 /// the first GRANT / REVOKE, exactly like a table's relacl.
5441 schema_acl: Vec<AclItem>,
5442 /// v7.39 (read01 round 60) — the database's ACL. EMPTY = PG's default:
5443 /// PUBLIC holds CONNECT + TEMPORARY, the owner holds all three.
5444 database_acl: Vec<AclItem>,
5445 /// v7.17.0 — catalogued VIEW objects (Phase 1.2). Each
5446 /// `SELECT FROM v` at engine exec-time looks up `v` here and
5447 /// prepends the view body as a synthetic CTE. Persisted in
5448 /// catalog FILE_VERSION 27+; older catalogs deserialise with
5449 /// an empty map.
5450 views: BTreeMap<String, ViewDef>,
5451 /// v7.17.0 — catalogued MATERIALIZED VIEW source registry
5452 /// (Phase 1.3). Maps name → SELECT source. The materialised
5453 /// rows themselves live as a regular `Table` with the same
5454 /// name; REFRESH re-parses + re-executes the source against
5455 /// the table. Persisted in catalog FILE_VERSION 28+;
5456 /// older catalogs deserialise with an empty map.
5457 materialized_views: BTreeMap<String, String>,
5458 /// v7.17.0 — catalogued user-defined ENUM types (Phase 1.4).
5459 /// Maps name → label list. Columns reference these by name
5460 /// via `ColumnSchema.user_enum_type`. Persisted in catalog
5461 /// FILE_VERSION 29+; older catalogs deserialise with an empty
5462 /// map.
5463 enum_types: BTreeMap<String, EnumDef>,
5464 /// v7.17.0 — catalogued user-defined DOMAIN types (Phase 1.5).
5465 /// Maps name → base + CHECK constraints. Columns reference
5466 /// these by name via `ColumnSchema.user_domain_type`.
5467 /// Persisted in catalog FILE_VERSION 30+; older catalogs
5468 /// deserialise with an empty map.
5469 domain_types: BTreeMap<String, DomainDef>,
5470 /// v7.39 (read01 round 50) — `COMMENT ON <kind> <obj> IS '…'` store.
5471 /// Keyed by a canonical `"<kind>:<name>"` string (`"table:t"`,
5472 /// `"column:t.c"`, `"index:i"`, `"view:v"`, …) so a new commentable
5473 /// object kind needs no schema change. `COMMENT … IS NULL` removes the
5474 /// entry. Persisted in catalog FILE_VERSION 61+; older catalogs
5475 /// deserialise with an empty map. Read back by obj_description /
5476 /// col_description and the pg_description view.
5477 comments: BTreeMap<String, String>,
5478 /// v7.39 (round 547) — PG's `pg_db_role_setting`: the GUC defaults
5479 /// `ALTER ROLE … SET` / `ALTER DATABASE … SET` record, applied when
5480 /// a session starts.
5481 ///
5482 /// Keyed exactly as PG keys it — `(database, role)` where an empty
5483 /// name is PG's oid 0, meaning "all". So `ALTER ROLE ALL SET` is
5484 /// `("", "")`, `ALTER DATABASE d SET` is `(d, "")`, `ALTER ROLE r
5485 /// SET` is `("", r)` and `ALTER ROLE r IN DATABASE d SET` is
5486 /// `(d, r)`. The value is that scope's parameter list.
5487 db_role_settings: BTreeMap<(String, String), BTreeMap<String, String>>,
5488 /// v7.39 (round 550) — replication slots, by name.
5489 ///
5490 /// A slot in PG is two things: a named record, and a reservation
5491 /// that holds WAL back. SPG keeps the record — which is what every
5492 /// setup script and monitoring query reads — and reports
5493 /// `wal_status = 'unreserved'`, PG's own word for a slot that no
5494 /// longer holds WAL. The whole family used to answer NULL and
5495 /// report success, so `pg_drop_replication_slot('nosuchslot')` said
5496 /// it worked and a setup script created nothing.
5497 ///
5498 /// Value: (plugin, slot_type). `plugin` is empty for a physical slot.
5499 replication_slots: BTreeMap<String, (String, String)>,
5500 /// v7.38.18 (S1) — the collation this database was CREATED with, and
5501 /// the one every text column that declares none is compared under.
5502 ///
5503 /// `None` means `C`, which is what every database written by every
5504 /// earlier version was built with — so an upgrade changes no answer
5505 /// and rebuilds no index. That is the whole migration story, and it
5506 /// is why this is an `Option` rather than a `String` defaulting to
5507 /// `"C"`.
5508 ///
5509 /// Set once, at creation, and never after. PostgreSQL refuses
5510 /// `ALTER DATABASE … LC_COLLATE` and the reason is the one that
5511 /// matters here too: every index key in this database was built
5512 /// under this collation, so it cannot move out from under them.
5513 /// See `docs/DESIGN-2026-08-23-collation.md`.
5514 db_collation: Option<String>,
5515 /// v7.38.19 — every name a `CREATE DATABASE` has asked for.
5516 ///
5517 /// SPG serves one database and answers to any name, so the statement
5518 /// has always been a no-op for naming. `pg_database` then listed one
5519 /// row -- whatever name the current session connected with -- so a
5520 /// database that had just been created, and could be connected to,
5521 /// was absent from the catalogue. `psql \l`, a migration tool asking
5522 /// "does this database exist", and a backup script that enumerates
5523 /// all read that table.
5524 ///
5525 /// Reported by sentori against 7.38.18. Runtime only, like
5526 /// `db_collation`: the statement is audited whenever it records a
5527 /// name, so replay rebuilds the set.
5528 created_databases: alloc::collections::BTreeSet<String>,
5529 /// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE types
5530 /// (`CREATE TYPE name AS (field_name field_type, …)`). Columns
5531 /// reference these by name via
5532 /// `ColumnSchema.user_composite_type` (parallel to
5533 /// `user_enum_type` / `user_domain_type`). Persisted in catalog
5534 /// FILE_VERSION 52+; older catalogs deserialise with an empty
5535 /// map.
5536 composite_types: BTreeMap<String, CompositeDef>,
5537 /// v7.17.0 — schema-namespace registry (Phase 1.6). Tracks
5538 /// which schemas exist. `public`, `pg_catalog`, and
5539 /// `information_schema` are built-in and always present.
5540 /// Schema-qualified table references still strip the prefix
5541 /// at lookup time per v7.16-and-earlier — full
5542 /// schema-as-isolation is v7.18+ scope. Persisted in catalog
5543 /// FILE_VERSION 31+; older catalogs deserialise with just
5544 /// the built-ins.
5545 schemas: alloc::collections::BTreeSet<String>,
5546}
5547
5548/// v7.12.4 — catalogued user-defined function. `body` is the raw
5549/// source text between `$$ ... $$`; the engine re-parses it on
5550/// invocation. This keeps the storage codec stable when the
5551/// PL/pgSQL surface grows (no breaking-change risk on the disk
5552/// format).
5553// v7.39 (round 322, V46) — no longer `Eq`: COST / ROWS are f64, as in PG.
5554#[derive(Debug, Clone, PartialEq)]
5555pub struct FunctionDef {
5556 pub name: String,
5557 /// Display form of the argument list, e.g.
5558 /// `"(name TEXT, ts TIMESTAMP)"`. Empty `"()"` for the trigger
5559 /// function shape. Parser-side canonicalised before storage.
5560 pub args_repr: String,
5561 /// Display form of the return type, e.g. `"TRIGGER"` /
5562 /// `"INT"` / `"SETOF text"`. The engine special-cases
5563 /// `"TRIGGER"` (case-insensitive) to gate trigger-only
5564 /// semantics (NEW/OLD).
5565 pub returns: String,
5566 /// `LANGUAGE` clause, lowercased. `"plpgsql"` / `"sql"`.
5567 pub language: String,
5568 /// Source body of the function. PL/pgSQL: includes the
5569 /// surrounding `BEGIN ... END;`. SQL: includes the
5570 /// statement(s). The engine re-parses on invocation; bad
5571 /// bodies surface as a parse error at CALL time, not CREATE.
5572 pub body: String,
5573 /// v7.39 (read01 round 61) — the role that ran CREATE FUNCTION.
5574 pub owner: Option<String>,
5575 /// v7.39 (read01 round 61) — explicit GRANTs (PG `pg_proc.proacl`). EMPTY
5576 /// is NOT "nobody may call it": PG grants EXECUTE to PUBLIC by default, and
5577 /// leaves proacl NULL to say so. The list materialises on the first
5578 /// GRANT / REVOKE.
5579 pub acl: Vec<AclItem>,
5580 /// v7.39 (round 322, V46) — `IMMUTABLE` / `STRICT` / `PARALLEL SAFE` /
5581 /// `SECURITY DEFINER` / `LEAKPROOF` / `COST` / `ROWS`. `strict` is the
5582 /// only one with execution semantics today (a NULL argument yields a
5583 /// NULL result without running the body); the rest are recorded so
5584 /// `pg_get_functiondef` and `pg_proc` report what was declared.
5585 pub volatility: u8,
5586 pub strict: bool,
5587 pub security_definer: bool,
5588 pub leakproof: bool,
5589 pub parallel: u8,
5590 pub cost: Option<f64>,
5591 pub rows: Option<f64>,
5592}
5593
5594/// v7.39 (round 322, V46) — `FunctionDef.volatility` codes: PG's
5595/// `pg_proc.provolatile` letters.
5596pub const FN_VOLATILE: u8 = b'v';
5597pub const FN_IMMUTABLE: u8 = b'i';
5598pub const FN_STABLE: u8 = b's';
5599
5600/// v7.39 (round 322, V46) — `FunctionDef.parallel` codes: PG's
5601/// `pg_proc.proparallel` letters.
5602pub const FN_PARALLEL_UNSAFE: u8 = b'u';
5603pub const FN_PARALLEL_RESTRICTED: u8 = b'r';
5604pub const FN_PARALLEL_SAFE: u8 = b's';
5605
5606/// v7.39 (round 315, V19) — which catalogued function does a persisted
5607/// ACL key refer to?
5608///
5609/// The key was computed by whichever formula was current when the image
5610/// was written, and the multi-word fix changed that formula for bare
5611/// types like `double precision`. A miss therefore does NOT mean "no
5612/// such function": an older image's key would land nowhere and its owner
5613/// and grants would be dropped in silence. Exact match first, then the
5614/// pre-fix formula.
5615#[must_use]
5616pub fn resolve_stored_function_key(
5617 functions: &BTreeMap<String, FunctionDef>,
5618 stored: &str,
5619) -> Option<String> {
5620 if functions.contains_key(stored) {
5621 return Some(stored.to_string());
5622 }
5623 functions
5624 .values()
5625 .find(|f| function_signature_key_legacy(&f.name, &f.args_repr) == stored)
5626 .map(|f| function_signature_key(&f.name, &f.args_repr))
5627}
5628
5629/// v7.39 (round 344, V49) — re-exported from [`spg_sql`], which owns the
5630/// SQL type spellings. This crate carried a byte-identical copy because
5631/// the two were siblings that did not depend on each other; spg-sql is a
5632/// dependency-free leaf, so the dependency is acyclic and the publish
5633/// order already puts it first. One list, one place to keep it right.
5634pub use spg_sql::parser::is_multiword_type_phrase;
5635
5636/// v7.39 (round 315, V19) — the signature key as computed BEFORE the
5637/// multi-word fix, used only to recognise what an older image wrote.
5638///
5639/// The function catalogue recomputes its keys from the stored name and
5640/// argument text on load, so it needs no migration. The ACL block does
5641/// not: it persists the computed key as a string and matches on it. A
5642/// key that changed shape would simply fail to match, and the owner and
5643/// grants would be dropped without a word — so the loader falls back to
5644/// this when the stored key finds nothing.
5645#[must_use]
5646pub fn function_signature_key_legacy(name: &str, args_repr: &str) -> String {
5647 let inner = args_repr
5648 .trim()
5649 .trim_start_matches('(')
5650 .trim_end_matches(')');
5651 let types: Vec<String> = if inner.trim().is_empty() {
5652 Vec::new()
5653 } else {
5654 inner
5655 .split(',')
5656 .map(|part| {
5657 let mut words: Vec<&str> = part.split_whitespace().collect();
5658 if !words.is_empty()
5659 && (words[0].eq_ignore_ascii_case("OUT")
5660 || words[0].eq_ignore_ascii_case("INOUT"))
5661 {
5662 words.remove(0);
5663 }
5664 let ty = if words.len() >= 2 {
5665 words[1..].join(" ")
5666 } else {
5667 words.first().map_or(String::new(), |w| (*w).to_string())
5668 };
5669 normalize_type_name(&ty)
5670 })
5671 .collect()
5672 };
5673 format!("{}({})", name.to_ascii_lowercase(), types.join(","))
5674}
5675
5676pub fn function_signature_key(name: &str, args_repr: &str) -> String {
5677 let types = function_arg_types(args_repr);
5678 format!("{}({})", name.to_ascii_lowercase(), types.join(","))
5679}
5680
5681/// The declared argument TYPES of a function, out of its `args_repr`
5682/// (`"(x INT, y DOUBLE PRECISION)"` → `["int", "float"]`). An entry may be a
5683/// bare type with no name (`"(INT)"`).
5684#[must_use]
5685pub fn function_arg_types(args_repr: &str) -> Vec<String> {
5686 let inner = args_repr
5687 .trim()
5688 .trim_start_matches('(')
5689 .trim_end_matches(')');
5690 if inner.trim().is_empty() {
5691 return Vec::new();
5692 }
5693 inner
5694 .split(',')
5695 .map(|part| {
5696 let mut words: Vec<&str> = part.split_whitespace().collect();
5697 // `OUT x INT` / `INOUT x INT` — the mode is not part of the type.
5698 if !words.is_empty()
5699 && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
5700 {
5701 words.remove(0);
5702 }
5703 // v7.39 (round 315, V19) — two or more words is USUALLY
5704 // `name TYPE`, but not when the type itself is spelled in
5705 // several words. `double precision` was read as a parameter
5706 // named "double" of type "precision", so it keyed differently
5707 // from `x double precision` — the same signature written two
5708 // ways did not resolve to the same function. Decide by asking
5709 // whether the whole phrase names a type first; only then is
5710 // the leading word a parameter name.
5711 let whole = words.join(" ");
5712 let ty = if words.len() >= 2 && !is_multiword_type_phrase(&whole) {
5713 words[1..].join(" ")
5714 } else {
5715 whole
5716 };
5717 normalize_type_name(&ty)
5718 })
5719 .collect()
5720}
5721
5722/// v7.39 (read01 round 65) — the declared argument NAMES of a function (`""` for
5723/// a bare type with no name).
5724#[must_use]
5725pub fn function_arg_names(args_repr: &str) -> Vec<String> {
5726 let inner = args_repr
5727 .trim()
5728 .trim_start_matches('(')
5729 .trim_end_matches(')');
5730 if inner.trim().is_empty() {
5731 return Vec::new();
5732 }
5733 inner
5734 .split(',')
5735 .map(|part| {
5736 let mut words: Vec<&str> = part.split_whitespace().collect();
5737 if !words.is_empty()
5738 && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
5739 {
5740 words.remove(0);
5741 }
5742 if words.len() >= 2 {
5743 words[0].to_string()
5744 } else {
5745 String::new()
5746 }
5747 })
5748 .collect()
5749}
5750
5751/// Fold PG's type aliases so a signature key is stable across spellings.
5752/// Unknown names pass through lower-cased — consistency is what the key needs.
5753#[must_use]
5754pub fn normalize_type_name(ty: &str) -> String {
5755 let t = ty.trim().to_ascii_lowercase();
5756 // Peel a precision/length modifier: `numeric(10,2)`, `varchar(64)`.
5757 let base = t.split_once('(').map_or(t.as_str(), |(h, _)| h).trim();
5758 match base {
5759 "int" | "int4" | "integer" => "int",
5760 "bigint" | "int8" => "bigint",
5761 "smallint" | "int2" => "smallint",
5762 "text" | "varchar" | "character varying" | "char" | "character" | "bpchar" => "text",
5763 "bool" | "boolean" => "bool",
5764 "float" | "float8" | "double precision" => "float",
5765 "real" | "float4" => "real",
5766 "numeric" | "decimal" => "numeric",
5767 "timestamptz" | "timestamp with time zone" => "timestamptz",
5768 "timestamp" | "timestamp without time zone" => "timestamp",
5769 other => other,
5770 }
5771 .to_string()
5772}
5773
5774/// v7.12.4 — catalogued trigger. References its function by
5775/// name; the function must exist at TRIGGER creation time
5776/// (forward references are deferred to v7.12.5+).
5777#[derive(Debug, Clone, PartialEq, Eq)]
5778pub struct TriggerDef {
5779 pub name: String,
5780 /// Watched table. Trigger is dropped when the table drops.
5781 pub table: String,
5782 /// `"BEFORE"` / `"AFTER"` / `"INSTEAD OF"`. Stored as the
5783 /// uppercased keyword so deserialised catalogs round-trip
5784 /// without canonicalisation surprises.
5785 pub timing: String,
5786 /// Each entry is one of `"INSERT"` / `"UPDATE"` / `"DELETE"`
5787 /// / `"TRUNCATE"`. `INSERT OR UPDATE` parses to two entries.
5788 pub events: Vec<String>,
5789 /// `"ROW"` / `"STATEMENT"`. v7.12.4 ships `"ROW"` only;
5790 /// `"STATEMENT"` parses and persists but the executor
5791 /// refuses it at trigger fire time.
5792 pub for_each: String,
5793 /// Name of the PL/pgSQL function to invoke.
5794 pub function: String,
5795 /// v7.13.0 — `UPDATE OF col, col, …` column-list filter
5796 /// (mailrs round-5 G7). Non-empty means the trigger fires
5797 /// only when at least one of these columns appears in the
5798 /// UPDATE's SET list. Empty = no column filter. Stored in
5799 /// catalog FILE_VERSION 23+; older catalogs deserialise with
5800 /// an empty vec.
5801 pub update_columns: Vec<String>,
5802 /// v7.16.1 — whether the trigger fires when its watched
5803 /// event occurs. Toggled by `ALTER TABLE … { ENABLE |
5804 /// DISABLE } TRIGGER …`; pg_dump --disable-triggers wraps
5805 /// every data block with a DISABLE/ENABLE pair so the
5806 /// rows already-computed in prod don't get re-rewritten.
5807 /// Defaults to `true` at CREATE TRIGGER time. Stored in
5808 /// catalog FILE_VERSION 25+; older catalogs deserialise
5809 /// with `enabled = true`.
5810 pub enabled: bool,
5811 /// v7.39 (round 138) — the deparsed `WHEN ( condition )` predicate text
5812 /// (re-parsed at fire time to filter row triggers). Empty = no WHEN.
5813 /// Persisted from FILE_VERSION 70; older catalogs read back empty.
5814 pub when_condition: String,
5815}
5816
5817/// v7.39 (round 280) — one `CREATE STATISTICS` object.
5818#[derive(Debug, Clone, PartialEq, Eq)]
5819pub struct StatisticsExtDef {
5820 pub name: String,
5821 pub table: String,
5822 /// PG's single-letter kinds: `d` ndistinct, `f` dependencies,
5823 /// `m` mcv. PG's default set is all three.
5824 pub kinds: Vec<String>,
5825 pub columns: Vec<String>,
5826}
5827
5828/// v7.39 (round 139) — a catalogued query-rewrite RULE. Stored flat like
5829/// `TriggerDef`, keyed by `(name, table)`. Command / WHEN text is deparsed SQL
5830/// re-parsed at rewrite time (the same round-trip trick as
5831/// `TriggerDef.when_condition`). Persisted from FILE_VERSION 71.
5832#[derive(Debug, Clone, PartialEq, Eq)]
5833pub struct RuleDef {
5834 pub name: String,
5835 pub table: String,
5836 /// Event keyword, uppercased: `INSERT` / `UPDATE` / `DELETE` / `SELECT`.
5837 pub event: String,
5838 /// `true` = `DO INSTEAD`, `false` = `DO ALSO`.
5839 pub instead: bool,
5840 /// Deparsed `WHERE` predicate text; empty = unconditional.
5841 pub when_condition: String,
5842 /// Deparsed DO command statements; empty = `NOTHING`.
5843 pub commands: Vec<String>,
5844}
5845
5846/// v7.17.0 — catalogued SEQUENCE. PG semantics: a counter object
5847/// returning monotonically increasing values via `nextval(name)`.
5848/// `last_value` is the most recent value handed out; `is_called`
5849/// is false until the first `nextval`/`setval`. Stored separately
5850/// from tables in the catalog.
5851#[derive(Debug, Clone, PartialEq, Eq)]
5852pub struct SequenceDef {
5853 pub name: String,
5854 /// Data type — narrows the i64 range. PG default BIGINT.
5855 pub data_type: SequenceDataType,
5856 pub start: i64,
5857 pub increment: i64,
5858 pub min_value: i64,
5859 pub max_value: i64,
5860 pub cache: i64,
5861 pub cycle: bool,
5862 /// `OWNED BY` target — `(table, column)` or NONE.
5863 pub owned_by: Option<(String, String)>,
5864 /// Most recently handed-out value. Meaningless when
5865 /// `is_called == false`; in that case the NEXT `nextval`
5866 /// will return `start`.
5867 pub last_value: i64,
5868 pub is_called: bool,
5869 /// v7.39 (read01 round 60) — the role that ran CREATE SEQUENCE. `None` = an
5870 /// image written before FILE_VERSION 66, which predates sequence owners.
5871 pub owner: Option<String>,
5872 /// v7.39 (read01 round 60) — explicit GRANTs on this sequence. A sequence's
5873 /// meaningful privileges are SELECT (`currval`), UPDATE (`setval`) and
5874 /// USAGE (`nextval`).
5875 pub acl: Vec<AclItem>,
5876}
5877
5878/// v7.17.0 — sequence integer width.
5879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5880pub enum SequenceDataType {
5881 SmallInt,
5882 Int,
5883 BigInt,
5884}
5885
5886/// v7.17.0 Phase 1.6 — built-in schema names that every Catalog
5887/// understands without an explicit CREATE SCHEMA. Used by
5888/// [`Catalog::schema_exists`] and the engine's schema-qualified
5889/// lookup path.
5890#[must_use]
5891pub fn is_builtin_schema(name: &str) -> bool {
5892 name.eq_ignore_ascii_case("public")
5893 || name.eq_ignore_ascii_case("pg_catalog")
5894 || name.eq_ignore_ascii_case("information_schema")
5895}
5896
5897/// v7.17.0 — parse a PG-canonical UUID text representation into the
5898/// 16-byte network-order layout used by `Value::Uuid`. Accepted input
5899/// shapes (all case-insensitive):
5900/// * Canonical hyphenated 8-4-4-4-12 (`550e8400-e29b-41d4-a716-446655440000`)
5901/// * Unhyphenated 32-char hex (`550e8400e29b41d4a716446655440000`)
5902/// * Either form wrapped in `{ ... }`
5903///
5904/// Returns `None` for any malformed input (wrong length, non-hex
5905/// characters, misplaced hyphens). The caller surfaces a SQL error
5906/// at coercion time — silent acceptance of garbage would mask
5907/// application bugs and is exactly the divergence from PG that
5908/// breaks the 0-change cutover promise.
5909#[must_use]
5910pub fn parse_uuid_str(input: &str) -> Option<[u8; 16]> {
5911 let s = input.trim();
5912 // Strip surrounding braces if present.
5913 let s = if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
5914 inner
5915 } else {
5916 s
5917 };
5918 // Two valid shapes after braces are stripped: 32 hex chars or
5919 // the canonical 36-char hyphenated form.
5920 let hex: String = match s.len() {
5921 32 => s.to_ascii_lowercase(),
5922 36 => {
5923 // Hyphens must be exactly at positions 8, 13, 18, 23.
5924 let b = s.as_bytes();
5925 if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
5926 return None;
5927 }
5928 let mut out = String::with_capacity(32);
5929 out.push_str(&s[0..8]);
5930 out.push_str(&s[9..13]);
5931 out.push_str(&s[14..18]);
5932 out.push_str(&s[19..23]);
5933 out.push_str(&s[24..36]);
5934 out.make_ascii_lowercase();
5935 out
5936 }
5937 _ => return None,
5938 };
5939 let bytes = hex.as_bytes();
5940 let mut out = [0u8; 16];
5941 for i in 0..16 {
5942 let hi = hex_nibble(bytes[i * 2])?;
5943 let lo = hex_nibble(bytes[i * 2 + 1])?;
5944 out[i] = (hi << 4) | lo;
5945 }
5946 Some(out)
5947}
5948
5949fn hex_nibble(b: u8) -> Option<u8> {
5950 match b {
5951 b'0'..=b'9' => Some(b - b'0'),
5952 b'a'..=b'f' => Some(10 + b - b'a'),
5953 b'A'..=b'F' => Some(10 + b - b'A'),
5954 _ => None,
5955 }
5956}
5957
5958/// v7.17.0 — render a `Value::Uuid` payload as the canonical
5959/// lowercase 8-4-4-4-12 hyphenated form PG `text` cast surfaces.
5960#[must_use]
5961pub fn format_uuid(b: &[u8; 16]) -> String {
5962 const HEX: &[u8; 16] = b"0123456789abcdef";
5963 let mut out = String::with_capacity(36);
5964 for (i, byte) in b.iter().enumerate() {
5965 if matches!(i, 4 | 6 | 8 | 10) {
5966 out.push('-');
5967 }
5968 out.push(HEX[(byte >> 4) as usize] as char);
5969 out.push(HEX[(byte & 0x0f) as usize] as char);
5970 }
5971 out
5972}
5973
5974/// v7.17.0 Phase 1.5 — catalogued user-defined DOMAIN. A domain
5975/// is a named CHECK-constrained alias over a built-in type;
5976/// columns bound to it inherit the base type plus the CHECK
5977/// predicates + NOT NULL + DEFAULT at INSERT/UPDATE time.
5978/// v7.37.17 (Phase E RC rebase) — the write-set one writer version left
5979/// on a table, addressed by stable [`row_header::RowId`]s so it can be
5980/// replayed onto a fresher clone of the relation whose physical slots
5981/// differ. Produced by [`Table::extract_tx_writeset`], consumed by
5982/// [`Table::replay_tx_writeset`].
5983#[derive(Debug, Clone, Default)]
5984pub struct TxWriteSet {
5985 /// INSERTs and UPDATE-new-versions (`header.xmin == v`).
5986 pub inserted: Vec<(row_header::RowId, Row<'static>)>,
5987 /// DELETE / UPDATE-old-version targets (`header.xmax == v`).
5988 pub tombstoned: Vec<row_header::RowId>,
5989}
5990
5991impl TxWriteSet {
5992 #[must_use]
5993 pub fn is_empty(&self) -> bool {
5994 self.inserted.is_empty() && self.tombstoned.is_empty()
5995 }
5996}
5997
5998/// v7.39 (round 260) — one named CHECK on a domain. PG auto-names an
5999/// unnamed one `<domain>_check`, then `_check1`, `_check2`, … (probed).
6000#[derive(Debug, Clone, PartialEq, Eq)]
6001pub struct DomainCheck {
6002 pub name: String,
6003 /// The predicate source, referencing the pseudo-column `VALUE`.
6004 pub expr: String,
6005}
6006
6007/// `default` / `checks` are stored as Display-form source so
6008/// `spg-storage` stays free of `spg-sql` dependency — same
6009/// pattern as FunctionDef / ViewDef.
6010#[derive(Debug, Clone, PartialEq, Eq)]
6011pub struct DomainDef {
6012 pub name: String,
6013 pub base_type: DataType,
6014 pub nullable: bool,
6015 pub default: Option<String>,
6016 /// v7.39 (round 260) — each CHECK carries its constraint NAME, so
6017 /// `ALTER DOMAIN … DROP CONSTRAINT <name>` can find it and the
6018 /// violation message can report the constraint that actually failed.
6019 /// PG's auto-naming for an unnamed check is `<domain>_check`, then
6020 /// `_check1`, `_check2`, … (probed).
6021 pub checks: Vec<DomainCheck>,
6022 /// v7.39 (round 258/259) — when this domain was declared over ANOTHER
6023 /// domain (`CREATE DOMAIN child AS parent CHECK (…)`), the parent's
6024 /// name. `base_type` is the ultimate scalar type either way, so
6025 /// without this the parent's constraints were invisible and a value
6026 /// violating them was silently accepted. PG checks the whole chain,
6027 /// base-first, and an `ALTER DOMAIN` on the parent takes effect for
6028 /// the child immediately (probed) — so the chain is walked at check
6029 /// time rather than copied at CREATE time. Catalog FILE_VERSION 74+.
6030 pub base_domain: Option<String>,
6031}
6032
6033/// v7.17.0 Phase 1.4 — catalogued user-defined ENUM type. The
6034/// label vector is order-preserving (PG enum ordering follows the
6035/// declared order). At INSERT/UPDATE on a column bound to this
6036/// enum, the engine looks up the value against `labels` and
6037/// rejects non-members.
6038#[derive(Debug, Clone, PartialEq, Eq)]
6039pub struct EnumDef {
6040 pub name: String,
6041 pub labels: Vec<String>,
6042}
6043
6044/// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE type
6045/// (`CREATE TYPE name AS (field_name field_type, ...)`). Order
6046/// matters: PG composite literals are positional, and SPG mirrors
6047/// that. Stored as ordered `(name, DataType)` pairs to keep the
6048/// codec straightforward and to allow eventual `Value::Composite`
6049/// bodies to encode positionally. Persisted in catalog FILE_VERSION
6050/// 52+; older catalogs deserialise with an empty composite_types
6051/// map. Composite types can be used as a column type by spelling
6052/// the composite's name; the resolution from
6053/// `ColumnSchema.user_composite_type = Some(name)` happens at the
6054/// engine boundary (parallel to `user_enum_type` /
6055/// `user_domain_type`). The dense storage shape — JSON-text body
6056/// keyed by the composite's field list — keeps the codec free of
6057/// recursive `Value` bodies until the full Value::Composite arena
6058/// migration in a later phase.
6059#[derive(Debug, Clone, PartialEq, Eq)]
6060pub struct CompositeDef {
6061 pub name: String,
6062 /// Ordered `(field_name, field_type)` pairs. PG composite
6063 /// literals are positional, so order is part of the type's
6064 /// identity.
6065 pub fields: Vec<(String, DataType)>,
6066 /// v7.39 (round 264) — parallel to `fields`: the USER type name of
6067 /// each field when it is itself a composite (or another named user
6068 /// type). `DataType` has no room for one, so a nested composite
6069 /// field resolved to the parser's Text placeholder and the inner
6070 /// record stayed TEXT — `(x).inner.street` errored, `pg_typeof`
6071 /// said text, and `row_to_json` nested a string instead of an
6072 /// object. Same shape as `ColumnSchema.user_composite_type` and
6073 /// `DomainDef.base_domain`. Catalog FILE_VERSION 76+; an older
6074 /// catalog reads all-None, which is what it meant.
6075 pub field_user_types: Vec<Option<String>>,
6076}
6077
6078/// v7.17.0 Phase 1.2 — catalogued VIEW. The body is stored as the
6079/// raw source text the parser saw between `AS` and the statement
6080/// terminator; the engine re-parses on each invocation. Same
6081/// pattern as `FunctionDef` — keeps `spg-storage` free of
6082/// `spg-sql` dependency.
6083#[derive(Debug, Clone, PartialEq, Eq)]
6084pub struct ViewDef {
6085 pub name: String,
6086 /// Optional `(col, col, …)` rename list. Empty when the body's
6087 /// projected names are used directly.
6088 pub columns: Vec<String>,
6089 /// Raw SELECT source. Display-rendered at storage time so the
6090 /// catalog round-trips a deterministic form regardless of
6091 /// whitespace / comments in the original input. Re-parsed at
6092 /// SELECT-from-view time to materialise as a synthetic CTE.
6093 pub body: String,
6094 /// v7.39 (round 132) — `WITH CHECK OPTION`: 0 = none, 1 = LOCAL,
6095 /// 2 = CASCADED. A storage-local u8 (no dependency on the SQL AST).
6096 /// Persisted from FILE_VERSION 69; older catalogs read back as 0.
6097 pub check_option: u8,
6098}
6099
6100impl SequenceDataType {
6101 /// PG default min/max per AS clause.
6102 pub fn default_bounds(self, increment_positive: bool) -> (i64, i64) {
6103 match self {
6104 Self::SmallInt => {
6105 if increment_positive {
6106 (1, i64::from(i16::MAX))
6107 } else {
6108 (i64::from(i16::MIN), -1)
6109 }
6110 }
6111 Self::Int => {
6112 if increment_positive {
6113 (1, i64::from(i32::MAX))
6114 } else {
6115 (i64::from(i32::MIN), -1)
6116 }
6117 }
6118 Self::BigInt => {
6119 if increment_positive {
6120 (1, i64::MAX)
6121 } else {
6122 (i64::MIN, -1)
6123 }
6124 }
6125 }
6126 }
6127}
6128
6129impl Catalog {
6130 /// v7.37.15 (Phase D) — fleet-wide vacuum pass. Walks every
6131 /// user table and reclaims rows whose delete-commit version is
6132 /// older than `oldest_active_snapshot`. Returns an aggregated
6133 /// report with per-table breakdown so hosts can emit metrics.
6134 ///
6135 /// `dry_run = true` reports the work without doing it. Use it
6136 /// to estimate the cost before scheduling a real pass.
6137 pub fn vacuum_all(
6138 &mut self,
6139 oldest_active_snapshot: u64,
6140 dry_run: bool,
6141 ) -> vacuum::VacuumReport {
6142 let mut total = vacuum::VacuumReport::default();
6143 // Snapshot the table names so we don't hold an immutable
6144 // borrow during the get_mut loop.
6145 let names: Vec<String> = self
6146 .tables
6147 .iter()
6148 .map(|t| t.schema().name.clone())
6149 .collect();
6150 for name in names {
6151 let Some(t) = self.get_mut(&name) else {
6152 continue;
6153 };
6154 let r = t.vacuum(oldest_active_snapshot, dry_run);
6155 if r.rows_reclaimed > 0 {
6156 total.per_table.push((name, r.rows_reclaimed));
6157 }
6158 total.rows_reclaimed += r.rows_reclaimed;
6159 total.rows_examined += r.rows_examined;
6160 }
6161 total
6162 }
6163
6164 pub const fn new() -> Self {
6165 Self {
6166 cold_read_stats: ColdReadStats {
6167 cold_reads: core::sync::atomic::AtomicU64::new(0),
6168 },
6169 tables: Vec::new(),
6170 by_name: BTreeMap::new(),
6171 temp_prefix: None,
6172 case_insensitive_names: false,
6173 dirty_tables: alloc::collections::BTreeSet::new(),
6174 dirty_nontable: alloc::collections::BTreeSet::new(),
6175 next_rel_id: 0,
6176 cold_segments: Vec::new(),
6177 functions: BTreeMap::new(),
6178 triggers: Vec::new(),
6179 rules: Vec::new(),
6180 statistics_ext: Vec::new(),
6181 large_objects: alloc::collections::BTreeMap::new(),
6182 sequences: BTreeMap::new(),
6183 schema_acl: Vec::new(),
6184 database_acl: Vec::new(),
6185 views: BTreeMap::new(),
6186 materialized_views: BTreeMap::new(),
6187 enum_types: BTreeMap::new(),
6188 domain_types: BTreeMap::new(),
6189 comments: BTreeMap::new(),
6190 db_role_settings: BTreeMap::new(),
6191 replication_slots: BTreeMap::new(),
6192 db_collation: None,
6193 created_databases: alloc::collections::BTreeSet::new(),
6194 composite_types: BTreeMap::new(),
6195 schemas: alloc::collections::BTreeSet::new(),
6196 }
6197 }
6198
6199 /// v7.12.4 — read-only view of catalogued user-defined
6200 /// functions. Engine callers go through here to look up the
6201 /// function body before re-parsing it for invocation.
6202 pub const fn functions(&self) -> &BTreeMap<String, FunctionDef> {
6203 &self.functions
6204 }
6205
6206 /// v7.12.4 — register a new user-defined function. With
6207 /// `or_replace = false`, errors if the name is taken. The
6208 /// engine validates the body before passing it here.
6209 pub fn create_function(
6210 &mut self,
6211 def: FunctionDef,
6212 or_replace: bool,
6213 ) -> Result<(), StorageError> {
6214 // v7.39 (read01 round 62) — functions are keyed by SIGNATURE, not by
6215 // name: `f(int)` and `f(text)` are two functions, as in PG. Keying by
6216 // name alone made a second overload an "already exists" error — so a
6217 // pg_dump carrying an overload set could not restore — and, worse, a
6218 // call to one overload silently ran the other.
6219 let key = function_signature_key(&def.name, &def.args_repr);
6220 if !or_replace && self.functions.contains_key(&key) {
6221 return Err(StorageError::Corrupt(format!(
6222 "function {:?} already exists (drop or use CREATE OR REPLACE)",
6223 def.name
6224 )));
6225 }
6226 self.functions.insert(key, def);
6227 Ok(())
6228 }
6229
6230 /// v7.39 (read01 round 62) — every overload of `name`.
6231 #[must_use]
6232 pub fn functions_named(&self, name: &str) -> Vec<&FunctionDef> {
6233 self.functions
6234 .values()
6235 .filter(|f| f.name.eq_ignore_ascii_case(name))
6236 .collect()
6237 }
6238
6239 /// v7.39 (read01 round 62) — one overload, by its signature key.
6240 #[must_use]
6241 pub fn function_by_key(&self, key: &str) -> Option<&FunctionDef> {
6242 self.functions.get(key)
6243 }
6244
6245 /// v7.39 (read01 round 62) — drop ONE overload. `true` if it was there.
6246 pub fn drop_function_by_key(&mut self, key: &str) -> bool {
6247 self.functions.remove(key).is_some()
6248 }
6249
6250 /// v7.12.4 — remove a user-defined function by name. Returns
6251 /// `true` if a function was removed, `false` if none matched.
6252 /// Caller decides whether to surface `if_exists` semantics.
6253 /// v7.39 (read01 round 62) — with no signature, PG drops the function only
6254 /// when the name is unambiguous. SPG mirrors that: this removes EVERY
6255 /// overload of `name`, and the caller (ddl.rs) refuses the ambiguous case
6256 /// before getting here.
6257 pub fn drop_function(&mut self, name: &str) -> bool {
6258 let keys: Vec<String> = self
6259 .functions
6260 .iter()
6261 .filter(|(_, f)| f.name.eq_ignore_ascii_case(name))
6262 .map(|(k, _)| k.clone())
6263 .collect();
6264 let hit = !keys.is_empty();
6265 for k in keys {
6266 self.functions.remove(&k);
6267 }
6268 hit
6269 }
6270
6271 /// v7.17.0 — read-only handle to catalogued sequences.
6272 /// v7.39 (read01 round 60) — the `public` schema's ACL (PG nspacl).
6273 #[must_use]
6274 pub fn schema_acl(&self) -> &[AclItem] {
6275 &self.schema_acl
6276 }
6277
6278 pub fn schema_acl_mut(&mut self) -> &mut Vec<AclItem> {
6279 &mut self.schema_acl
6280 }
6281
6282 /// v7.39 (read01 round 60) — the database's ACL.
6283 #[must_use]
6284 pub fn database_acl(&self) -> &[AclItem] {
6285 &self.database_acl
6286 }
6287
6288 pub fn database_acl_mut(&mut self) -> &mut Vec<AclItem> {
6289 &mut self.database_acl
6290 }
6291
6292 /// v7.39 (read01 round 60) — mutable sequence access, for GRANT.
6293 /// v7.39 (round 469) — resolves the session's temporary sequence
6294 /// first, like its read-only twin. `nextval` and `setval` reach the
6295 /// map through here, so a temporary sequence shadowing a permanent one
6296 /// advances the temporary one — measured against PG18, where the
6297 /// permanent sequence's counter is untouched while the temp exists.
6298 pub fn sequence_mut(&mut self, name: &str) -> Option<&mut SequenceDef> {
6299 let key = self.sequence_key(name);
6300 self.sequences.get_mut(&key)
6301 }
6302
6303 /// v7.39 (read01 round 61) — mutable function access, for GRANT.
6304 pub fn function_mut(&mut self, name: &str) -> Option<&mut FunctionDef> {
6305 self.functions.get_mut(name)
6306 }
6307
6308 /// Every catalogued sequence, temp ones included under their mangled
6309 /// storage names. Listing code filters these through
6310 /// [`Self::listed_name`]; anything resolving ONE name by its logical
6311 /// spelling wants [`Self::sequence`] instead.
6312 pub const fn sequences_all(&self) -> &BTreeMap<String, SequenceDef> {
6313 &self.sequences
6314 }
6315
6316 /// v7.39 (round 469) — resolve one sequence by its logical name, the
6317 /// session's temporary one winning over a permanent one of the same
6318 /// name. The same rule [`Self::resolve_index`] applies to tables.
6319 #[must_use]
6320 pub fn sequence(&self, name: &str) -> Option<&SequenceDef> {
6321 if let Some(mangled) = self.temp_name_for(name)
6322 && let Some(def) = self.sequences.get(&mangled)
6323 {
6324 return Some(def);
6325 }
6326 self.sequences.get(name)
6327 }
6328
6329 /// Does a sequence of this logical name exist for this session?
6330 #[must_use]
6331 pub fn has_sequence(&self, name: &str) -> bool {
6332 self.sequence(name).is_some()
6333 }
6334
6335 /// The storage key a sequence of this logical name resolves to — the
6336 /// session's temp mangling when it has one, else the name itself.
6337 #[must_use]
6338 pub fn sequence_key(&self, name: &str) -> String {
6339 if let Some(mangled) = self.temp_name_for(name)
6340 && self.sequences.contains_key(&mangled)
6341 {
6342 return mangled;
6343 }
6344 name.into()
6345 }
6346
6347 /// v7.17.0 — register a new SEQUENCE. Errors if `name`
6348 /// collides with an existing sequence and `if_not_exists`
6349 /// is false.
6350 pub fn create_sequence(
6351 &mut self,
6352 def: SequenceDef,
6353 if_not_exists: bool,
6354 ) -> Result<(), StorageError> {
6355 if self.sequences.contains_key(&def.name) {
6356 if if_not_exists {
6357 return Ok(());
6358 }
6359 // v7.39 (read01 round 47) — a sequence is a relation to PG (42P07).
6360 return Err(StorageError::Corrupt(format!(
6361 "relation {:?} already exists",
6362 def.name
6363 )));
6364 }
6365 self.mark_nontable_dirty(NonTableKind::Sequence, &def.name);
6366 self.sequences.insert(def.name.clone(), def);
6367 Ok(())
6368 }
6369
6370 /// v7.17.0 — remove a SEQUENCE by name. Returns `true` if a
6371 /// sequence was removed, `false` if none matched. Caller
6372 /// surfaces IF EXISTS semantics.
6373 /// v7.39 (read01 round 49) — `ALTER SEQUENCE old RENAME TO new`.
6374 /// Errors when `old` is missing or `new` is taken; the SequenceDef's own
6375 /// `name` field is rewritten so it stays self-describing.
6376 pub fn rename_sequence(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6377 if !self.sequences.contains_key(old) {
6378 return Err(StorageError::Corrupt(format!(
6379 "relation {old:?} does not exist"
6380 )));
6381 }
6382 if self.sequences.contains_key(new) {
6383 return Err(StorageError::Corrupt(format!(
6384 "relation {new:?} already exists"
6385 )));
6386 }
6387 self.mark_nontable_dirty(NonTableKind::Sequence, old);
6388 self.mark_nontable_dirty(NonTableKind::Sequence, new);
6389 if let Some(mut def) = self.sequences.remove(old) {
6390 def.name = new.to_string();
6391 self.sequences.insert(new.to_string(), def);
6392 }
6393 Ok(())
6394 }
6395
6396 pub fn drop_sequence(&mut self, name: &str) -> bool {
6397 self.mark_nontable_dirty(NonTableKind::Sequence, name);
6398 self.sequences.remove(name).is_some()
6399 }
6400
6401 /// v7.17.0 — atomic nextval. Increments `last_value` per
6402 /// `increment`, returns the new value, sets `is_called`.
6403 /// Returns an error on CYCLE-less overflow.
6404 /// v7.39 (round 497) — the counter state of every sequence, for
6405 /// carrying across a commit install.
6406 ///
6407 /// A sequence's VALUE is not transactional in PG: `nextval` advances
6408 /// shared state that a rollback does not give back, because two
6409 /// sessions must never receive the same number. SPG keeps sequences in
6410 /// the catalog, and a transaction works on a catalog CLONE, so
6411 /// installing that clone at COMMIT would restore whatever the counter
6412 /// was at BEGIN. These two let the install put the live counters back.
6413 #[must_use]
6414 pub fn sequence_counters(&self) -> Vec<(String, i64, bool)> {
6415 self.sequences
6416 .iter()
6417 .map(|(k, d)| (k.clone(), d.last_value, d.is_called))
6418 .collect()
6419 }
6420
6421 /// Restore counters saved by [`Self::sequence_counters`], for the
6422 /// sequences that still exist. A sequence the transaction CREATED is
6423 /// absent from the saved set and keeps the value it was given.
6424 pub fn restore_sequence_counters(&mut self, saved: &[(String, i64, bool)]) {
6425 for (k, last, called) in saved {
6426 if let Some(d) = self.sequences.get_mut(k) {
6427 d.last_value = *last;
6428 d.is_called = *called;
6429 }
6430 }
6431 }
6432
6433 pub fn sequence_next_value(&mut self, name: &str) -> Result<i64, StorageError> {
6434 let key = self.sequence_key(name);
6435 let Some(seq) = self.sequences.get_mut(&key) else {
6436 return Err(StorageError::TableNotFound { name: name.into() });
6437 };
6438 // PG semantics: when !is_called (fresh sequence or
6439 // setval(_, false)), the next nextval returns the stored
6440 // `last_value`. When is_called, it advances by `increment`
6441 // and CYCLE-wraps on overflow.
6442 let candidate = if seq.is_called {
6443 let next = seq.last_value.checked_add(seq.increment).ok_or_else(|| {
6444 StorageError::Corrupt(format!("sequence {name:?} arithmetic overflow"))
6445 })?;
6446 if seq.increment > 0 {
6447 if next > seq.max_value {
6448 if seq.cycle {
6449 seq.min_value
6450 } else {
6451 // v7.39 (round 220) — PG's 2200H wording, not a
6452 // Corrupt-classed error.
6453 return Err(StorageError::SequenceExhausted {
6454 name: name.into(),
6455 limit: seq.max_value,
6456 is_max: true,
6457 });
6458 }
6459 } else {
6460 next
6461 }
6462 } else if next < seq.min_value {
6463 if seq.cycle {
6464 seq.max_value
6465 } else {
6466 return Err(StorageError::SequenceExhausted {
6467 name: name.into(),
6468 limit: seq.min_value,
6469 is_max: false,
6470 });
6471 }
6472 } else {
6473 next
6474 }
6475 } else {
6476 seq.last_value
6477 };
6478 seq.last_value = candidate;
6479 seq.is_called = true;
6480 Ok(candidate)
6481 }
6482
6483 /// v7.17.0 — currval. Errors if the session has never called
6484 /// nextval on this sequence (PG semantics). At the catalog
6485 /// level we approximate "session" with "is_called persisted";
6486 /// the engine session-tracking layer can wrap this for the
6487 /// strict per-session semantics later.
6488 pub fn sequence_current_value(&self, name: &str) -> Result<i64, StorageError> {
6489 let Some(seq) = self.sequences.get(name) else {
6490 return Err(StorageError::TableNotFound { name: name.into() });
6491 };
6492 if !seq.is_called {
6493 return Err(StorageError::Corrupt(format!(
6494 "currval of sequence {name:?} is not yet defined in this session"
6495 )));
6496 }
6497 Ok(seq.last_value)
6498 }
6499
6500 /// v7.17.0 — setval(name, value [, is_called]). PG returns
6501 /// `value` regardless. `is_called=true` means the NEXT
6502 /// nextval will return `value + increment`; `is_called=false`
6503 /// means the next nextval will return `value`.
6504 pub fn sequence_set_value(
6505 &mut self,
6506 name: &str,
6507 value: i64,
6508 is_called: bool,
6509 ) -> Result<i64, StorageError> {
6510 let key = self.sequence_key(name);
6511 let Some(seq) = self.sequences.get_mut(&key) else {
6512 return Err(StorageError::TableNotFound { name: name.into() });
6513 };
6514 // v7.39 (round 244) — PG refuses a value outside the sequence's
6515 // range (22003); SPG accepted it silently, leaving last_value out
6516 // of bounds.
6517 if value < seq.min_value || value > seq.max_value {
6518 return Err(StorageError::Unsupported(format!(
6519 "setval: value {value} is out of bounds for sequence \"{name}\" ({}..{})",
6520 seq.min_value, seq.max_value
6521 )));
6522 }
6523 seq.last_value = value;
6524 seq.is_called = is_called;
6525 Ok(value)
6526 }
6527
6528 /// v7.17.0 Phase 1.2 — read-only handle to catalogued views. Temp ones
6529 /// are in here under their mangled storage names; listing code filters
6530 /// through [`Self::listed_name`], and anything resolving ONE name by
6531 /// its logical spelling wants [`Self::view`].
6532 pub const fn views_all(&self) -> &BTreeMap<String, ViewDef> {
6533 &self.views
6534 }
6535
6536 /// v7.39 (round 469) — resolve one view by its logical name, the
6537 /// session's temporary one winning over a permanent one of the same
6538 /// name.
6539 #[must_use]
6540 pub fn view(&self, name: &str) -> Option<&ViewDef> {
6541 if let Some(mangled) = self.temp_name_for(name)
6542 && let Some(def) = self.views.get(&mangled)
6543 {
6544 return Some(def);
6545 }
6546 self.views.get(name)
6547 }
6548
6549 /// Does a view of this logical name exist for this session?
6550 #[must_use]
6551 pub fn has_view(&self, name: &str) -> bool {
6552 self.view(name).is_some()
6553 }
6554
6555 /// The storage key a view of this logical name resolves to.
6556 #[must_use]
6557 pub fn view_key(&self, name: &str) -> String {
6558 if let Some(mangled) = self.temp_name_for(name)
6559 && self.views.contains_key(&mangled)
6560 {
6561 return mangled;
6562 }
6563 name.into()
6564 }
6565
6566 /// v7.17.0 Phase 1.2 — install a VIEW. `or_replace=true`
6567 /// overwrites an existing entry; `if_not_exists=true` is a
6568 /// silent no-op when the name is taken. Errors if both flags
6569 /// are off and the name collides.
6570 pub fn create_view(
6571 &mut self,
6572 def: ViewDef,
6573 or_replace: bool,
6574 if_not_exists: bool,
6575 ) -> Result<(), StorageError> {
6576 if self.views.contains_key(&def.name) {
6577 if or_replace {
6578 self.mark_nontable_dirty(NonTableKind::View, &def.name);
6579 self.mark_nontable_dirty(NonTableKind::View, &def.name);
6580 self.views.insert(def.name.clone(), def);
6581 return Ok(());
6582 }
6583 if if_not_exists {
6584 return Ok(());
6585 }
6586 // v7.39 (read01 round 47) — a view is a relation to PG (42P07).
6587 return Err(StorageError::Corrupt(format!(
6588 "relation {:?} already exists",
6589 def.name
6590 )));
6591 }
6592 // Reject name collision with tables / sequences — same
6593 // namespace per PG.
6594 if self.by_name.contains_key(&def.name) {
6595 return Err(StorageError::Corrupt(format!(
6596 "view {:?} would shadow an existing table",
6597 def.name
6598 )));
6599 }
6600 if self.sequences.contains_key(&def.name) {
6601 return Err(StorageError::Corrupt(format!(
6602 "view {:?} would shadow an existing sequence",
6603 def.name
6604 )));
6605 }
6606 self.views.insert(def.name.clone(), def);
6607 Ok(())
6608 }
6609
6610 /// v7.17.0 Phase 1.2 — remove a view by name. Returns true if
6611 /// a view was removed.
6612 pub fn drop_view(&mut self, name: &str) -> bool {
6613 self.mark_nontable_dirty(NonTableKind::View, name);
6614 self.views.remove(name).is_some()
6615 }
6616
6617 /// v7.17.0 Phase 1.3 — read-only handle to the materialised-
6618 /// view source registry. Each entry pairs with a regular
6619 /// table of the same name that holds the cached rows.
6620 pub const fn materialized_views(&self) -> &BTreeMap<String, String> {
6621 &self.materialized_views
6622 }
6623
6624 /// v7.17.0 Phase 1.3 — register a source for a materialised
6625 /// view. Caller has already created the backing table.
6626 pub fn register_materialized_view(&mut self, name: String, body: String) {
6627 self.mark_nontable_dirty(NonTableKind::MaterializedView, &name);
6628 self.materialized_views.insert(name, body);
6629 }
6630
6631 /// v7.17.0 Phase 1.3 — drop the source registry entry. Returns
6632 /// true if a source was unregistered. Caller separately drops
6633 /// the backing table.
6634 pub fn drop_materialized_view_source(&mut self, name: &str) -> bool {
6635 self.mark_nontable_dirty(NonTableKind::MaterializedView, name);
6636 self.materialized_views.remove(name).is_some()
6637 }
6638
6639 /// v7.17.0 Phase 1.4 — read-only handle to user-defined ENUM
6640 /// catalog.
6641 pub const fn enum_types(&self) -> &BTreeMap<String, EnumDef> {
6642 &self.enum_types
6643 }
6644
6645 /// v7.17.0 Phase 1.4 — install a new ENUM type. Errors if
6646 /// `name` collides with an existing enum (no IF NOT EXISTS
6647 /// per PG semantics for CREATE TYPE).
6648 pub fn create_enum_type(&mut self, def: EnumDef) -> Result<(), StorageError> {
6649 if self.enum_types.contains_key(&def.name) {
6650 return Err(StorageError::Corrupt(format!(
6651 "type {:?} already exists",
6652 def.name
6653 )));
6654 }
6655 self.mark_nontable_dirty(NonTableKind::EnumType, &def.name);
6656 self.enum_types.insert(def.name.clone(), def);
6657 Ok(())
6658 }
6659
6660 /// v7.17.0 Phase 1.4 — drop an ENUM type by name. Returns
6661 /// true if a type was removed.
6662 /// v7.37 D.55 — `ALTER TYPE … ADD VALUE`. Appends `label` to an existing
6663 /// enum's ordered label list, or inserts it before/after an existing label.
6664 /// `if_not_exists` makes a duplicate a no-op; otherwise a duplicate errors.
6665 /// Returns `Ok(true)` if a label was added, `Ok(false)` if it already existed
6666 /// (only possible under `if_not_exists`).
6667 /// v7.39 (read01 round 49) — `ALTER TYPE t RENAME VALUE 'old' TO 'new'`.
6668 /// The parser used to swallow this form as a no-op, so the rename was
6669 /// accepted and silently ignored. Renaming in place keeps the label's
6670 /// sort position, which is what PG does (enumsortorder is untouched).
6671 pub fn rename_enum_value(
6672 &mut self,
6673 type_name: &str,
6674 old: &str,
6675 new: &str,
6676 ) -> Result<(), StorageError> {
6677 let def = self
6678 .enum_types
6679 .get_mut(type_name)
6680 .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
6681 if def.labels.iter().any(|l| l == new) {
6682 return Err(StorageError::Corrupt(format!(
6683 "enum label {new:?} already exists"
6684 )));
6685 }
6686 let at = def.labels.iter().position(|l| l == old).ok_or_else(|| {
6687 StorageError::Corrupt(format!("{old:?} is not an existing enum label"))
6688 })?;
6689 def.labels[at] = new.to_string();
6690 Ok(())
6691 }
6692
6693 /// v7.39 (read01 round 50) — set (or, with `None`, remove) the comment on
6694 /// an object. `key` is the canonical `"<kind>:<name>"` form.
6695 pub fn set_comment(&mut self, key: &str, text: Option<&str>) {
6696 match text {
6697 Some(t) => {
6698 self.comments.insert(key.to_string(), t.to_string());
6699 }
6700 None => {
6701 self.comments.remove(key);
6702 }
6703 }
6704 }
6705
6706 /// v7.39 (read01 round 50) — the comment on an object, if any.
6707 #[must_use]
6708 pub fn comment(&self, key: &str) -> Option<&str> {
6709 self.comments.get(key).map(String::as_str)
6710 }
6711
6712 /// v7.39 (round 547) — record a GUC default for a scope. An empty
6713 /// database or role name is PG's oid 0 ("all"). `None` value
6714 /// removes just that parameter, as PG's RESET does.
6715 pub fn set_db_role_setting(
6716 &mut self,
6717 database: &str,
6718 role: &str,
6719 param: &str,
6720 value: Option<&str>,
6721 ) {
6722 let key = (database.to_string(), role.to_string());
6723 match value {
6724 Some(v) => {
6725 self.db_role_settings
6726 .entry(key)
6727 .or_default()
6728 .insert(param.to_ascii_lowercase(), v.to_string());
6729 }
6730 None => {
6731 if let Some(m) = self.db_role_settings.get_mut(&key) {
6732 m.remove(¶m.to_ascii_lowercase());
6733 if m.is_empty() {
6734 self.db_role_settings.remove(&key);
6735 }
6736 }
6737 }
6738 }
6739 }
6740
6741 /// v7.39 (round 550) — create a replication slot. `Err` carries
6742 /// PG's own message for a duplicate.
6743 ///
6744 /// # Errors
6745 /// When a slot of that name already exists.
6746 pub fn create_replication_slot(
6747 &mut self,
6748 name: &str,
6749 plugin: &str,
6750 slot_type: &str,
6751 ) -> Result<(), String> {
6752 if self.replication_slots.contains_key(name) {
6753 return Err(alloc::format!("replication slot \"{name}\" already exists"));
6754 }
6755 self.replication_slots.insert(
6756 name.to_string(),
6757 (plugin.to_string(), slot_type.to_string()),
6758 );
6759 Ok(())
6760 }
6761
6762 /// # Errors
6763 /// When no slot of that name exists — PG's message, and the case
6764 /// that used to report success.
6765 pub fn drop_replication_slot(&mut self, name: &str) -> Result<(), String> {
6766 if self.replication_slots.remove(name).is_none() {
6767 return Err(alloc::format!("replication slot \"{name}\" does not exist"));
6768 }
6769 Ok(())
6770 }
6771
6772 #[must_use]
6773 /// v7.38.18 (S1) — the collation this database was created with.
6774 /// `"C"` when nothing was recorded, which is what an older catalog
6775 /// and a default `initdb`-less start both mean.
6776 pub fn db_collation(&self) -> &str {
6777 self.db_collation.as_deref().unwrap_or("C")
6778 }
6779
6780 /// Record the creation collation. Refused once one is set, because
6781 /// every index key already in this database was built under it —
6782 /// the same refusal PostgreSQL gives `ALTER DATABASE … LC_COLLATE`,
6783 /// and for the same reason.
6784 ///
6785 /// `Ok(false)` when the value asked for is the one already in force,
6786 /// so a host that passes its environment on every start is not an
6787 /// error.
6788 pub fn set_db_collation(&mut self, name: &str) -> Result<bool, StorageError> {
6789 if self.db_collation.as_deref() == Some(name) {
6790 return Ok(false);
6791 }
6792 if self.db_collation.is_none() && name.eq_ignore_ascii_case("C") {
6793 return Ok(false);
6794 }
6795 if self.db_collation.is_some() || !self.tables.is_empty() {
6796 return Err(StorageError::Corrupt(format!(
6797 "database collation is already {:?} and cannot be changed; \
6798 PostgreSQL refuses this too, because every index key here \
6799 was built under it",
6800 self.db_collation()
6801 )));
6802 }
6803 self.db_collation = Some(name.into());
6804 Ok(true)
6805 }
6806
6807 /// The user said so, in SQL: `CREATE DATABASE … LC_COLLATE 'x'`.
6808 ///
6809 /// Differs from [`Self::set_db_collation`] in one way, and the
6810 /// difference is the whole point: this REPLACES a collation the
6811 /// database already has, as long as no table has been created yet.
6812 /// The refusal in `set_db_collation` exists because index keys were
6813 /// built under the old collation — with no tables, none were.
6814 ///
6815 /// The case it is for: a server stamps the container's `LANG` on a
6816 /// fresh database at startup, and the customer's bootstrap script
6817 /// then says `CREATE DATABASE app LC_COLLATE 'de_DE.utf8'`. What the
6818 /// script asked for beats what the container happened to export.
6819 ///
6820 /// `Ok(false)` when a table already exists — the caller warns rather
6821 /// than failing, because PostgreSQL would have made a SEPARATE
6822 /// database here and returned success, and failing a bootstrap
6823 /// script is a customer change.
6824 pub fn declare_db_collation(&mut self, name: &str) -> bool {
6825 if self.db_collation.as_deref() == Some(name) {
6826 return true;
6827 }
6828 if !self.tables.is_empty() {
6829 return false;
6830 }
6831 self.db_collation = Some(name.into());
6832 true
6833 }
6834
6835 /// Record a name a `CREATE DATABASE` asked for; `true` when new.
6836 pub fn record_created_database(&mut self, name: &str) -> bool {
6837 self.created_databases.insert(name.to_string())
6838 }
6839
6840 /// The names `CREATE DATABASE` has been asked for.
6841 pub const fn created_databases(&self) -> &alloc::collections::BTreeSet<String> {
6842 &self.created_databases
6843 }
6844
6845 pub const fn replication_slots(&self) -> &BTreeMap<String, (String, String)> {
6846 &self.replication_slots
6847 }
6848
6849 /// PG's RESET ALL: drops this scope's whole entry, leaving the
6850 /// other scopes alone — measured on PG18, where `ALTER ROLE r RESET
6851 /// ALL` left the ALL, the database and the role-in-database rows.
6852 pub fn reset_db_role_settings(&mut self, database: &str, role: &str) {
6853 self.db_role_settings
6854 .remove(&(database.to_string(), role.to_string()));
6855 }
6856
6857 #[must_use]
6858 pub const fn db_role_settings(&self) -> &BTreeMap<(String, String), BTreeMap<String, String>> {
6859 &self.db_role_settings
6860 }
6861
6862 /// v7.39 (read01 round 50) — every `(key, text)` pair, for the
6863 /// pg_description view.
6864 #[must_use]
6865 pub const fn comments(&self) -> &BTreeMap<String, String> {
6866 &self.comments
6867 }
6868
6869 /// v7.39 (read01 round 50) — drop every comment whose key names `obj`
6870 /// (the object itself and, for a table, its columns). Called when the
6871 /// object is dropped so a later object of the same name doesn't inherit
6872 /// a stale comment.
6873 pub fn drop_comments_for(&mut self, kind: &str, name: &str) {
6874 let exact = alloc::format!("{kind}:{name}");
6875 let col_prefix = alloc::format!("column:{name}.");
6876 self.comments
6877 .retain(|k, _| *k != exact && !k.starts_with(&col_prefix));
6878 }
6879
6880 pub fn add_enum_value(
6881 &mut self,
6882 type_name: &str,
6883 label: &str,
6884 if_not_exists: bool,
6885 position: Option<(bool, String)>,
6886 ) -> Result<bool, StorageError> {
6887 self.mark_nontable_dirty(NonTableKind::EnumType, type_name);
6888 let def = self
6889 .enum_types
6890 .get_mut(type_name)
6891 .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
6892 if def.labels.iter().any(|l| l == label) {
6893 if if_not_exists {
6894 return Ok(false);
6895 }
6896 // v7.39 (read01 round 49) — PG wording (42710 at the wire).
6897 return Err(StorageError::Corrupt(format!(
6898 "enum label {label:?} already exists"
6899 )));
6900 }
6901 match position {
6902 None => def.labels.push(label.to_string()),
6903 Some((is_before, anchor)) => {
6904 let at = def
6905 .labels
6906 .iter()
6907 .position(|l| l == &anchor)
6908 .ok_or_else(|| {
6909 StorageError::Corrupt(format!(
6910 "enum label {anchor:?} does not exist in type {type_name:?}"
6911 ))
6912 })?;
6913 let idx = if is_before { at } else { at + 1 };
6914 def.labels.insert(idx, label.to_string());
6915 }
6916 }
6917 Ok(true)
6918 }
6919
6920 pub fn drop_enum_type(&mut self, name: &str) -> bool {
6921 self.mark_nontable_dirty(NonTableKind::EnumType, name);
6922 self.enum_types.remove(name).is_some()
6923 }
6924
6925 /// v7.17.0 Phase 1.5 — read-only handle to DOMAIN catalog.
6926 pub const fn domain_types(&self) -> &BTreeMap<String, DomainDef> {
6927 &self.domain_types
6928 }
6929
6930 /// v7.17.0 Phase 1.5 — install a DOMAIN. Errors on collision
6931 /// with an existing domain.
6932 pub fn create_domain_type(&mut self, def: DomainDef) -> Result<(), StorageError> {
6933 if self.domain_types.contains_key(&def.name) {
6934 return Err(StorageError::Corrupt(format!(
6935 "domain {:?} already exists",
6936 def.name
6937 )));
6938 }
6939 self.mark_nontable_dirty(NonTableKind::DomainType, &def.name);
6940 self.domain_types.insert(def.name.clone(), def);
6941 Ok(())
6942 }
6943
6944 /// v7.17.0 Phase 1.5 — drop a DOMAIN by name.
6945 pub fn drop_domain_type(&mut self, name: &str) -> bool {
6946 self.mark_nontable_dirty(NonTableKind::DomainType, name);
6947 self.domain_types.remove(name).is_some()
6948 }
6949
6950 /// v7.37.42-T2 ζ-B — read-only handle to user-defined COMPOSITE
6951 /// catalog. Used by the engine to resolve
6952 /// `ColumnSchema.user_composite_type` lookups + by
6953 /// information_schema-style introspection.
6954 pub const fn composite_types(&self) -> &BTreeMap<String, CompositeDef> {
6955 &self.composite_types
6956 }
6957
6958 /// v7.37.42-T2 ζ-B — install a new COMPOSITE type. Errors if
6959 /// `name` already exists in the composite registry (PG forbids
6960 /// IF NOT EXISTS on CREATE TYPE composite; the engine surfaces
6961 /// the collision with the existing name).
6962 pub fn create_composite_type(&mut self, def: CompositeDef) -> Result<(), StorageError> {
6963 if self.composite_types.contains_key(&def.name) {
6964 return Err(StorageError::Corrupt(format!(
6965 "type {:?} already exists",
6966 def.name
6967 )));
6968 }
6969 self.mark_nontable_dirty(NonTableKind::CompositeType, &def.name);
6970 self.composite_types.insert(def.name.clone(), def);
6971 Ok(())
6972 }
6973
6974 /// v7.37.42-T2 ζ-B — drop a COMPOSITE type by name. Returns
6975 /// true if a type was removed.
6976 pub fn drop_composite_type(&mut self, name: &str) -> bool {
6977 self.mark_nontable_dirty(NonTableKind::CompositeType, name);
6978 self.composite_types.remove(name).is_some()
6979 }
6980
6981 /// v7.17.0 Phase 1.6 — read-only handle to the user-created
6982 /// schema registry. Built-in schemas (`public`, `pg_catalog`,
6983 /// `information_schema`) are NOT included here; use
6984 /// [`schema_exists`](Self::schema_exists) for the full
6985 /// check.
6986 pub const fn user_schemas(&self) -> &alloc::collections::BTreeSet<String> {
6987 &self.schemas
6988 }
6989
6990 /// v7.17.0 Phase 1.6 — schema-name resolver. Returns true
6991 /// for built-in schemas + every user-CREATEd one. Used by
6992 /// CREATE SCHEMA collision checks and (future) by
6993 /// information_schema.schemata.
6994 pub fn schema_exists(&self, name: &str) -> bool {
6995 is_builtin_schema(name) || self.schemas.contains(name)
6996 }
6997
6998 /// v7.17.0 Phase 1.6 — register a new schema. Errors if the
6999 /// name already exists and `if_not_exists=false`. Built-in
7000 /// names cannot be redeclared.
7001 pub fn create_schema(&mut self, name: String, if_not_exists: bool) -> Result<(), StorageError> {
7002 if is_builtin_schema(&name) {
7003 if if_not_exists {
7004 return Ok(());
7005 }
7006 return Err(StorageError::Corrupt(format!(
7007 "schema {name:?} is built-in and cannot be redeclared"
7008 )));
7009 }
7010 if self.schemas.contains(&name) {
7011 if if_not_exists {
7012 return Ok(());
7013 }
7014 return Err(StorageError::Corrupt(format!(
7015 "schema {name:?} already exists"
7016 )));
7017 }
7018 self.schemas.insert(name);
7019 Ok(())
7020 }
7021
7022 /// v7.17.0 Phase 1.6 — drop a user-created schema. Returns
7023 /// true if a schema was removed. Built-in names always
7024 /// return false (cannot be dropped). Tables that previously
7025 /// used the schema as a prefix keep their bare name and stay
7026 /// queryable — this is the "prefix routing, not isolation"
7027 /// posture documented in v7.17 Phase 1.6.
7028 pub fn drop_schema(&mut self, name: &str) -> Result<bool, StorageError> {
7029 if is_builtin_schema(name) {
7030 return Err(StorageError::Corrupt(format!(
7031 "schema {name:?} is built-in and cannot be dropped"
7032 )));
7033 }
7034 Ok(self.schemas.remove(name))
7035 }
7036
7037 /// v7.17.0 — ALTER SEQUENCE option merge. Caller-provided
7038 /// updates overwrite the matching fields; unset fields keep
7039 /// their stored values. RESTART variants update last_value
7040 /// directly per PG: `RESTART` resets to current `start`;
7041 /// `RESTART WITH n` resets to `n`.
7042 #[allow(clippy::too_many_arguments)]
7043 pub fn alter_sequence(
7044 &mut self,
7045 name: &str,
7046 increment: Option<i64>,
7047 min_value: Option<i64>,
7048 max_value: Option<i64>,
7049 start: Option<i64>,
7050 restart: Option<Option<i64>>,
7051 cache: Option<i64>,
7052 cycle: Option<bool>,
7053 owned_by: Option<Option<(String, String)>>,
7054 ) -> Result<(), StorageError> {
7055 self.mark_nontable_dirty(NonTableKind::Sequence, name);
7056 let Some(seq) = self.sequences.get_mut(name) else {
7057 return Err(StorageError::TableNotFound { name: name.into() });
7058 };
7059 if let Some(v) = increment {
7060 seq.increment = v;
7061 }
7062 if let Some(v) = min_value {
7063 seq.min_value = v;
7064 }
7065 if let Some(v) = max_value {
7066 seq.max_value = v;
7067 }
7068 if let Some(v) = start {
7069 seq.start = v;
7070 }
7071 if let Some(restart_value) = restart {
7072 seq.last_value = restart_value.unwrap_or(seq.start);
7073 seq.is_called = false;
7074 }
7075 if let Some(v) = cache {
7076 seq.cache = v;
7077 }
7078 if let Some(v) = cycle {
7079 seq.cycle = v;
7080 }
7081 if let Some(v) = owned_by {
7082 seq.owned_by = v;
7083 }
7084 Ok(())
7085 }
7086
7087 /// v7.12.4 — read-only slice of all catalogued triggers.
7088 /// Engine row-write paths filter this by (table, event,
7089 /// timing) and fire matches in slice order.
7090 pub fn triggers(&self) -> &[TriggerDef] {
7091 &self.triggers
7092 }
7093
7094 /// v7.15.0 — mutable handle to the trigger slice for
7095 /// `ALTER TABLE … RENAME COLUMN`, which rewrites every
7096 /// `update_columns` entry that referenced the renamed
7097 /// column.
7098 pub fn triggers_mut(&mut self) -> &mut Vec<TriggerDef> {
7099 &mut self.triggers
7100 }
7101
7102 /// v7.12.4 — register a new trigger. With `or_replace = false`,
7103 /// errors when a trigger with the same name already exists on
7104 /// the same table (PG scoping rule — trigger names are
7105 /// per-table, not global). Trigger function must already
7106 /// exist in the catalog at registration time.
7107 pub fn create_trigger(
7108 &mut self,
7109 def: TriggerDef,
7110 or_replace: bool,
7111 ) -> Result<(), StorageError> {
7112 // v7.39 (round 137) — a trigger may target a base table (BEFORE / AFTER)
7113 // or a view (INSTEAD OF). The engine enforces the timing↔target rule;
7114 // storage only requires the relation to exist as one or the other.
7115 if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
7116 return Err(StorageError::TableNotFound {
7117 name: def.table.clone(),
7118 });
7119 }
7120 // v7.39 (read01 round 62) — functions are keyed by SIGNATURE now. A
7121 // trigger names its function by NAME (a trigger function takes no
7122 // arguments), so the existence check goes through the name index.
7123 if self.functions_named(&def.function).is_empty() {
7124 // v7.39 (round 710) — PG's wording: the FUNCTION is what does
7125 // not exist (`function nosuch_fn() does not exist`), and the
7126 // old message rode `Corrupt`'s on-disk banner besides.
7127 return Err(StorageError::Corrupt(format!(
7128 "function {}() does not exist",
7129 def.function
7130 )));
7131 }
7132 let dup = self
7133 .triggers
7134 .iter()
7135 .position(|t| t.name == def.name && t.table == def.table);
7136 match (dup, or_replace) {
7137 (Some(_), false) => Err(StorageError::Corrupt(format!(
7138 "trigger {:?} already exists on table {:?}",
7139 def.name, def.table
7140 ))),
7141 (Some(i), true) => {
7142 self.triggers[i] = def;
7143 Ok(())
7144 }
7145 (None, _) => {
7146 self.triggers.push(def);
7147 Ok(())
7148 }
7149 }
7150 }
7151
7152 /// v7.12.4 — remove a trigger by `(name, table)`. Returns
7153 /// `true` if one was removed.
7154 pub fn drop_trigger(&mut self, name: &str, table: &str) -> bool {
7155 let before = self.triggers.len();
7156 self.triggers
7157 .retain(|t| !(t.name == name && t.table == table));
7158 before != self.triggers.len()
7159 }
7160
7161 /// v7.39 (round 139) — the catalogued query-rewrite RULEs.
7162 pub fn rules(&self) -> &[RuleDef] {
7163 &self.rules
7164 }
7165
7166 /// v7.39 (round 280) — the catalogued extended-statistics objects.
7167 #[must_use]
7168 pub fn statistics_ext(&self) -> &[StatisticsExtDef] {
7169 &self.statistics_ext
7170 }
7171
7172 /// v7.39 (round 287) — every large object, ascending by OID.
7173 #[must_use]
7174 pub fn large_objects(&self) -> &alloc::collections::BTreeMap<u32, Vec<u8>> {
7175 &self.large_objects
7176 }
7177
7178 /// The bytes of one large object, or `None` when no such OID exists.
7179 #[must_use]
7180 pub fn large_object(&self, oid: u32) -> Option<&[u8]> {
7181 self.large_objects.get(&oid).map(Vec::as_slice)
7182 }
7183
7184 /// Create a large object. `oid` of 0 means "pick one" — PG's
7185 /// `lo_create(0)` / `lo_creat(-1)` spelling. Errors when the
7186 /// requested OID is taken.
7187 pub fn create_large_object(&mut self, oid: u32, bytes: Vec<u8>) -> Result<u32, String> {
7188 let id = if oid == 0 {
7189 self.next_large_object_oid()
7190 } else {
7191 oid
7192 };
7193 if self.large_objects.contains_key(&id) {
7194 return Err(format!("large object {id} already exists"));
7195 }
7196 self.large_objects.insert(id, bytes);
7197 Ok(id)
7198 }
7199
7200 /// Overwrite `len` bytes at `offset` (0-based), growing the object
7201 /// with zero bytes if the write starts past the end — PG's
7202 /// `lo_put` semantics.
7203 pub fn put_large_object(&mut self, oid: u32, offset: usize, data: &[u8]) -> Result<(), String> {
7204 let Some(buf) = self.large_objects.get_mut(&oid) else {
7205 return Err(format!("large object {oid} does not exist"));
7206 };
7207 let end = offset.saturating_add(data.len());
7208 if buf.len() < end {
7209 buf.resize(end, 0);
7210 }
7211 buf[offset..end].copy_from_slice(data);
7212 Ok(())
7213 }
7214
7215 /// v7.39 (round 306) — `lo_truncate`. PG's truncate sets the object
7216 /// to exactly `len` bytes in BOTH directions: it shortens, and it
7217 /// GROWS with zero fill when `len` exceeds the current size
7218 /// (measured — `lo_truncate(fd, 8)` over a 4-byte object leaves
7219 /// eight bytes, the last four zero).
7220 pub fn truncate_large_object(&mut self, oid: u32, len: usize) -> Result<(), String> {
7221 let Some(buf) = self.large_objects.get_mut(&oid) else {
7222 return Err(format!("large object {oid} does not exist"));
7223 };
7224 buf.resize(len, 0);
7225 Ok(())
7226 }
7227
7228 /// Remove a large object. `false` when the OID was not there.
7229 pub fn unlink_large_object(&mut self, oid: u32) -> bool {
7230 self.large_objects.remove(&oid).is_some()
7231 }
7232
7233 /// The next free OID in PG's user band.
7234 /// v7.39 (round 343, V40) — large objects have their own oid band.
7235 /// It used to start at 16_384, which is where user TABLES start, so
7236 /// the first large object and the first table shared an oid — and
7237 /// `pg_largeobject_metadata.oid` is joinable against `pg_class.oid`,
7238 /// so a join across them matched a row that has nothing to do with
7239 /// it. (PG cannot collide: every oid there comes off one counter.)
7240 /// An object already stored keeps the oid it was given; only new
7241 /// ones land in the band.
7242 fn next_large_object_oid(&self) -> u32 {
7243 self.large_objects
7244 .keys()
7245 .next_back()
7246 .map_or(500_000, |m| m.saturating_add(1))
7247 }
7248
7249 /// Register one. `Err(name)` when the name is taken.
7250 pub fn create_statistics_ext(&mut self, def: StatisticsExtDef) -> Result<(), String> {
7251 if self.statistics_ext.iter().any(|s| s.name == def.name) {
7252 return Err(def.name);
7253 }
7254 self.statistics_ext.push(def);
7255 Ok(())
7256 }
7257
7258 /// Drop one by name; false when absent.
7259 pub fn drop_statistics_ext(&mut self, name: &str) -> bool {
7260 let before = self.statistics_ext.len();
7261 self.statistics_ext.retain(|s| s.name != name);
7262 before != self.statistics_ext.len()
7263 }
7264
7265 /// v7.39 (round 139) — register a RULE. Its target relation (table or view)
7266 /// must exist; `or_replace` overwrites a same-(name,table) rule.
7267 pub fn create_rule(&mut self, def: RuleDef, or_replace: bool) -> Result<(), StorageError> {
7268 if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
7269 return Err(StorageError::TableNotFound {
7270 name: def.table.clone(),
7271 });
7272 }
7273 let dup = self
7274 .rules
7275 .iter()
7276 .position(|r| r.name == def.name && r.table == def.table);
7277 match (dup, or_replace) {
7278 (Some(_), false) => Err(StorageError::Corrupt(format!(
7279 "rule {:?} for relation {:?} already exists",
7280 def.name, def.table
7281 ))),
7282 (Some(i), true) => {
7283 self.rules[i] = def;
7284 Ok(())
7285 }
7286 (None, _) => {
7287 self.rules.push(def);
7288 Ok(())
7289 }
7290 }
7291 }
7292
7293 /// v7.39 (round 139) — drop a RULE by `(name, table)`.
7294 pub fn drop_rule(&mut self, name: &str, table: &str) -> bool {
7295 let before = self.rules.len();
7296 self.rules.retain(|r| !(r.name == name && r.table == table));
7297 before != self.rules.len()
7298 }
7299
7300 pub fn create_table(&mut self, schema: TableSchema) -> Result<(), StorageError> {
7301 if self.by_name.contains_key(&schema.name) {
7302 return Err(StorageError::DuplicateTable {
7303 name: schema.name.clone(),
7304 });
7305 }
7306 let idx = self.tables.len();
7307 let name = schema.name.clone();
7308 let mut t = Table::new(schema);
7309 // v7.38.18 (S2) — the table inherits the database's collation,
7310 // which is what its undeclared text columns compare under.
7311 t.set_db_collation(self.db_collation());
7312 self.tables.push(t);
7313 self.by_name.insert(name.clone(), idx);
7314 // v7.39 (round 496) — see `dirty_tables`.
7315 self.dirty_tables.insert(name);
7316 // v7.37.15 (Phase C.1) — stamp the new relation with a stable,
7317 // monotonic, never-reused RelId. Pre-increment so ids start at
7318 // 1 (0 = UNASSIGNED); a later DROP TABLE frees the slot but not
7319 // the id.
7320 self.next_rel_id += 1;
7321 let rid = row_header::RelId(self.next_rel_id);
7322 self.tables[idx].set_rel_id(rid);
7323 Ok(())
7324 }
7325
7326 /// v7.39 (round 436) — the session's temporary table of this name wins
7327 /// over a permanent one, as `pg_temp` does in PG's search path and as
7328 /// MySQL's TEMPORARY shadowing does. Every name → index resolution in
7329 /// this catalog goes through here.
7330 fn resolve_index(&self, name: &str) -> Option<usize> {
7331 if let Some(prefix) = &self.temp_prefix {
7332 let mut mangled = String::with_capacity(prefix.len() + name.len());
7333 mangled.push_str(prefix);
7334 mangled.push_str(name);
7335 if let Some(idx) = self.by_name.get(&mangled) {
7336 return Some(*idx);
7337 }
7338 if self.case_insensitive_names
7339 && let Some(idx) = self.index_ignoring_case(&mangled)
7340 {
7341 return Some(idx);
7342 }
7343 }
7344 if let Some(idx) = self.by_name.get(name) {
7345 return Some(*idx);
7346 }
7347 // v7.39.2 — a MySQL session finds the relation under any
7348 // spelling of its name.
7349 //
7350 // The lexer folds an unquoted identifier and leaves a backticked
7351 // one alone, so `CREATE TABLE MyTable` stored `mytable` while
7352 // ``SELECT 1 FROM `MyTable` `` looked for `MyTable` and found
7353 // nothing: the two spellings of one name were two tables.
7354 // `mysqldump` backticks every identifier, so a dump restored
7355 // here and an application that writes the name unquoted were
7356 // looking at different relations.
7357 //
7358 // This is MySQL's `lower_case_table_names = 1` — names compare
7359 // without case — which is what SPG has always half-done, and
7360 // what it now reports. Exact match first, so a catalog that
7361 // already holds two names differing only in case keeps
7362 // answering the way it did.
7363 //
7364 // PostgreSQL sessions never set this: `"MyTable"` and `mytable`
7365 // are two relations there, and the flag is off.
7366 if self.case_insensitive_names {
7367 return self.index_ignoring_case(name);
7368 }
7369 None
7370 }
7371
7372 /// The single relation whose name matches `name` without regard to
7373 /// case, or `None` when there is none — or more than one, which the
7374 /// exact lookup above has already failed to settle.
7375 fn index_ignoring_case(&self, name: &str) -> Option<usize> {
7376 let mut found = None;
7377 for (k, idx) in &self.by_name {
7378 if k.len() == name.len() && k.eq_ignore_ascii_case(name) {
7379 if found.is_some() {
7380 return None;
7381 }
7382 found = Some(*idx);
7383 }
7384 }
7385 found
7386 }
7387
7388 /// v7.39.2 — does this session compare relation names without case?
7389 ///
7390 /// Per SESSION, and the catalog is shared, so the engine installs it
7391 /// the way it installs `temp_prefix`: on every session switch, into
7392 /// the main catalog and into every open transaction's shadow.
7393 pub fn set_case_insensitive_names(&mut self, on: bool) {
7394 self.case_insensitive_names = on;
7395 }
7396
7397 /// v7.39 (round 436) — install the calling session's temp namespace.
7398 /// `None` disables temp resolution entirely (a session that never made
7399 /// one pays a single `Option` check per lookup).
7400 pub fn set_temp_prefix(&mut self, prefix: Option<String>) {
7401 self.temp_prefix = prefix;
7402 }
7403
7404 /// The mangled storage name a temp table of `name` takes in this
7405 /// session, or `None` when the session has no temp namespace.
7406 #[must_use]
7407 pub fn temp_name_for(&self, name: &str) -> Option<String> {
7408 self.temp_prefix
7409 .as_ref()
7410 .map(|p| alloc::format!("{p}{name}"))
7411 }
7412
7413 pub fn get(&self, name: &str) -> Option<&Table> {
7414 let idx = self.resolve_index(name)?;
7415 self.tables.get(idx)
7416 }
7417
7418 pub fn get_mut(&mut self, name: &str) -> Option<&mut Table> {
7419 let idx = self.resolve_index(name)?;
7420 // v7.39 (round 496) — the choke point for changing a table, so the
7421 // record is taken here. Over-approximate on purpose: a caller that
7422 // takes the handle and writes nothing merely carries that table
7423 // through a commit, which is the old behaviour.
7424 let recorded = self.tables.get(idx).map(|t| t.schema().name.clone());
7425 if let Some(n) = recorded {
7426 self.dirty_tables.insert(n);
7427 }
7428 self.tables.get_mut(idx)
7429 }
7430
7431 /// v7.39 (round 496) — the tables changed through this handle since
7432 /// [`Self::clear_dirty_tables`]. See `dirty_tables`.
7433 #[must_use]
7434 pub fn dirty_tables(&self) -> &alloc::collections::BTreeSet<String> {
7435 &self.dirty_tables
7436 }
7437
7438 /// r1059 — mark one table dirty without taking its handle. The
7439 /// rebase/merge paths replace a tx's shadow with a fresh base
7440 /// clone and must carry the tx's OWN dirty window across (the
7441 /// base's set is an ever-growing history, never cleared).
7442 pub fn mark_table_dirty(&mut self, name: &str) {
7443 self.dirty_tables.insert(name.into());
7444 }
7445
7446 /// v7.39 (round 496) — start a fresh recording window. A transaction's
7447 /// shadow calls this at BEGIN so the set means "changed by this tx".
7448 /// 7.38.1 S3.1 — one window covers both records (tables and the
7449 /// non-table families).
7450 pub fn clear_dirty_tables(&mut self) {
7451 self.dirty_tables.clear();
7452 self.dirty_nontable.clear();
7453 }
7454
7455 /// 7.38.1 S3.1 (D4) — record a non-table object as changed by this
7456 /// window. Called from every create/alter/rename/drop of the six
7457 /// [`NonTableKind`] families; a rename records BOTH names.
7458 fn mark_nontable_dirty(&mut self, kind: NonTableKind, name: &str) {
7459 self.dirty_nontable.insert((kind, name.into()));
7460 }
7461
7462 /// 7.38.1 S3.1 (D4) — reconcile the six non-table families with
7463 /// `base` (the latest committed catalog): every entry this window
7464 /// did NOT touch is taken from base — existence, definition and
7465 /// absence alike — so a neighbour's CREATE / ALTER / DROP of a
7466 /// sequence, view, matview, enum, domain or composite type
7467 /// survives a poisoned transaction's COMMIT. Entries this window
7468 /// DID touch keep the shadow's version (the tx's own DDL wins its
7469 /// own objects, exactly like the dirty-table merge above it).
7470 pub fn merge_nontable_objects_from(&mut self, base: &Catalog) {
7471 use NonTableKind as K;
7472 fn merge_map<V: Clone>(
7473 kind: NonTableKind,
7474 dirty: &alloc::collections::BTreeSet<(NonTableKind, String)>,
7475 mine: &mut BTreeMap<String, V>,
7476 theirs: &BTreeMap<String, V>,
7477 ) {
7478 let names: alloc::vec::Vec<String> =
7479 mine.keys().chain(theirs.keys()).cloned().collect();
7480 for n in names {
7481 if dirty.contains(&(kind, n.clone())) {
7482 continue;
7483 }
7484 match theirs.get(&n) {
7485 Some(v) => {
7486 mine.insert(n, v.clone());
7487 }
7488 None => {
7489 mine.remove(&n);
7490 }
7491 }
7492 }
7493 }
7494 let dirty = self.dirty_nontable.clone();
7495 merge_map(K::Sequence, &dirty, &mut self.sequences, &base.sequences);
7496 merge_map(K::View, &dirty, &mut self.views, &base.views);
7497 merge_map(
7498 K::MaterializedView,
7499 &dirty,
7500 &mut self.materialized_views,
7501 &base.materialized_views,
7502 );
7503 merge_map(K::EnumType, &dirty, &mut self.enum_types, &base.enum_types);
7504 merge_map(
7505 K::DomainType,
7506 &dirty,
7507 &mut self.domain_types,
7508 &base.domain_types,
7509 );
7510 merge_map(
7511 K::CompositeType,
7512 &dirty,
7513 &mut self.composite_types,
7514 &base.composite_types,
7515 );
7516 }
7517
7518 /// v7.39 (round 496) — put `table` in at `name`, replacing any table
7519 /// already there and keeping the rest of the catalog untouched.
7520 ///
7521 /// The commit-time table-granularity merge needs exactly this: take
7522 /// the latest committed catalog, then overwrite only the tables the
7523 /// transaction changed.
7524 pub fn install_table(&mut self, name: &str, table: Table) {
7525 match self.by_name.get(name).copied() {
7526 Some(idx) => self.tables[idx] = table,
7527 None => {
7528 let idx = self.tables.len();
7529 self.tables.push(table);
7530 self.by_name.insert(name.into(), idx);
7531 }
7532 }
7533 self.dirty_tables.insert(name.into());
7534 }
7535
7536 /// v7.37.42 (docker-fair SCALARSQ attack) — resolve a table name to
7537 /// its insertion-order index ONCE, so callers that need to fetch the
7538 /// same table many times (per-row PK probes in correlated scalar
7539 /// subqueries) can avoid the per-call `BTreeMap<String, usize>` string
7540 /// descent. The returned index is stable for the lifetime of the
7541 /// catalog snapshot the caller holds (same engine read guard).
7542 pub fn tables_position_of(&self, name: &str) -> Option<usize> {
7543 self.resolve_index(name)
7544 }
7545
7546 /// Direct positional fetch counterpart to [`tables_position_of`].
7547 /// `idx` must come from `tables_position_of` against the same catalog
7548 /// snapshot — out-of-range returns `None`.
7549 pub fn tables_at(&self, idx: usize) -> Option<&Table> {
7550 self.tables.get(idx)
7551 }
7552
7553 /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto
7554 /// this catalog (the [`RowChange`] physical-redo apply primitive that
7555 /// row-level WAL recovery will use in place of statement re-execution).
7556 /// Applies each change in order via the same `Table` mutators the
7557 /// engine used — no uniqueness/FK/parse/plan: the original execution
7558 /// already validated, replay trusts and applies. Positions are
7559 /// physical and only valid when replayed from the matching checkpoint
7560 /// baseline in original order (see [`RowChange`] docs).
7561 ///
7562 /// A change naming an absent table, or whose position is out of range,
7563 /// is a corrupt/misaligned log and surfaces as an error rather than a
7564 /// silent skip.
7565 pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), StorageError> {
7566 // v7.37.5 (mailrs crash-recovery Ask 3) — true batched replay.
7567 // Pre-v7.37.5 each `RowChange::Delete` record ran a fresh
7568 // O(N) PersistentVec rebuild + O(N × indices × log N)
7569 // `rebuild_indices()` — 5000 records × 100k rows × 13 indices
7570 // ≈ 27 min on the mailrs prod-shape WAL.
7571 //
7572 // The strategy: group consecutive changes by table, and for
7573 // each run, compose all the row-level mutations through a
7574 // single "live" tracking vector + a per-table operation log,
7575 // then apply rows + indices ONCE at the end. The result:
7576 // - DELETE blow-up: O(records × rows × indices × log rows)
7577 // → O(rows × indices × log rows) — one rebuild per run.
7578 // - Row-position semantics preserved: positions in a later
7579 // `Delete` / `Update` record reference the layout produced
7580 // by every earlier change; we walk the live-vector
7581 // forward as each change is processed so positions
7582 // translate correctly to the ORIGINAL row index space.
7583 //
7584 // For correctness, even with this batching `apply_redo`
7585 // remains in-order: a single per-table run only batches
7586 // a contiguous slice of changes targeting that table; a
7587 // mid-run change targeting a DIFFERENT table forces a
7588 // flush of the current run.
7589 let mut runs: alloc::vec::Vec<(String, alloc::vec::Vec<&RowChange>)> =
7590 alloc::vec::Vec::new();
7591 for change in changes {
7592 // v7.39 (flip crash-replay P0) — a replayed tombstone carries
7593 // the xmax the CRASHED process allocated, but this process's
7594 // version cursor restarted; without advancing it past every
7595 // replayed version, `Snapshot::visible`'s "deletion is in the
7596 // future" branch (xmax > snapshot.version) resurrects every
7597 // replayed delete. Same recovery contract as the snapshot
7598 // loader (`observe_persisted_version`, the pg_control-style
7599 // nextXid recovery).
7600 if let RowChange::Tombstone { xmax, .. } = change {
7601 row_header::observe_persisted_version(*xmax);
7602 }
7603 let table = match change {
7604 RowChange::Insert { table, .. }
7605 | RowChange::Update { table, .. }
7606 | RowChange::Delete { table, .. }
7607 | RowChange::Tombstone { table, .. } => table.clone(),
7608 };
7609 if runs.last().map(|(t, _)| t.as_str()) != Some(table.as_str()) {
7610 runs.push((table, alloc::vec::Vec::new()));
7611 }
7612 runs.last_mut().unwrap().1.push(change);
7613 }
7614 for (table_name, run) in runs {
7615 self.apply_redo_run_on_table(&table_name, &run)?;
7616 }
7617 Ok(())
7618 }
7619
7620 /// v7.37.5 — apply a contiguous slice of `RowChange`s all
7621 /// targeting the same `table_name`. Composes row mutations
7622 /// through a single live-tracking vector + a single tail
7623 /// for appended `Insert`s + a single in-place edit set for
7624 /// `Update`s, then writes the final row layout to
7625 /// `self.rows` and rebuilds indices ONCE.
7626 fn apply_redo_run_on_table(
7627 &mut self,
7628 table_name: &str,
7629 run: &[&RowChange],
7630 ) -> Result<(), StorageError> {
7631 // Look up the table once; the unchecked unwrap is safe
7632 // because the caller just resolved `table_name` for each
7633 // change.
7634 let table = self.get_mut(table_name).ok_or_else(|| {
7635 StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
7636 })?;
7637 // Live-tracking over both pre-existing rows and tail-
7638 // appended Insert rows. `live[i] = true` initially for
7639 // every existing row. Appended Inserts extend with `true`.
7640 // A `Delete` flips entries to `false` (using the position
7641 // mapping that walks live indices in order). An `Update`
7642 // edits in place — collected into an overlay map keyed by
7643 // ORIGINAL row position so later Updates win.
7644 let original_rows: alloc::vec::Vec<Row<'static>> = table.rows().iter().cloned().collect();
7645 let mut live: alloc::vec::Vec<bool> = alloc::vec![true; original_rows.len()];
7646 let mut tail: alloc::vec::Vec<Row<'static>> = alloc::vec::Vec::new();
7647 // Overlay: index into ORIGINAL row space (existing rows
7648 // 0..original_rows.len()) or into tail (offset
7649 // original_rows.len()). Map -> new values.
7650 let mut overlay: alloc::collections::BTreeMap<usize, alloc::vec::Vec<Value<'static>>> =
7651 alloc::collections::BTreeMap::new();
7652 // v7.37.15 (Epic W durable-tombstone slice) — extra bookkeeping
7653 // ONLY when this run actually carries an in-place `Tombstone`.
7654 // A tombstone keeps its row physically present but stamps `xmax`
7655 // on the header; the run finalizer `set_rows_and_rebuild_indices`
7656 // freezes every header (and reassigns ids), so we must re-stamp
7657 // in a post-pass keyed by RowId. When the run has no tombstone
7658 // (every default gate-off replay) this is all skipped and the
7659 // path below stays byte-for-byte the legacy one.
7660 let has_tomb = run.iter().any(|c| matches!(c, RowChange::Tombstone { .. }));
7661 // Ids of the pre-existing rows, snapshotted parallel to
7662 // `original_rows`, and ids of the tail rows filled from each
7663 // `Insert`'s carried `rowid`. Together they let a tombstone name
7664 // the exact row the writer stamped, independent of the ids the
7665 // finalizer will hand out. (When `!has_tomb`, both stay empty.)
7666 // v7.39 (flip crash-replay P0) — ids are tracked UNCONDITIONALLY
7667 // now: the finalizer preserves them so a later WAL record's
7668 // tombstone can still name rows this record produced.
7669 let orig_rowids: alloc::vec::Vec<row_header::RowId> =
7670 table.rowids().iter().copied().collect();
7671 // Headers snapshotted in lock-step: the finalizer preserves
7672 // them so earlier records' tombstone stamps survive.
7673 let orig_headers: alloc::vec::Vec<row_header::RowHeader> =
7674 table.headers().iter().copied().collect();
7675 let mut tail_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
7676 // (RowId, xmax) of every row this run tombstones.
7677 let mut tomb_targets: alloc::vec::Vec<(row_header::RowId, u64)> = alloc::vec::Vec::new();
7678 // Helper: given a "current" position (i.e. position in
7679 // the post-prior-deletes layout), translate to the
7680 // ABSOLUTE position in the unified live + tail space
7681 // by walking the live vector + tail. Returns None when
7682 // the position is out of range.
7683 fn translate(live: &[bool], tail_len: usize, current_pos: usize) -> Option<usize> {
7684 // Walk live[..] counting live entries until we hit
7685 // current_pos. Then if not yet matched, dip into tail.
7686 let mut seen = 0usize;
7687 for (i, &alive) in live.iter().enumerate() {
7688 if alive {
7689 if seen == current_pos {
7690 return Some(i);
7691 }
7692 seen += 1;
7693 }
7694 }
7695 // Position lives in tail. tail_len rows in the tail
7696 // are all live (we haven't deleted any tail rows in
7697 // this simplification; if we did, we'd extend `live`).
7698 let off = current_pos - seen;
7699 if off < tail_len {
7700 Some(live.len() + off)
7701 } else {
7702 None
7703 }
7704 }
7705 for change in run {
7706 match *change {
7707 RowChange::Insert { row, rowid, .. } => {
7708 // Validate against schema before recording the
7709 // change so a corrupt log surfaces as an error
7710 // rather than silently mis-applying.
7711 if row.len() != table.schema().columns.len() {
7712 return Err(StorageError::ArityMismatch {
7713 expected: table.schema().columns.len(),
7714 actual: row.len(),
7715 });
7716 }
7717 tail.push(row.clone());
7718 // Keep the id lock-step with `tail` so a later
7719 // tombstone (this run or a later WAL record) can
7720 // find the row by the id the writer captured.
7721 tail_rowids.push(*rowid);
7722 }
7723 RowChange::Update { pos, new_row, .. } => {
7724 if new_row.len() != table.schema().columns.len() {
7725 return Err(StorageError::ArityMismatch {
7726 expected: table.schema().columns.len(),
7727 actual: new_row.len(),
7728 });
7729 }
7730 let abs = translate(&live, tail.len(), *pos).ok_or_else(|| {
7731 StorageError::Corrupt(alloc::format!(
7732 "redo: update_row position {pos} out of bounds in table {table_name:?}",
7733 ))
7734 })?;
7735 // Tail edits are applied directly to `tail`
7736 // (we own it); existing-row edits land in
7737 // the overlay map keyed by original index.
7738 if abs < live.len() {
7739 overlay.insert(abs, new_row.clone());
7740 } else {
7741 tail[abs - live.len()] = Row::new(new_row.clone());
7742 }
7743 }
7744 RowChange::Delete { positions, .. } => {
7745 // De-dup + sort so the translate walk stays
7746 // monotone (the second translate doesn't have
7747 // to redo work the first one did, in principle;
7748 // we keep it simple here and re-walk per
7749 // position). Bounds-filter silently mirrors
7750 // `Table::delete_rows`.
7751 let mut sorted: alloc::vec::Vec<usize> = positions.clone();
7752 sorted.sort_unstable();
7753 sorted.dedup();
7754 // Walk live[] once per Delete record to
7755 // translate all positions in this record's
7756 // post-prior-deletes layout to absolute
7757 // indices. We MUST defer the live[] flip
7758 // until after all positions are translated
7759 // so two positions in the same record
7760 // (e.g. [3, 7]) reference the same layout.
7761 let mut to_flip_live: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
7762 let mut to_flip_tail: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
7763 // Two-pointer walk: live[i] scanned monotonically,
7764 // sorted positions consumed in order.
7765 let mut seen = 0usize;
7766 let mut sp = sorted.iter().peekable();
7767 for (i, &alive) in live.iter().enumerate() {
7768 if !alive {
7769 continue;
7770 }
7771 while let Some(&&p) = sp.peek() {
7772 if seen == p {
7773 to_flip_live.push(i);
7774 sp.next();
7775 } else {
7776 break;
7777 }
7778 }
7779 if sp.peek().is_none() {
7780 break;
7781 }
7782 seen += 1;
7783 }
7784 // Remaining positions fall into the tail.
7785 for &p in sp {
7786 // p >= seen and refers to the (p - seen)-th
7787 // entry in tail. Filter out-of-bounds.
7788 let off = p - seen;
7789 if off < tail.len() {
7790 to_flip_tail.push(off);
7791 }
7792 }
7793 for i in to_flip_live {
7794 live[i] = false;
7795 // Any pending overlay edit for this
7796 // index is moot — the row is gone.
7797 overlay.remove(&i);
7798 }
7799 // Tail deletes: remove in REVERSE order so
7800 // shifting indices stay valid.
7801 to_flip_tail.sort_unstable();
7802 to_flip_tail.dedup();
7803 for off in to_flip_tail.into_iter().rev() {
7804 tail.remove(off);
7805 {
7806 // Keep the id vector lock-step with `tail`.
7807 tail_rowids.remove(off);
7808 }
7809 // Re-key tail-relative overlay entries that
7810 // were past `off` — in practice tail edits
7811 // are applied directly so the overlay map
7812 // only holds existing-row keys; nothing to
7813 // do here.
7814 }
7815 }
7816 RowChange::Tombstone { rowids, xmax, .. } => {
7817 // An in-place tombstone leaves the row physically
7818 // present — it does not touch `live` / `tail` /
7819 // `overlay`. Record the (id, xmax) targets; the
7820 // post-finalizer pass re-stamps `xmax` onto the
7821 // matching row's (otherwise-frozen) header.
7822 for rid in rowids {
7823 tomb_targets.push((*rid, *xmax));
7824 }
7825 }
7826 }
7827 }
7828 // Compose the final row layout: keep existing rows where
7829 // live[i] = true, applying overlay edits in place; then
7830 // append the surviving tail.
7831 let mut new_rows: PersistentVec<Row> = PersistentVec::new();
7832 let mut new_hot_bytes: u64 = 0;
7833 let schema_snapshot = table.schema().clone();
7834 // Parallel to `new_rows` (only built when `has_tomb`): the RowId
7835 // of each row in its FINAL slot, so the post-pass can map a
7836 // tombstone target id → the slot to re-stamp `xmax` on.
7837 let mut final_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
7838 let mut final_headers: alloc::vec::Vec<row_header::RowHeader> = alloc::vec::Vec::new();
7839 for (i, row) in original_rows.into_iter().enumerate() {
7840 if !live[i] {
7841 continue;
7842 }
7843 let final_row = if let Some(new_values) = overlay.remove(&i) {
7844 Row::new(new_values)
7845 } else {
7846 row
7847 };
7848 new_hot_bytes = new_hot_bytes
7849 .saturating_add(row_body_encoded_len(&final_row, &schema_snapshot) as u64);
7850 new_rows.push_mut(final_row);
7851 final_rowids.push(
7852 orig_rowids
7853 .get(i)
7854 .copied()
7855 .unwrap_or(row_header::RowId::UNASSIGNED),
7856 );
7857 final_headers.push(
7858 orig_headers
7859 .get(i)
7860 .copied()
7861 .unwrap_or_else(row_header::RowHeader::frozen),
7862 );
7863 }
7864 for (off, row) in tail.into_iter().enumerate() {
7865 new_hot_bytes =
7866 new_hot_bytes.saturating_add(row_body_encoded_len(&row, &schema_snapshot) as u64);
7867 new_rows.push_mut(row);
7868 final_rowids.push(
7869 tail_rowids
7870 .get(off)
7871 .copied()
7872 .unwrap_or(row_header::RowId::UNASSIGNED),
7873 );
7874 final_headers.push(row_header::RowHeader::frozen());
7875 }
7876 // v7.39 (flip crash-replay P0) — id-preserving finalizer, so a
7877 // LATER WAL record's tombstone still resolves rows this record
7878 // produced (per-statement replay used to reassign ids between
7879 // records, orphaning every cross-record tombstone target).
7880 table.set_rows_and_rebuild_indices_with_rowids(
7881 new_rows,
7882 new_hot_bytes,
7883 &final_rowids,
7884 &final_headers,
7885 );
7886 // v7.37.15 (Epic W durable-tombstone slice) — header-preserving
7887 // re-stamp. `set_rows_and_rebuild_indices` above froze every
7888 // header, so any row this run tombstoned is currently all-
7889 // visible again. Re-apply the `xmax` stamp by matching the
7890 // tombstone's target RowId against the final-slot id map. This
7891 // is what makes a gate-on DELETE durable across replay without
7892 // changing the on-disk snapshot format (headers/ids are still
7893 // NOT serialised — that is the deferred V6 coupling; see below).
7894 if has_tomb && !tomb_targets.is_empty() {
7895 let mut id_to_slot: alloc::collections::BTreeMap<row_header::RowId, usize> =
7896 alloc::collections::BTreeMap::new();
7897 for (slot, rid) in final_rowids.iter().enumerate() {
7898 if *rid != row_header::RowId::UNASSIGNED {
7899 id_to_slot.insert(*rid, slot);
7900 }
7901 }
7902 let table = self.get_mut(table_name).ok_or_else(|| {
7903 StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
7904 })?;
7905 for (rid, xmax) in &tomb_targets {
7906 match id_to_slot.get(rid) {
7907 Some(&slot) => {
7908 // First-deleter-wins + bounds handled inside.
7909 let _ = table.mark_row_deleted(slot, *xmax);
7910 }
7911 None => {
7912 // The target row was not produced by THIS redo
7913 // run and its id was not in the run-start
7914 // snapshot — the documented cross-checkpoint
7915 // limitation: after a checkpoint restore the
7916 // table's ids are reassigned (not yet persisted
7917 // in the envelope), so a tombstone naming a
7918 // pre-checkpoint row cannot be resolved by id.
7919 // Skipping leaves the row visible (identical to
7920 // the pre-Epic-W non-durable behaviour); it is
7921 // never a correctness regression, only an
7922 // unclosed durability gap the V6 envelope slice
7923 // closes. Counted for observability.
7924 UNRESOLVED_TOMBSTONES.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
7925 }
7926 }
7927 }
7928 }
7929 Ok(())
7930 }
7931
7932 fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
7933 self.get_mut(name)
7934 .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
7935 }
7936
7937 /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
7938 /// every table (the engine calls this before a mutating statement
7939 /// when persistence is on; idempotent, keeps any in-flight capture).
7940 pub fn enable_redo_all(&mut self) {
7941 for t in &mut self.tables {
7942 t.enable_redo();
7943 }
7944 }
7945
7946 /// v7.34 — drain the row-level redo captured across all tables, in
7947 /// table order then per-table apply order, and stop capturing. The
7948 /// engine calls this after a successful mutating statement and writes
7949 /// the returned [`RowChange`]s to the WAL in place of the SQL text.
7950 pub fn drain_redo(&mut self) -> Vec<RowChange> {
7951 let mut all = Vec::new();
7952 for t in &mut self.tables {
7953 all.extend(t.take_redo());
7954 }
7955 all
7956 }
7957
7958 pub fn table_count(&self) -> usize {
7959 self.tables.len()
7960 }
7961
7962 /// v7.14.0 — remove a table by name. Returns `true` when the
7963 /// table existed (and is now gone), `false` when it didn't.
7964 /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
7965 /// where the dump re-creates schema and starts with
7966 /// `DROP TABLE IF EXISTS`.
7967 pub fn drop_table(&mut self, name: &str) -> bool {
7968 // v7.39 (round 436) — resolve through the session's temp namespace
7969 // first, exactly as a read would: MariaDB's plain `DROP TABLE tmp`
7970 // drops the TEMPORARY one and leaves a permanent namesake standing
7971 // (measured). Removing by the raw name would have dropped the
7972 // permanent table out from under every other session.
7973 let key = match self.temp_prefix.as_ref() {
7974 Some(p) => {
7975 let mangled = alloc::format!("{p}{name}");
7976 if self.by_name.contains_key(&mangled) {
7977 mangled
7978 } else {
7979 name.into()
7980 }
7981 }
7982 None => name.into(),
7983 };
7984 let Some(idx) = self.by_name.remove(&key) else {
7985 return false;
7986 };
7987 // v7.39 (round 496) — see `dirty_tables`. Recorded under the
7988 // RESOLVED key, which is what a commit-time merge looks up.
7989 self.dirty_tables.insert(key.clone());
7990 // swap_remove invalidates the trailing index → rebuild
7991 // by_name for affected entries.
7992 self.tables.swap_remove(idx);
7993 // Re-stamp moved table's index slot in by_name.
7994 if idx < self.tables.len() {
7995 let moved_name = self.tables[idx].schema.name.clone();
7996 self.by_name.insert(moved_name, idx);
7997 }
7998 true
7999 }
8000
8001 /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
8002 /// the schema name, the catalog name → index map, and
8003 /// rewrites every reference dangling at the table name:
8004 /// * every FK on every OTHER table whose `parent_table`
8005 /// pointed at the old name now points at the new
8006 /// name, so FK enforcement keeps working
8007 /// * every trigger watching the table updates its `table`
8008 /// field
8009 /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
8010 /// when the old name isn't in the catalog and
8011 /// `Err(StorageError::DuplicateTable)` when the new name is
8012 /// already taken.
8013 pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
8014 if old == new {
8015 return Ok(());
8016 }
8017 if self.by_name.contains_key(new) {
8018 return Err(StorageError::Corrupt(format!(
8019 "rename_table: target name {new:?} already exists"
8020 )));
8021 }
8022 let idx = self
8023 .by_name
8024 .remove(old)
8025 .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
8026 self.tables[idx].schema.name = new.to_string();
8027 self.by_name.insert(new.to_string(), idx);
8028 for t in &mut self.tables {
8029 for fk in &mut t.schema.foreign_keys {
8030 if fk.parent_table == old {
8031 fk.parent_table = new.to_string();
8032 }
8033 }
8034 }
8035 for trig in &mut self.triggers {
8036 if trig.table == old {
8037 trig.table = new.to_string();
8038 }
8039 }
8040 Ok(())
8041 }
8042
8043 /// v7.16.2 — rename an index by name. Walks every table
8044 /// since the index lives on its owning table; updates the
8045 /// name in place. Errors with `IndexNotFound` when no
8046 /// index matches. mailrs round-10 A.5.
8047 pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
8048 if old == new {
8049 return Ok(());
8050 }
8051 // Reject the new name if it already exists anywhere.
8052 for t in &self.tables {
8053 if t.indices.iter().any(|i| i.name == new) {
8054 return Err(StorageError::Corrupt(format!(
8055 "rename_index: target name {new:?} already exists"
8056 )));
8057 }
8058 }
8059 for t in &mut self.tables {
8060 for i in &mut t.indices {
8061 if i.name == old {
8062 i.name = new.to_string();
8063 return Ok(());
8064 }
8065 }
8066 }
8067 Err(StorageError::IndexNotFound { name: old.into() })
8068 }
8069
8070 /// v7.14.0 — remove a named index across the catalog.
8071 /// Returns `true` when found + dropped.
8072 pub fn drop_named_index(&mut self, name: &str) -> bool {
8073 for t in &mut self.tables {
8074 let before = t.indices.len();
8075 t.indices.retain(|i| i.name != name);
8076 if t.indices.len() != before {
8077 return true;
8078 }
8079 }
8080 false
8081 }
8082
8083 /// v7.39.7 — the same drop, scoped to ONE table.
8084 ///
8085 /// MySQL keys an index name inside its table, and `DROP INDEX i ON t`
8086 /// says which. `None` means the table itself is missing, which is a
8087 /// different error from the index being missing — MySQL answers 1146
8088 /// for the first and 1091 for the second.
8089 pub fn drop_named_index_on(&mut self, table: &str, name: &str) -> Option<bool> {
8090 let t = self
8091 .tables
8092 .iter_mut()
8093 .find(|t| t.schema.name.eq_ignore_ascii_case(table))?;
8094 let before = t.indices.len();
8095 t.indices.retain(|i| i.name != name);
8096 Some(t.indices.len() != before)
8097 }
8098
8099 /// Borrow-free copy of every table's name in catalog order
8100 /// (= insertion order, matching the on-disk encoding).
8101 pub fn table_names(&self) -> Vec<String> {
8102 self.tables.iter().map(|t| t.schema.name.clone()).collect()
8103 }
8104
8105 /// v7.39 (round 436) — the marker every session's temporary-table
8106 /// namespace starts with. Public so the catalog synths can tell a
8107 /// temp table from an ordinary one without knowing the session id.
8108 pub const TEMP_NAME_MARKER: &'static str = "__spg_temp_";
8109
8110 /// v7.39 (round 437) — how a stored table name should appear to the
8111 /// CALLING session in a catalog listing (SHOW TABLES, pg_class,
8112 /// information_schema, …):
8113 /// * an ordinary table → its own name
8114 /// * this session's temporary table → its logical name, prefix stripped
8115 /// * another session's temporary table → `None`, i.e. not listed
8116 ///
8117 /// Measured on both oracles: MariaDB 11 and PG 18 each list the calling
8118 /// session's own temporary tables and neither lists anybody else's.
8119 /// Round 436 stored temp tables under a prefix without teaching the
8120 /// listings about it, so the mangled names leaked to every client.
8121 #[must_use]
8122 pub fn listed_name<'a>(&self, stored: &'a str) -> Option<&'a str> {
8123 if !stored.starts_with(Self::TEMP_NAME_MARKER) {
8124 return Some(stored);
8125 }
8126 let prefix = self.temp_prefix.as_ref()?;
8127 stored.strip_prefix(prefix.as_str())
8128 }
8129
8130 /// The listing names of every table this session may see, in catalog
8131 /// order. See [`Catalog::listed_name`].
8132 #[must_use]
8133 pub fn visible_table_names(&self) -> Vec<String> {
8134 self.tables
8135 .iter()
8136 .filter_map(|t| self.listed_name(&t.schema.name).map(String::from))
8137 .collect()
8138 }
8139
8140 /// v5.1: register a cold-tier segment that already lives in
8141 /// memory (caller did the file read). Returns the
8142 /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
8143 /// will reference — currently this is just the index into
8144 /// `cold_segments`, but treat it as an opaque token.
8145 ///
8146 /// Storage is `no_std`, so file I/O is the caller's
8147 /// responsibility — `spg-server` reads the file and forwards
8148 /// the bytes here. The bytes stay resident in the catalog
8149 /// for the life of the `Catalog`, parsed only once.
8150 pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
8151 let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
8152 StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
8153 })?;
8154 let seg = OwnedSegment::from_bytes(bytes)
8155 .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
8156 self.cold_segments.push(Some(Arc::new(seg)));
8157 Ok(id)
8158 }
8159
8160 /// v6.7.3 — register a cold-tier segment at a specific id. Used
8161 /// by the spg-server manifest-boot path so segments whose
8162 /// neighbouring ids were retired by compaction still get back
8163 /// the same `segment_id` they had pre-restart (the
8164 /// `RowLocator::Cold { segment_id }` baked into the BTree-index
8165 /// snapshot persists across restart and must continue to
8166 /// resolve).
8167 ///
8168 /// Pads the Vec with `None` slots up to `target_id` if needed.
8169 /// Errors when the target slot is already occupied (would
8170 /// stomp another segment), the parse fails, or `target_id`
8171 /// exceeds `u32::MAX`.
8172 pub fn load_segment_bytes_at(
8173 &mut self,
8174 target_id: u32,
8175 bytes: Vec<u8>,
8176 ) -> Result<(), StorageError> {
8177 let seg = OwnedSegment::from_bytes(bytes)
8178 .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
8179 let idx = target_id as usize;
8180 while self.cold_segments.len() <= idx {
8181 self.cold_segments.push(None);
8182 }
8183 if self.cold_segments[idx].is_some() {
8184 return Err(StorageError::Corrupt(format!(
8185 "load_segment_bytes_at: segment_id {target_id} already occupied"
8186 )));
8187 }
8188 self.cold_segments[idx] = Some(Arc::new(seg));
8189 Ok(())
8190 }
8191
8192 /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
8193 /// The physical file is the caller's concern (typically kept
8194 /// on disk until the next CHECKPOINT writes a manifest that
8195 /// no longer lists it); this just flips the in-memory slot
8196 /// to `None` so later cold lookups for `segment_id` resolve
8197 /// as "unknown" instead of returning a stale row.
8198 ///
8199 /// No-op when the slot is already `None`. Errors only when
8200 /// `segment_id` is out of bounds.
8201 pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
8202 let idx = segment_id as usize;
8203 if idx >= self.cold_segments.len() {
8204 return Err(StorageError::Corrupt(format!(
8205 "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
8206 self.cold_segments.len()
8207 )));
8208 }
8209 self.cold_segments[idx] = None;
8210 Ok(())
8211 }
8212
8213 /// Number of *active* (non-tombstoned) cold segments.
8214 #[must_use]
8215 pub fn cold_segment_count(&self) -> usize {
8216 self.cold_segments.iter().filter(|s| s.is_some()).count()
8217 }
8218
8219 /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
8220 /// for scan loops that conditionally walk the cold tier. Returns
8221 /// `false` when the catalog has never loaded a cold segment (or all
8222 /// segments are tombstoned), so callers can skip the per-table cold
8223 /// PK-index walk entirely on hot-only databases. O(N segments);
8224 /// typical N is small (single-digit) so the check is sub-µs.
8225 #[must_use]
8226 pub fn has_any_cold_segments(&self) -> bool {
8227 self.cold_segments.iter().any(Option::is_some)
8228 }
8229
8230 /// Slot count including tombstones (= the next id the
8231 /// no-arg `load_segment_bytes` would allocate).
8232 #[must_use]
8233 pub fn cold_segment_slot_count(&self) -> usize {
8234 self.cold_segments.len()
8235 }
8236
8237 /// v6.2.7 — list every *active* cold-tier segment id known to
8238 /// this catalog (skips compaction tombstones since v6.7.3).
8239 /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
8240 /// segments they could have walked.
8241 #[must_use]
8242 pub fn cold_segment_ids_global(&self) -> Vec<u32> {
8243 self.cold_segments
8244 .iter()
8245 .enumerate()
8246 .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
8247 .collect()
8248 }
8249
8250 /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
8251 /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
8252 /// server startup; default 4 GiB) and wakes when the budget is
8253 /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
8254 /// counter exposes whether the budget is being approached without
8255 /// triggering any demotion.
8256 #[must_use]
8257 pub fn hot_tier_bytes(&self) -> u64 {
8258 self.tables
8259 .iter()
8260 .map(Table::hot_bytes)
8261 .fold(0u64, u64::saturating_add)
8262 }
8263
8264 /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
8265 /// hot tier into a brand-new cold-tier segment. The named `BTree`
8266 /// index supplies the per-row PK (its column must be an integer
8267 /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
8268 /// `index_key_as_u64` constraint used by the cold-tier lookup
8269 /// path). On success returns a [`FreezeReport`] with the
8270 /// freshly-allocated segment id, the count of rows that moved,
8271 /// the encoded segment bytes (so the caller can persist them to
8272 /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
8273 /// hot-tier byte delta that was reclaimed.
8274 ///
8275 /// **Semantics**:
8276 /// 1. The first `max_rows` rows (by hot-tier position — same as
8277 /// insertion order under v4.39 `PersistentVec`) are read.
8278 /// 2. Rows are sorted ascending by PK and serialised into a new
8279 /// segment via [`encode_segment`].
8280 /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
8281 /// `rebuild_indices` it triggers regenerates `Hot` locators
8282 /// for every remaining row (their positions shift down by
8283 /// `max_rows`). Existing `Cold` locators in this index — from
8284 /// a previous freeze — are also rebuilt **but with empty
8285 /// payload** since rebuild reads only `self.rows`; this
8286 /// routine re-registers them at the end of the call so the
8287 /// user-visible state preserves all prior cold locators.
8288 /// 4. The new segment is loaded into `self.cold_segments` via
8289 /// [`Catalog::load_segment_bytes`] (allocating a fresh
8290 /// `segment_id`). New `Cold` locators are registered on the
8291 /// named index — one per frozen row.
8292 ///
8293 /// **v5.2.2 limits** (relaxed in later sub-versions):
8294 /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
8295 /// returns a stale-locator error (no promote-on-write until
8296 /// v5.2.3).
8297 /// - Single-table scope: callers iterate tables themselves.
8298 /// - All-or-nothing: returns `Err` and leaves catalog unchanged
8299 /// if any step fails before the atomic swap point.
8300 ///
8301 /// Errors:
8302 /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
8303 /// index, non-integer PK column, `max_rows == 0`, or
8304 /// `max_rows > row_count`.
8305 /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
8306 /// only realistic source is "a single row is larger than the
8307 /// page size"; SPG schemas don't hit it in practice).
8308 pub fn freeze_oldest_to_cold(
8309 &mut self,
8310 table_name: &str,
8311 index_name: &str,
8312 max_rows: usize,
8313 ) -> Result<FreezeReport, StorageError> {
8314 // --- validation phase: never mutates ---------------------
8315 if max_rows == 0 {
8316 return Err(StorageError::Corrupt(
8317 "freeze_oldest_to_cold: max_rows must be > 0".into(),
8318 ));
8319 }
8320 let table = self.get(table_name).ok_or_else(|| {
8321 StorageError::Corrupt(format!(
8322 "freeze_oldest_to_cold: table {table_name:?} not found"
8323 ))
8324 })?;
8325 if max_rows > table.rows.len() {
8326 return Err(StorageError::Corrupt(format!(
8327 "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
8328 table.rows.len()
8329 )));
8330 }
8331 let idx = table
8332 .indices
8333 .iter()
8334 .find(|i| i.name == index_name)
8335 .ok_or_else(|| {
8336 StorageError::Corrupt(format!(
8337 "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
8338 ))
8339 })?;
8340 if !matches!(idx.kind, IndexKind::BTree(_)) {
8341 return Err(StorageError::Corrupt(format!(
8342 "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
8343 )));
8344 }
8345 let column_position = idx.column_position;
8346
8347 // --- segment build phase: reads only --------------------
8348 let schema = table.schema.clone();
8349 let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
8350 for row_idx in 0..max_rows {
8351 let row = table.rows.get(row_idx).expect("bounds-checked above");
8352 let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
8353 StorageError::Corrupt(format!(
8354 "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
8355 ))
8356 })?;
8357 let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
8358 StorageError::Corrupt(format!(
8359 "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
8360 v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
8361 ))
8362 })?;
8363 to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
8364 }
8365 // encode_segment requires ascending u64 keys. Sort by PK
8366 // before encoding; the caller's row-position order is not
8367 // necessarily PK order (e.g. workloads that insert random
8368 // PKs).
8369 to_freeze.sort_by_key(|(k, _, _)| *k);
8370 // Reject duplicate PKs — encode_segment also rejects them
8371 // (`SegmentError::UnsortedKey`), but the resulting error
8372 // message there is misleading. Surface a clearer one.
8373 for w in to_freeze.windows(2) {
8374 if w[0].0 == w[1].0 {
8375 return Err(StorageError::Corrupt(format!(
8376 "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
8377 w[0].0
8378 )));
8379 }
8380 }
8381 // Snapshot the (key, locator) pairs that will be registered
8382 // post-swap. Cloning the IndexKey out before the move makes
8383 // the registration loop borrow-free.
8384 let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
8385 // Segment encode is now infallible w.r.t. ordering. Map the
8386 // `SegmentError` into a `StorageError::Corrupt` so the
8387 // public surface stays one error type.
8388 let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
8389 .into_iter()
8390 .map(|(k, body, _)| (k, body))
8391 .collect();
8392 let frozen_rows = seg_rows.len();
8393 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
8394 .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
8395
8396 // --- atomic swap phase: mutations only past this point ---
8397 // v5.2.3 made `Table::rebuild_indices` preserve every Cold
8398 // locator across the per-table rebuild, so `delete_rows`
8399 // below no longer wipes prior-freeze cold entries. The pre-
8400 // v5.2.3 capture-then-re-register that used to live here
8401 // was removed in v5.3.1 — keeping it would double-count
8402 // every prior-frozen key's Cold locator on each subsequent
8403 // freeze.
8404 let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
8405 let positions: Vec<usize> = (0..max_rows).collect();
8406 let t_mut = self
8407 .get_mut(table_name)
8408 .expect("just validated; still present");
8409 let removed = t_mut.delete_rows(&positions);
8410 debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
8411 let bytes_after = t_mut.hot_bytes();
8412 let bytes_freed = bytes_before.saturating_sub(bytes_after);
8413
8414 let segment_id = self
8415 .load_segment_bytes(seg_bytes.clone())
8416 .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
8417 let new_cold = post_swap_keys.into_iter().map(|k| {
8418 (
8419 k,
8420 RowLocator::Cold {
8421 segment_id,
8422 page_offset: 0,
8423 },
8424 )
8425 });
8426 let t_mut = self.get_mut(table_name).expect("still present");
8427 t_mut.register_cold_locators(index_name, new_cold)?;
8428 // r944 — a freeze has to say that it froze something.
8429 //
8430 // `has_cold_rows_fast()` reads the cached count, and neither
8431 // freeze path touched it, so afterwards it answered "no cold
8432 // rows" while cold rows existed. That predicate gates four join
8433 // paths, and a gate that wrongly declines the cold-aware path
8434 // drops the frozen rows from the answer.
8435 //
8436 // Marking it stale rather than adding to it: stale reads as
8437 // true, which is the safe direction, and this function cannot
8438 // know the exact total (rows may already have been cold). ANALYZE
8439 // recomputes the number.
8440 t_mut.mark_cold_row_count_stale();
8441
8442 Ok(FreezeReport {
8443 segment_id,
8444 frozen_rows,
8445 bytes_freed,
8446 segment_bytes: seg_bytes,
8447 })
8448 }
8449
8450 /// v5.1: borrow the cold segment at `segment_id`. Used by the
8451 /// spg-server preload path to enumerate (key, locator) pairs
8452 /// after loading a segment, so it can call
8453 /// [`Table::register_cold_locators`] without re-parsing the
8454 /// bytes.
8455 #[must_use]
8456 pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
8457 self.cold_segments
8458 .get(segment_id as usize)
8459 .and_then(|s| s.as_deref())
8460 }
8461
8462 /// v5.1: resolve a single `RowLocator::Cold` to its underlying
8463 /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
8464 /// iterating a multi-locator slice (e.g. the engine's index
8465 /// seek path) can dispatch per locator instead of getting back
8466 /// only the first row for a key. Returns `None` when the
8467 /// segment isn't registered, the key isn't `u64`-coercible, or
8468 /// the segment doesn't actually carry the key (bloom or page-
8469 /// index reject).
8470 pub fn resolve_cold_locator(
8471 &self,
8472 table_name: &str,
8473 segment_id: u32,
8474 key: &IndexKey,
8475 ) -> Option<Row<'static>> {
8476 let t = self.get(table_name)?;
8477 let u64_key = index_key_as_u64(key)?;
8478 let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
8479 let payload = seg.lookup(u64_key)?;
8480 let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
8481 // v7.39 (pg_stat blks knife) — one cold-tier "block read".
8482 self.cold_read_stats
8483 .cold_reads
8484 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8485 Some(row)
8486 }
8487
8488 /// v5.1: indexed PK lookup that dispatches per locator,
8489 /// returning the first matching row from either the hot tier
8490 /// (`Table::rows`) or a registered cold segment.
8491 ///
8492 /// The cold path requires the index column to be coercible to
8493 /// a `u64` (the segment's PK type) and the segment payload to
8494 /// be a [`encode_row_body_dense`]-encoded row body for the
8495 /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
8496 /// PKs; other types fall through to hot-only behavior.
8497 ///
8498 /// Returns `None` if (a) the table or index doesn't exist,
8499 /// (b) the key isn't in the index at all, or (c) the key was
8500 /// resolved to a stale locator (Hot index out of range, Cold
8501 /// segment id unknown, segment lookup miss). Does not surface
8502 /// segment-decode errors — those would indicate corrupted
8503 /// cold-tier files and should be caught at
8504 /// [`Catalog::load_segment_bytes`] time.
8505 pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
8506 let t = self.get(table)?;
8507 let idx = t.indices.iter().find(|i| i.name == index_name)?;
8508 let locators = idx.lookup_eq(key);
8509 let cold_u64_key = index_key_as_u64(key);
8510 for loc in locators {
8511 match *loc {
8512 RowLocator::Hot(i) => {
8513 if let Some(row) = t.rows.get(i) {
8514 return Some(row.clone());
8515 }
8516 }
8517 RowLocator::Cold {
8518 segment_id,
8519 page_offset: _,
8520 } => {
8521 let Some(u64_key) = cold_u64_key else {
8522 // Key type not coercible to u64 — cold tier
8523 // only handles BIGINT/INT/SMALLINT in v5.1.
8524 continue;
8525 };
8526 let Some(seg) = self
8527 .cold_segments
8528 .get(segment_id as usize)
8529 .and_then(|s| s.as_deref())
8530 else {
8531 // v6.7.3 — `None` slot = compaction
8532 // retired this segment; the live locator
8533 // on a freshly-compacted index points to
8534 // the merged segment_id, so a Cold hit
8535 // here against a tombstone means the BTree
8536 // entry hasn't been swapped yet (mid-
8537 // compaction reader race) or the caller is
8538 // looking up a stale snapshot. Skip — the
8539 // next locator in the list, if any, is
8540 // typically the merged segment.
8541 continue;
8542 };
8543 let Some(payload) = seg.lookup(u64_key) else {
8544 continue;
8545 };
8546 let (row, _) =
8547 decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
8548 return Some(row);
8549 }
8550 }
8551 }
8552 None
8553 }
8554
8555 /// v5.2.3: promote a frozen row back to the hot tier so an
8556 /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
8557 /// (decoded from its registered segment), pushes it into
8558 /// `table.rows` via [`Table::insert`] (which also adds a fresh
8559 /// `Hot(new_idx)` locator on `index_name`), then retires the
8560 /// shadowed `Cold` locator via
8561 /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
8562 /// in the segment file becomes garbage — recoverable when a
8563 /// future cold-segment compaction job lands.
8564 ///
8565 /// Returns:
8566 /// - `Ok(Some(new_hot_idx))` when the key resolved through a
8567 /// cold locator and the promote completed. `new_hot_idx` is
8568 /// the position the row now occupies in `table.rows`.
8569 /// - `Ok(None)` when the key has no Cold locator on the index
8570 /// (already hot, or wasn't present at all). Callers treat this
8571 /// as "nothing to do here, fall back to the hot-only path".
8572 ///
8573 /// Errors when the table / index doesn't exist, the index isn't
8574 /// `BTree`, the cold segment is missing / can't decode the row,
8575 /// or the inferred row body fails `Table::insert` validation.
8576 pub fn promote_cold_row(
8577 &mut self,
8578 table_name: &str,
8579 index_name: &str,
8580 key: &IndexKey,
8581 ) -> Result<Option<usize>, StorageError> {
8582 let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
8583 let Some((segment_id, _page_offset)) = cold_loc else {
8584 return Ok(None);
8585 };
8586 let u64_key = index_key_as_u64(key).ok_or_else(|| {
8587 StorageError::Corrupt(
8588 "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
8589 .into(),
8590 )
8591 })?;
8592 // Read the row body from the segment. Borrow the segment +
8593 // schema short-term so we can then take `&mut self` for the
8594 // hot-side insert.
8595 let schema = self
8596 .get(table_name)
8597 .ok_or_else(|| {
8598 StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
8599 })?
8600 .schema
8601 .clone();
8602 let seg = self
8603 .cold_segments
8604 .get(segment_id as usize)
8605 .and_then(|s| s.as_ref())
8606 .ok_or_else(|| {
8607 StorageError::Corrupt(format!(
8608 "promote_cold_row: segment {segment_id} not registered on catalog"
8609 ))
8610 })?;
8611 let payload = seg.lookup(u64_key).ok_or_else(|| {
8612 StorageError::Corrupt(format!(
8613 "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
8614 but the segment's bloom/page lookup didn't return a row"
8615 ))
8616 })?;
8617 let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
8618 // Insert the promoted row into the hot tier. `Table::insert`
8619 // appends to `self.rows`, adds a `Hot(new_idx)` locator to
8620 // every BTree index covering the row's keyed columns, and
8621 // increments `hot_bytes`.
8622 let t = self
8623 .get_mut(table_name)
8624 .expect("table existed at lookup time");
8625 t.insert(row)?;
8626 let new_hot_idx =
8627 t.rows.len().checked_sub(1).ok_or_else(|| {
8628 StorageError::Corrupt("promote_cold_row: empty after insert".into())
8629 })?;
8630 // The hot insert added Hot(new_idx) alongside the still-
8631 // present Cold locator. Drop the Cold entry so future
8632 // lookups return only the fresh hot row.
8633 t.remove_cold_locators_for_key(index_name, key)?;
8634 Ok(Some(new_hot_idx))
8635 }
8636
8637 /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
8638 /// when the row to remove lives in a cold-tier segment — the
8639 /// row body stays in the segment file (becoming garbage) but
8640 /// every `Cold` locator for `key` on `index_name` is removed
8641 /// so PK lookups stop returning it.
8642 ///
8643 /// Returns the number of cold locators retired (0 when the key
8644 /// has no cold entries — the DELETE fell on a hot row or a
8645 /// key that was already absent). Errors when the table /
8646 /// index doesn't exist or the index isn't `BTree`.
8647 ///
8648 /// Cold-segment compaction (which merges shadowed-heavy
8649 /// segments and reclaims their disk footprint) lands in a
8650 /// later v5.x sub-version; until then, repeated UPDATE/DELETE
8651 /// of cold rows can amplify cold-segment disk usage by up to
8652 /// 1-2× — still well under typical LSM-tree shadowing because
8653 /// SPG segments are bulk-baked, not write-merged.
8654 pub fn shadow_cold_row(
8655 &mut self,
8656 table_name: &str,
8657 index_name: &str,
8658 key: &IndexKey,
8659 ) -> Result<usize, StorageError> {
8660 let t = self.get_mut(table_name).ok_or_else(|| {
8661 StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
8662 })?;
8663 t.remove_cold_locators_for_key(index_name, key)
8664 }
8665
8666 /// v6.7.4 — read-only slice preparation for the parallel
8667 /// freezer. Walks rows in `row_range`, builds the
8668 /// `(pk_u64, encoded_body, IndexKey)` triples that the
8669 /// coordinator's k-way merge consumes, sorts the slice by
8670 /// `pk_u64`, and returns a [`FreezeSlice`].
8671 ///
8672 /// Caller invariants:
8673 /// - `row_range.end <= table.rows.len()` (caller's job to
8674 /// compute the partition).
8675 /// - All slices passed to `commit_freeze_slices` must cover a
8676 /// contiguous half-open range `[0, total_max_rows)` with no
8677 /// gaps and no overlaps. The coordinator validates this
8678 /// invariant before committing.
8679 ///
8680 /// `&self`-only — multiple workers can run this concurrently
8681 /// against the same `Catalog` reference under the engine's
8682 /// write lock (workers don't mutate; the coordinator does).
8683 pub fn prepare_freeze_slice(
8684 &self,
8685 table_name: &str,
8686 index_name: &str,
8687 row_range: core::ops::Range<usize>,
8688 ) -> Result<FreezeSlice, StorageError> {
8689 let table = self.get(table_name).ok_or_else(|| {
8690 StorageError::Corrupt(format!(
8691 "prepare_freeze_slice: table {table_name:?} not found"
8692 ))
8693 })?;
8694 let idx = table
8695 .indices
8696 .iter()
8697 .find(|i| i.name == index_name)
8698 .ok_or_else(|| {
8699 StorageError::Corrupt(format!(
8700 "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
8701 ))
8702 })?;
8703 if !matches!(idx.kind, IndexKind::BTree(_)) {
8704 return Err(StorageError::Corrupt(format!(
8705 "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
8706 )));
8707 }
8708 if row_range.end > table.rows.len() {
8709 return Err(StorageError::Corrupt(format!(
8710 "prepare_freeze_slice: row_range end {} > row_count {}",
8711 row_range.end,
8712 table.rows.len()
8713 )));
8714 }
8715 let column_position = idx.column_position;
8716 let schema = table.schema.clone();
8717 let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
8718 for row_idx in row_range.clone() {
8719 let row = table.rows.get(row_idx).expect("bounds-checked above");
8720 let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
8721 StorageError::Corrupt(format!(
8722 "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
8723 ))
8724 })?;
8725 let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
8726 StorageError::Corrupt(format!(
8727 "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
8728 v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
8729 ))
8730 })?;
8731 rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
8732 }
8733 rows.sort_by_key(|(k, _, _)| *k);
8734 Ok(FreezeSlice { row_range, rows })
8735 }
8736
8737 /// v6.7.4 — coordinator commit step. Merges N
8738 /// [`FreezeSlice`]s into one segment via the standard
8739 /// [`encode_segment`] path, atomically swaps the catalog
8740 /// state (delete the union row range + register Cold
8741 /// locators + load the segment).
8742 ///
8743 /// Validates that the slices cover a contiguous, gap-free,
8744 /// overlap-free half-open range starting at index 0 (the
8745 /// freezer always freezes "oldest first" — same semantics as
8746 /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
8747 ///
8748 /// Empty `slices` → no-op success (returns a zero-row report
8749 /// without mutating). Total row count = `Σ slice.rows.len()`.
8750 pub fn commit_freeze_slices(
8751 &mut self,
8752 table_name: &str,
8753 index_name: &str,
8754 slices: Vec<FreezeSlice>,
8755 ) -> Result<FreezeReport, StorageError> {
8756 // --- validation phase: never mutates ---------------------
8757 let table = self.get(table_name).ok_or_else(|| {
8758 StorageError::Corrupt(format!(
8759 "commit_freeze_slices: table {table_name:?} not found"
8760 ))
8761 })?;
8762 let idx = table
8763 .indices
8764 .iter()
8765 .find(|i| i.name == index_name)
8766 .ok_or_else(|| {
8767 StorageError::Corrupt(format!(
8768 "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
8769 ))
8770 })?;
8771 if !matches!(idx.kind, IndexKind::BTree(_)) {
8772 return Err(StorageError::Corrupt(format!(
8773 "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
8774 )));
8775 }
8776 // Validate slice coverage: contiguous from 0, no gaps, no
8777 // overlaps. Allow the caller to pass slices in any order —
8778 // sort by row_range.start first.
8779 let mut ordered = slices;
8780 ordered.sort_by_key(|s| s.row_range.start);
8781 // Drop fully-empty slices that fell out of an uneven
8782 // partition; they carry no data but contribute to the
8783 // contiguity check, so keep them in line.
8784 let mut expected_start = 0usize;
8785 for s in &ordered {
8786 if s.row_range.start != expected_start {
8787 return Err(StorageError::Corrupt(format!(
8788 "commit_freeze_slices: gap/overlap at row {}; expected start {}",
8789 s.row_range.start, expected_start
8790 )));
8791 }
8792 expected_start = s.row_range.end;
8793 }
8794 let max_rows = expected_start;
8795 if max_rows > table.rows.len() {
8796 return Err(StorageError::Corrupt(format!(
8797 "commit_freeze_slices: total row range {} exceeds row_count {}",
8798 max_rows,
8799 table.rows.len()
8800 )));
8801 }
8802 if max_rows == 0 {
8803 return Ok(FreezeReport {
8804 segment_id: u32::MAX,
8805 frozen_rows: 0,
8806 bytes_freed: 0,
8807 segment_bytes: Vec::new(),
8808 });
8809 }
8810
8811 // --- segment build phase: reads only --------------------
8812 // K-way merge of already-sorted slices. Each slice's rows
8813 // are ascending by pk_u64; we keep a per-slice cursor and
8814 // pull the next-smallest head until every cursor drains.
8815 let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
8816 if total_rows != max_rows {
8817 return Err(StorageError::Corrupt(format!(
8818 "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
8819 )));
8820 }
8821 let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
8822 let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
8823 loop {
8824 // Pick the slice whose head row has the smallest key
8825 // and isn't yet exhausted.
8826 let mut pick: Option<usize> = None;
8827 for (i, c) in cursors.iter().enumerate() {
8828 let slice = &ordered[i];
8829 if *c >= slice.rows.len() {
8830 continue;
8831 }
8832 match pick {
8833 None => pick = Some(i),
8834 Some(j) => {
8835 if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
8836 pick = Some(i);
8837 }
8838 }
8839 }
8840 }
8841 let Some(i) = pick else { break };
8842 let row = ordered[i].rows[cursors[i]].clone();
8843 cursors[i] += 1;
8844 merged.push(row);
8845 }
8846 // Reject duplicate PKs — same error as the single-threaded
8847 // path so callers get a uniform surface.
8848 for w in merged.windows(2) {
8849 if w[0].0 == w[1].0 {
8850 return Err(StorageError::Corrupt(format!(
8851 "commit_freeze_slices: duplicate PK {} across slices",
8852 w[0].0
8853 )));
8854 }
8855 }
8856 let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
8857 let seg_rows: Vec<(u64, Vec<u8>)> =
8858 merged.into_iter().map(|(k, body, _)| (k, body)).collect();
8859 let frozen_rows = seg_rows.len();
8860 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
8861 .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
8862
8863 // --- atomic swap phase: mutations only past this point ---
8864 let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
8865 let positions: Vec<usize> = (0..max_rows).collect();
8866 let t_mut = self
8867 .get_mut(table_name)
8868 .expect("just validated; still present");
8869 let removed = t_mut.delete_rows(&positions);
8870 debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
8871 let bytes_after = t_mut.hot_bytes();
8872 let bytes_freed = bytes_before.saturating_sub(bytes_after);
8873
8874 let segment_id = self
8875 .load_segment_bytes(seg_bytes.clone())
8876 .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
8877 let new_cold = post_swap_keys.into_iter().map(|k| {
8878 (
8879 k,
8880 RowLocator::Cold {
8881 segment_id,
8882 page_offset: 0,
8883 },
8884 )
8885 });
8886 let t_mut = self.get_mut(table_name).expect("still present");
8887 t_mut.register_cold_locators(index_name, new_cold)?;
8888 // r944 — a freeze has to say that it froze something.
8889 //
8890 // `has_cold_rows_fast()` reads the cached count, and neither
8891 // freeze path touched it, so afterwards it answered "no cold
8892 // rows" while cold rows existed. That predicate gates four join
8893 // paths, and a gate that wrongly declines the cold-aware path
8894 // drops the frozen rows from the answer.
8895 //
8896 // Marking it stale rather than adding to it: stale reads as
8897 // true, which is the safe direction, and this function cannot
8898 // know the exact total (rows may already have been cold). ANALYZE
8899 // recomputes the number.
8900 t_mut.mark_cold_row_count_stale();
8901
8902 Ok(FreezeReport {
8903 segment_id,
8904 frozen_rows,
8905 bytes_freed,
8906 segment_bytes: seg_bytes,
8907 })
8908 }
8909
8910 /// v6.7.3 — compact every cold segment on `(table, index)` whose
8911 /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
8912 /// into a single larger merged segment. Rows present in source
8913 /// segment payloads but no longer referenced by any
8914 /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
8915 /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
8916 /// merge.
8917 ///
8918 /// **Semantics**:
8919 /// 1. Walk the BTree index to collect every Cold locator that
8920 /// targets a small (< threshold) segment. Each such
8921 /// `(key, segment_id)` becomes a row in the merged segment;
8922 /// payload is looked up from the source segment in-place.
8923 /// 2. Encode the collected rows into one new segment via
8924 /// [`encode_segment`]; register it via
8925 /// [`Catalog::load_segment_bytes`] (allocating a fresh
8926 /// `merged_segment_id` at the end of `cold_segments`).
8927 /// 3. Rewrite the BTree index in one pass: every
8928 /// `RowLocator::Cold { segment_id ∈ sources }` becomes
8929 /// `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
8930 /// Hot locators are untouched.
8931 /// 4. Tombstone every source slot via
8932 /// [`Catalog::tombstone_segment`]. Source segment payloads
8933 /// are no longer reachable through the catalog; the on-disk
8934 /// files are the caller's concern.
8935 ///
8936 /// On fewer than 2 candidate segments the catalog is **not**
8937 /// mutated and a no-op report (`merged_segment_id: None`,
8938 /// `sources: []`) is returned. This is the routine case — a
8939 /// freshly-frozen table has at most 1 small segment, no merge
8940 /// possible.
8941 ///
8942 /// Atomicity: every mutating step runs after the read-only
8943 /// gather phase, so a panic before the merge encode leaves the
8944 /// catalog unchanged. The mutation block itself (load + rewrite +
8945 /// tombstone) takes only `&mut self` — callers serialise the
8946 /// engine write lock outside this function.
8947 ///
8948 /// Errors when the table / index doesn't exist, the index isn't
8949 /// `BTree`, the index column type isn't u64-coercible (cold-tier
8950 /// pre-condition), or a source segment fails its in-place
8951 /// row-body lookup (would indicate prior catalog corruption).
8952 pub fn compact_cold_segments(
8953 &mut self,
8954 table_name: &str,
8955 index_name: &str,
8956 target_segment_bytes: u64,
8957 ) -> Result<CompactReport, StorageError> {
8958 // --- validation phase ----------------------------------
8959 let t = self.get(table_name).ok_or_else(|| {
8960 StorageError::Corrupt(format!(
8961 "compact_cold_segments: table {table_name:?} not found"
8962 ))
8963 })?;
8964 let idx = t
8965 .indices
8966 .iter()
8967 .find(|i| i.name == index_name)
8968 .ok_or_else(|| {
8969 StorageError::Corrupt(format!(
8970 "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
8971 ))
8972 })?;
8973 let map = match &idx.kind {
8974 IndexKind::BTree(m) => m,
8975 IndexKind::Nsw(_)
8976 | IndexKind::Brin { .. }
8977 | IndexKind::Gin(_)
8978 | IndexKind::GinTrgm(_)
8979 | IndexKind::GinFulltext(_)
8980 | IndexKind::GinJsonb(_)
8981 | IndexKind::BTreeMulti(_) => {
8982 return Err(StorageError::Corrupt(format!(
8983 "compact_cold_segments: index {index_name:?} is not BTree; \
8984 compaction applies only to BTree cold-tier indices"
8985 )));
8986 }
8987 };
8988
8989 // --- gather phase --------------------------------------
8990 // Step A: every segment_id this BTree index Cold-references.
8991 let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
8992 for (_key, locators) in map.iter() {
8993 for loc in locators {
8994 if let RowLocator::Cold { segment_id, .. } = loc {
8995 referenced_ids.insert(*segment_id);
8996 }
8997 }
8998 }
8999 // Step B: keep only the small + still-active ones.
9000 let candidate_set: BTreeSet<u32> = referenced_ids
9001 .into_iter()
9002 .filter(|id| {
9003 self.cold_segments
9004 .get(*id as usize)
9005 .and_then(|s| s.as_deref())
9006 .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
9007 })
9008 .collect();
9009 if candidate_set.len() < 2 {
9010 return Ok(CompactReport {
9011 sources: Vec::new(),
9012 merged_segment_id: None,
9013 merged_segment_bytes: Vec::new(),
9014 merged_rows: 0,
9015 deleted_rows_pruned: 0,
9016 bytes_reclaimed_estimate: 0,
9017 });
9018 }
9019 // Step C: pre-count source rows for the deleted-pruned metric.
9020 let mut source_row_count: usize = 0;
9021 let mut source_byte_total: u64 = 0;
9022 for &id in &candidate_set {
9023 let seg = self.cold_segments[id as usize]
9024 .as_ref()
9025 .expect("candidate selected only when slot is Some");
9026 source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
9027 source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
9028 }
9029 // Step D: collect (key, body) pairs from every live Cold
9030 // locator pointing at a candidate. dedupe by key — one
9031 // BTree key resolves to at most one cold payload (the
9032 // freezer + promote/shadow flow keeps Cold locators
9033 // unique per key).
9034 let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
9035 for (key, locators) in map.iter() {
9036 for loc in locators {
9037 let RowLocator::Cold { segment_id, .. } = loc else {
9038 continue;
9039 };
9040 if !candidate_set.contains(segment_id) {
9041 continue;
9042 }
9043 let u64_key = index_key_as_u64(key).ok_or_else(|| {
9044 StorageError::Corrupt(format!(
9045 "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
9046 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
9047 ))
9048 })?;
9049 let seg = self.cold_segments[*segment_id as usize]
9050 .as_ref()
9051 .expect("candidate slot guaranteed Some above");
9052 let payload = seg.lookup(u64_key).ok_or_else(|| {
9053 StorageError::Corrupt(format!(
9054 "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
9055 at segment {segment_id} but the segment lookup missed"
9056 ))
9057 })?;
9058 collected.insert(u64_key, (payload, key.clone()));
9059 break;
9060 }
9061 }
9062 let merged_rows = collected.len();
9063 let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
9064
9065 // Step E: encode the merged segment. `BTreeMap<u64, _>`
9066 // iteration is ascending by key, which is what
9067 // `encode_segment` requires.
9068 let seg_rows: Vec<(u64, Vec<u8>)> = collected
9069 .iter()
9070 .map(|(k, (body, _))| (*k, body.clone()))
9071 .collect();
9072 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
9073 .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
9074 let merged_bytes_len = seg_bytes.len() as u64;
9075
9076 // --- atomic mutation phase ------------------------------
9077 let merged_segment_id = self
9078 .load_segment_bytes(seg_bytes.clone())
9079 .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
9080
9081 // Rewrite the BTree index: every Cold locator pointing at
9082 // a candidate source becomes a Cold locator pointing at
9083 // the merged segment. Use a flat collect-then-replace
9084 // pattern so we never hold a `&self` borrow across the
9085 // `&mut self` write.
9086 let entries: Vec<(IndexKey, crate::posting::PostingList)> = {
9087 let t = self
9088 .get(table_name)
9089 .expect("table existed at the start of this fn");
9090 let idx = t
9091 .indices
9092 .iter()
9093 .find(|i| i.name == index_name)
9094 .expect("index existed at the start of this fn");
9095 let IndexKind::BTree(map) = &idx.kind else {
9096 unreachable!("validated above");
9097 };
9098 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
9099 };
9100 let t_mut = self
9101 .get_mut(table_name)
9102 .expect("table existed at the start of this fn");
9103 let idx_mut = t_mut
9104 .indices
9105 .iter_mut()
9106 .find(|i| i.name == index_name)
9107 .expect("index existed at the start of this fn");
9108 let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
9109 unreachable!("validated above");
9110 };
9111 for (key, locators) in entries {
9112 let mut new_locs = crate::posting::PostingList::new();
9113 let mut changed = false;
9114 for loc in &locators {
9115 match *loc {
9116 RowLocator::Cold {
9117 segment_id,
9118 page_offset: _,
9119 } if candidate_set.contains(&segment_id) => {
9120 let replacement = RowLocator::Cold {
9121 segment_id: merged_segment_id,
9122 page_offset: 0,
9123 };
9124 if !new_locs.contains(replacement) {
9125 new_locs.push(replacement);
9126 }
9127 changed = true;
9128 }
9129 other => new_locs.push(other),
9130 }
9131 }
9132 if changed {
9133 map_mut.insert_mut(key, new_locs);
9134 }
9135 }
9136
9137 // Tombstone every source slot. Last step — failures here
9138 // would leave the segment double-referenced in both
9139 // memory + manifest, but `tombstone_segment` only errors
9140 // on out-of-bounds, which we've already validated.
9141 for &id in &candidate_set {
9142 self.tombstone_segment(id)?;
9143 }
9144
9145 let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
9146 Ok(CompactReport {
9147 sources: candidate_set.into_iter().collect(),
9148 merged_segment_id: Some(merged_segment_id),
9149 merged_segment_bytes: seg_bytes,
9150 merged_rows,
9151 deleted_rows_pruned,
9152 bytes_reclaimed_estimate,
9153 })
9154 }
9155
9156 /// Internal helper: scan `(table, index)` for a `Cold` locator
9157 /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
9158 /// when found, `Ok(None)` when the key has only hot entries
9159 /// or no entries at all, `Err` on the same input-validation
9160 /// errors as the public `promote_cold_row` / `shadow_cold_row`.
9161 fn find_cold_locator(
9162 &self,
9163 table_name: &str,
9164 index_name: &str,
9165 key: &IndexKey,
9166 ) -> Result<Option<(u32, u32)>, StorageError> {
9167 let t = self.get(table_name).ok_or_else(|| {
9168 StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
9169 })?;
9170 let idx = t
9171 .indices
9172 .iter()
9173 .find(|i| i.name == index_name)
9174 .ok_or_else(|| {
9175 StorageError::Corrupt(format!(
9176 "find_cold_locator: index {index_name:?} not found on {table_name:?}"
9177 ))
9178 })?;
9179 if !matches!(idx.kind, IndexKind::BTree(_)) {
9180 return Err(StorageError::Corrupt(format!(
9181 "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
9182 )));
9183 }
9184 for loc in idx.lookup_eq(key) {
9185 if let RowLocator::Cold {
9186 segment_id,
9187 page_offset,
9188 } = *loc
9189 {
9190 return Ok(Some((segment_id, page_offset)));
9191 }
9192 }
9193 Ok(None)
9194 }
9195}
9196
9197/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
9198/// segments use as their on-disk PK. Returns `None` for keys that
9199/// aren't representable as `u64` — Text PKs need a hash mapping
9200/// the segment writer baked in (deferred to v5.2+), Bool PKs are
9201/// almost never wide enough to be sharded into a cold tier.
9202fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
9203 match key {
9204 // Reinterpret the i64 bit pattern as u64. Cold-tier segments
9205 // are sorted by this u64 view, so the chosen interpretation
9206 // only has to match between insert (bake_segment / freezer)
9207 // and lookup — using cast_unsigned keeps both sides honest
9208 // and silences clippy::cast_sign_loss.
9209 IndexKey::Int(n) => Some(n.cast_unsigned()),
9210 // Text / Bool / Uuid / Bytes / Numeric PKs aren't representable
9211 // as u64 and so can't participate in the u64-sorted cold-tier
9212 // segment PK layout. Same deferral story as Text — lookup falls
9213 // through the in-memory btree.
9214 IndexKey::Text(_)
9215 | IndexKey::Bool(_)
9216 | IndexKey::Uuid(_)
9217 | IndexKey::Bytes(_)
9218 | IndexKey::Numeric(_)
9219 | IndexKey::Null => None,
9220 }
9221}
9222
9223#[derive(Debug, Clone, PartialEq, Eq)]
9224#[non_exhaustive]
9225pub enum StorageError {
9226 DuplicateTable {
9227 name: String,
9228 },
9229 TableNotFound {
9230 name: String,
9231 },
9232 ArityMismatch {
9233 expected: usize,
9234 actual: usize,
9235 },
9236 TypeMismatch {
9237 column: String,
9238 expected: DataType,
9239 actual: DataType,
9240 position: usize,
9241 },
9242 NullInNotNull {
9243 column: String,
9244 },
9245 /// Index with this name already exists on the table.
9246 DuplicateIndex {
9247 name: String,
9248 },
9249 /// Column referenced by an index doesn't exist on the table.
9250 ColumnNotFound {
9251 column: String,
9252 },
9253 /// On-disk format failed to parse — corrupted file, wrong magic, truncated
9254 /// payload, or unknown tag bytes.
9255 Corrupt(String),
9256 /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
9257 /// exist on any table in this catalog.
9258 IndexNotFound {
9259 name: String,
9260 },
9261 /// v6.0.4 — operation requested isn't supported on this index
9262 /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
9263 /// index, or REBUILD WITH (encoding=…) on a non-vector column).
9264 Unsupported(String),
9265 /// v7.39 (round 220) — a CYCLE-less sequence ran past its bound.
9266 /// PG's 2200H phrasing: `nextval: reached maximum value of
9267 /// sequence "s" (n)` (`is_max: false` = the MINVALUE direction).
9268 SequenceExhausted {
9269 name: String,
9270 limit: i64,
9271 is_max: bool,
9272 },
9273}
9274
9275impl fmt::Display for StorageError {
9276 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
9277 match self {
9278 // v7.39 (read01 round 47) — PG's 42P07 wording.
9279 Self::DuplicateTable { name } => write!(f, "relation \"{name}\" already exists"),
9280 // v7.39 (read01 round 47) — PG's wording for a missing relation
9281 // (42P01). DROP TABLE says "table" and raises its own error at
9282 // the engine; every other path (SELECT / ALTER / …) says
9283 // "relation", which is what this carries.
9284 Self::TableNotFound { name } => write!(f, "relation \"{name}\" does not exist"),
9285 Self::ArityMismatch { expected, actual } => write!(
9286 f,
9287 "row arity mismatch: expected {expected} columns, got {actual}"
9288 ),
9289 Self::TypeMismatch {
9290 column,
9291 expected,
9292 actual,
9293 position,
9294 } => write!(
9295 f,
9296 "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
9297 ),
9298 Self::NullInNotNull { column } => {
9299 // v7.39 (SQLSTATE fidelity) — PG's 23502 phrasing (the
9300 // relation-qualified long form is added by engine call
9301 // sites that know the table name).
9302 write!(
9303 f,
9304 "null value in column \"{column}\" violates not-null constraint"
9305 )
9306 }
9307 // v7.39 (read01 round 47) — an index is a relation to PG (42P07).
9308 Self::DuplicateIndex { name } => write!(f, "relation \"{name}\" already exists"),
9309 // v7.39 (round 701) — PG's wording, and the same fix `EvalError::
9310 // ColumnNotFound` took in read01 round 81 with the same reason:
9311 // "column not found: x" matches none of the wire layer's `does
9312 // not exist` patterns, so a missing column reached the client as
9313 // the generic error class. The eval-side variant was changed and
9314 // the storage-side one was not, so which sentence you got
9315 // depended on which layer noticed — `CREATE INDEX ix ON t(nope)`
9316 // came out of storage and kept the old spelling.
9317 Self::ColumnNotFound { column } => write!(f, "column \"{column}\" does not exist"),
9318 Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
9319 Self::IndexNotFound { name } => write!(f, "index \"{name}\" does not exist"),
9320 Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
9321 // v7.39 (round 220) — PG's exact 2200H wording.
9322 Self::SequenceExhausted {
9323 name,
9324 limit,
9325 is_max,
9326 } => write!(
9327 f,
9328 "nextval: reached {} value of sequence \"{name}\" ({limit})",
9329 if *is_max { "maximum" } else { "minimum" }
9330 ),
9331 }
9332 }
9333}
9334
9335impl ColumnSchema {
9336 pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
9337 Self {
9338 name: name.into(),
9339 ty,
9340 nullable,
9341 collation_name: None,
9342 default: None,
9343 runtime_default: None,
9344 auto_increment: false,
9345 user_enum_type: None,
9346 user_domain_type: None,
9347 user_composite_type: None,
9348 acl: Vec::new(),
9349 on_update_runtime: None,
9350 collation: Collation::Binary,
9351 is_unsigned: false,
9352 inline_enum_variants: None,
9353 inline_set_variants: None,
9354 generated_stored_expr: None,
9355 identity_always: false,
9356 default_text: None,
9357 auto_restart: None,
9358 scalar_row_source: false,
9359 mysql_int_width: None,
9360 mysql_fsp: None,
9361 mysql_declared_timestamp: false,
9362 mysql_float_md: None,
9363 }
9364 }
9365
9366 /// v7.38.14 — the SAME column, re-described.
9367 ///
9368 /// `ColumnSchema::new` is for SYNTHESISING a column: a catalog row, an
9369 /// admin view, a computed output. It sets twenty-two fields to their
9370 /// defaults, which is right when there is no source column to speak of.
9371 ///
9372 /// It is wrong, and quietly so, when there IS one -- a join's combined
9373 /// schema, an aggregate's synthetic keys, a derived table's output. Those
9374 /// sites re-describe an existing column under a new name or type, and
9375 /// have each been written as `new(..)` followed by hand-picking a few
9376 /// attributes to copy across. They all pick differently and none picks
9377 /// them all.
9378 ///
9379 /// Five fields have been lost through that shape so far -- enum identity,
9380 /// MySQL fsp, the PG collation name, `ProjectedItem::fold_exempt`, and
9381 /// the `collation` enum -- and v7.38.14 alone found four sites dropping
9382 /// the last of those. The failure is never loud: `collation` defaults to
9383 /// `Binary`, which downstream reads as "byte-wise ON PURPOSE" rather than
9384 /// as "unknown", so a dropped declaration presents as a deliberate one.
9385 ///
9386 /// This constructor copies everything by construction. A field added to
9387 /// `ColumnSchema` therefore reaches every re-describe site without anyone
9388 /// having to remember, which is the property the hand-written copy lists
9389 /// never had.
9390 ///
9391 /// The two fields a re-describe legitimately changes -- name and
9392 /// nullability -- are parameters. Callers that also retype the column
9393 /// assign `ty` afterwards.
9394 #[must_use]
9395 pub fn rederive(source: &Self, name: impl Into<String>, nullable: bool) -> Self {
9396 Self {
9397 name: name.into(),
9398 nullable,
9399 ..source.clone()
9400 }
9401 }
9402
9403 /// Builder-style helper to attach a default value to an otherwise
9404 /// plain column schema. Used by the engine when CREATE TABLE
9405 /// specifies `column TYPE DEFAULT <expr>`.
9406 #[must_use]
9407 pub fn with_default(mut self, default: Value<'static>) -> Self {
9408 self.default = Some(default);
9409 self
9410 }
9411
9412 /// v7.9.21 — builder for runtime-evaluated defaults
9413 /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
9414 /// `expr` is the Expr's `Display` form, re-parsed by the
9415 /// engine at each INSERT.
9416 #[must_use]
9417 pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
9418 self.runtime_default = Some(expr.into());
9419 self
9420 }
9421
9422 /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
9423 #[must_use]
9424 pub const fn with_auto_increment(mut self) -> Self {
9425 self.auto_increment = true;
9426 self
9427 }
9428}
9429
9430impl TableSchema {
9431 pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
9432 Self {
9433 name: name.into(),
9434 columns,
9435 hot_tier_bytes: None,
9436 foreign_keys: Vec::new(),
9437 uniqueness_constraints: Vec::new(),
9438 exclusion_constraints: Vec::new(),
9439 checks: Vec::new(),
9440 partition_role: None,
9441 policies: Vec::new(),
9442 row_security: false,
9443 force_row_security: false,
9444 owner: None,
9445 acl: Vec::new(),
9446 }
9447 }
9448}
9449
9450// =========================================================================
9451// Persistent binary format for the catalog.
9452//
9453// Layout (little-endian throughout):
9454//
9455// [magic "SPGDB001" 8 bytes][version u8]
9456// [table_count u32]
9457// for each table:
9458// [name_len u16][name bytes]
9459// [col_count u16]
9460// for each col:
9461// [name_len u16][name bytes]
9462// [type_tag u8 + optional payload]
9463// 1=Int 2=BigInt 3=Float 4=Text 5=Bool
9464// 6=Vector(u32 dim)
9465// 7=SmallInt
9466// 8=Varchar(u32 max)
9467// 9=Char(u32 size)
9468// 10=Numeric(u8 precision, u8 scale)
9469// 11=Date
9470// 12=Timestamp
9471// [nullable u8] 0/1
9472// [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
9473// [row_count u32]
9474// for each row, for each col, one [value_tag u8] + value bytes:
9475// tag 0 (Null) → no body
9476// tag 1 (Int) → i32 LE
9477// tag 2 (BigInt) → i64 LE
9478// tag 3 (Float) → f64 LE
9479// tag 4 (Text) → u16 LE len + UTF-8 bytes
9480// tag 5 (Bool) → u8 0/1
9481// tag 6 (Vector) → u32 LE dim + dim×f32 LE
9482// tag 7 (SmallInt) → i16 LE
9483// tag 8 (Numeric) → i128 LE (16 bytes) + u8 scale
9484// tag 9 (Date) → i32 LE (days since Unix epoch)
9485// tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
9486//
9487// Bumped to version 3 when NUMERIC was added; to version 4 when
9488// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
9489// to version 5 when DATE / TIMESTAMP were added; to version 6 when
9490// NSW graph topology started travelling on disk (v2.7); to version 7
9491// when the NSW topology became multi-layer HNSW (v2.13); to version 8
9492// when row encoding switched to schema-driven dense layout (v3.0.2 —
9493// per-row NULL bitmap + per-column fixed-width body, no per-cell type
9494// tag).
9495// =========================================================================
9496
9497const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
9498/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
9499///
9500/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
9501/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
9502/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
9503/// entries at all (the map was rebuilt from `Table::rows` on load); v9
9504/// preserves on-disk Cold locators so freezer-produced cold-tier index
9505/// entries survive a catalog snapshot round-trip. v8 readers are accepted
9506/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
9507/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
9508/// behaviour.
9509/// v6.7.2 — bumped from 10 to 11 to append per-table
9510/// `hot_tier_bytes: Option<u64>` after the per-table indices
9511/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
9512/// None` for every table (the deserialiser short-circuits when
9513/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
9514/// fail loudly at the version check, matching the v6.1.2 /
9515/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
9516///
9517/// v6.8.0 — bumped from 11 to 12: per-index
9518/// `included_columns: Vec<u16>` appended at the tail of each
9519/// index payload. v11 (= v6.7.2) catalogs load with
9520/// `included_columns = Vec::new()` for every index — same
9521/// "older readers, append-only extension" pattern as the v6.7.2
9522/// hot_tier_bytes byte.
9523/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
9524/// Per-table appendix gains two new sections:
9525/// * `checks: Vec<String>` — CHECK predicate sources (Display
9526/// form of the AST Expr); re-parsed on INSERT/UPDATE to
9527/// enforce against candidate rows. Same persistence pattern
9528/// as `Index::partial_predicate`.
9529/// * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
9530/// u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
9531/// semantics.
9532/// v22 catalogs deserialise with empty `checks` and every UC
9533/// at `nulls_not_distinct = false`.
9534/// v24 introduces:
9535/// * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
9536/// `USING gin` over a TEXT/VARCHAR column). Payload shape is
9537/// identical to tag-3 GIN (String → Vec<RowLocator>); the
9538/// keys are PG-compatible 3-byte trigram shingles instead of
9539/// tsvector lexemes. v23 catalogs deserialise unchanged — no
9540/// v23 writer ever emitted tag 4.
9541/// v25 introduces:
9542/// * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
9543/// round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
9544/// TRIGGER …`). v24 catalogs deserialise with every trigger
9545/// `enabled = true`, matching pre-v7.16.1 behaviour.
9546/// v26 introduces (v7.17.0 Phase 1.1):
9547/// * Trailing SEQUENCE catalog block after triggers. Encoded
9548/// as `u32 count` followed by per-sequence:
9549/// `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
9550/// `start i64`, `increment i64`, `min_value i64`,
9551/// `max_value i64`, `cache i64`, `cycle u8`,
9552/// `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
9553/// `last_value i64`, `is_called u8`. v25-and-below catalogs
9554/// deserialise with an empty sequences map.
9555/// v27 introduces (v7.17.0 Phase 1.2):
9556/// * Trailing VIEW catalog block after sequences. Encoded as
9557/// `u32 count` followed by per-view:
9558/// `name`, `column_count u16`, then column names, then
9559/// `body` long-string. v26-and-below catalogs deserialise
9560/// with an empty views map.
9561/// v28 introduces (v7.17.0 Phase 1.3):
9562/// * Trailing MATERIALIZED VIEW source registry block after
9563/// views. Encoded as `u32 count` followed by per-entry:
9564/// `name`, `body` long-string. The materialised rows live
9565/// as a regular Table of the same name (already covered by
9566/// the pre-existing tables block). v27-and-below catalogs
9567/// deserialise with an empty map.
9568/// v29 introduces (v7.17.0 Phase 1.4):
9569/// * Per-table user_enum_type appendix (after the CHECK
9570/// appendix). Layout: `u16 count` followed by per-binding
9571/// `[u16 col_pos][str enum_name]`. Only columns whose
9572/// `user_enum_type` is Some land here; the catalog stays
9573/// compact for the common no-enum case.
9574/// * Trailing ENUM types catalog block after materialized
9575/// views. Encoded as `u32 count` followed by per-entry:
9576/// `name`, `u16 label_count`, then `label_count` short
9577/// strings. v28-and-below catalogs deserialise with an
9578/// empty enum_types map and every column's
9579/// `user_enum_type = None`.
9580/// v30 introduces (v7.17.0 Phase 1.5):
9581/// * Per-table user_domain_type appendix (after the
9582/// user_enum_type appendix). Same shape as the enum one.
9583/// * Trailing DOMAIN types catalog block after the enum
9584/// block. Encoded as `u32 count` followed by per-entry:
9585/// `name`, `data_type` byte, `nullable u8`,
9586/// `default_present u8` + optional default string,
9587/// `u16 check_count` then `check_count` Display-form
9588/// CHECK strings. v29-and-below catalogs deserialise with
9589/// an empty domain_types map and `user_domain_type = None`.
9590/// v31 introduces (v7.17.0 Phase 1.6):
9591/// * Trailing user-schemas block after the DOMAIN block.
9592/// Encoded as `u32 count` followed by `count` schema-name
9593/// short strings. Built-in schemas (`public`, `pg_catalog`,
9594/// `information_schema`) are NOT serialised — they're
9595/// hardcoded in `is_builtin_schema`. v30-and-below catalogs
9596/// deserialise with an empty user-schemas set.
9597/// v32 introduces (v7.17.0 Phase 2.1):
9598/// * Per-table on_update_runtime appendix (after the
9599/// user_domain_type appendix). Layout: `u16 count` followed
9600/// by per-binding `[u16 col_pos][str expr_src]`. Only
9601/// columns whose `on_update_runtime` is Some land here;
9602/// the catalog stays compact when no MySQL-shaped table
9603/// uses the attribute. v31-and-below catalogs deserialise
9604/// with every column's `on_update_runtime = None`.
9605/// v33 introduces (v7.17.0 Phase 2.2):
9606/// * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
9607/// surface over a TEXT / VARCHAR column). Payload shape is
9608/// identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
9609/// the keys are lower-cased word lexemes (same rule as
9610/// `to_tsvector('simple', text)`). v32 catalogs deserialise
9611/// unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
9612/// KEY was silently dropped pre-v7.17 so no rebuild shim is
9613/// needed for round-tripped catalogs.
9614/// v34 introduces (v7.17.0 Phase 2.5):
9615/// * Per-table collation appendix (after the on_update_runtime
9616/// appendix). Sparse layout: only columns whose `collation`
9617/// is non-Binary land here. `u16 count` then per-binding
9618/// `[u16 col_pos][u8 collation_tag]` where the tag matches
9619/// `Collation::TAG_*`. Snapshots written by v33-and-below
9620/// readers deserialise every column with `collation =
9621/// Binary`, preserving the prior byte-wise compare
9622/// semantics. Unknown tags read back as Binary too — keeps
9623/// a forward-compat path if a future v35 adds variants
9624/// and someone rolls back to a v34 reader.
9625/// v35 introduces (v7.17.0 Phase 4.4):
9626/// * Per-table is_unsigned appendix (after the collation
9627/// appendix). Sparse layout: only `is_unsigned = true`
9628/// columns land. `u16 count` then per-binding `[u16 col_pos]`.
9629/// v34-and-below catalogs deserialise every column as
9630/// `is_unsigned = false`, preserving the prior silent-
9631/// accept behaviour for negative inserts on UNSIGNED columns.
9632/// v46 introduces (v7.23, mailrs round-14):
9633/// * Escaped short-string codec — `write_str` lengths >= 0xFFFF
9634/// emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
9635/// document text) above 64 KiB encode instead of panicking.
9636/// One-way upgrade: v45-and-below readers reject v46 catalogs
9637/// loudly via the version gate; v46 readers decode v45 catalogs
9638/// with the plain-u16 rules (0xFFFF is a legitimate length
9639/// there).
9640/// v47 introduces (v7.27, mailrs round-21):
9641/// * Escaped lengths for the REMAINING u16-length cell payloads —
9642/// BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
9643/// terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
9644/// gave short strings. Round-14 fixed TEXT and missed these;
9645/// round-21 fired the BYTEA twin during a production migration.
9646/// One-way upgrade, same posture as v46.
9647/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
9648/// * `INTERVAL` becomes a real column type. Catalog tag 34 in
9649/// `write_data_type`; per-row body is a fixed 16 bytes
9650/// (i64 micros + i32 days + i32 months, LE, PG-byte-equal
9651/// field order). The runtime-only days collapse is gone —
9652/// `'1 day'` and `'24 hours'` are stored distinctly. One-way
9653/// upgrade: v47 catalogs without INTERVAL columns deserialise
9654/// identically; v47 readers fed a v48 catalog that contains
9655/// INTERVAL hit the explicit "unknown data type tag: 34"
9656/// fence in `read_data_type`.
9657/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
9658/// * Per-table partition role appendix(declarative
9659/// `PARTITION BY RANGE` parent / range child / DEFAULT
9660/// child)。Layout, written **after** the inline_set_variants
9661/// appendix and **before** the per-table block close:
9662/// `[u8 role_tag]`
9663/// 0 = `None`(普通表,后向兼容默认)
9664/// 1 = `Parent`: `[u8 kind_tag (0=Range)]`
9665/// `[u16 key_col_count]` `(× u16 col_pos)`
9666/// `[u16 tmpl_count]` `(× str source)`
9667/// 2 = `Range`: `[str parent_name]` `[Bound]` `[Bound]`
9668/// 3 = `Default`: `[str parent_name]`
9669/// `PartitionBound` codec:
9670/// `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
9671/// v48-and-below readers stop after the inline_set_variants
9672/// block — they don't see this appendix and deserialise every
9673/// table with `partition_role = None`. v49 writers always emit
9674/// `[0]` for plain tables, so the encoding stays one-byte-cheap.
9675/// v50 introduces (v7.37.7, sentori Epic 3 P1):
9676/// * Per-table `generated_stored_expr` appendix(stored generated
9677/// columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
9678/// written **after** the partition_role appendix and before
9679/// the per-table block close:
9680/// `[u16 binding_count]`
9681/// `binding_count × { [u16 col_pos][str expr_source] }`
9682/// Sparse — only generated columns land here, so plain-shape
9683/// catalogs stay byte-for-byte identical save for the new
9684/// u16 zero count. v49-and-below readers stop after the
9685/// partition_role appendix; v50 readers default every column
9686/// to `generated_stored_expr = None` when this block is absent.
9687/// v51 introduces (v7.37.8, sentori Epic 5 P2):
9688/// * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
9689/// over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
9690/// `[u32 posting_list_count]` then `(str token, u32 locator_count,
9691/// locators …)` per posting list. Same `write_str` /
9692/// `RowLocator::write_le` codec as the rest of the GIN family.
9693/// v50 catalogs never wrote tag 6(the same DDL loaded as a
9694/// BTree fallback); v51 readers see tag 6 explicitly and dispatch
9695/// into `IndexKind::GinJsonb`.
9696/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
9697/// * Trailing COMPOSITE-types catalog block after the
9698/// user-schemas block. Encoded as `u32 count` followed by
9699/// per-entry: `name`, `u16 field_count`, then `field_count`
9700/// `[str field_name][data_type]` pairs (`write_data_type` is
9701/// reused). v51-and-below catalogs deserialise with an empty
9702/// composite_types map; v52 readers tolerate v51 catalogs by
9703/// stopping at the schema block (no composite block present
9704/// ⇒ empty map). Composite types are referenced by columns
9705/// via `ColumnSchema.user_composite_type`, mirroring the
9706/// `user_enum_type` / `user_domain_type` pattern. The block
9707/// lands here (not as a per-table appendix) so dropping the
9708/// composite type registers globally and DROP TYPE can find it
9709/// without a table scan.
9710/// v53 introduces (v7.37.16 Epic W — cross-checkpoint tombstone
9711/// durability):
9712/// * Trailing per-table MVCC appendix carrying, for every row,
9713/// its `RowHeader` (`xmin:u64`, `xmax:u64`, `flags:u8`) and its
9714/// stable `RowId` (`u64`), followed by the relation's
9715/// `next_rowid:u64`. Layout per table (after the v50
9716/// generated_stored_expr block, before the table loop closes):
9717/// `[u32 row_count]` (== `Table::rows().len()`, cross-check)
9718/// per row in physical order:
9719/// `[u64 xmin][u64 xmax][u8 flags][u64 rowid]`
9720/// `[u64 next_rowid]`
9721/// v52-and-below catalogs never wrote this block; their reader
9722/// stops after the last per-table appendix and
9723/// `deserialize_rows` leaves every row `RowHeader::frozen()`
9724/// with dense 1..=N ids — the exact pre-v53 contract. A v53
9725/// reader instead reconstructs headers + ids VERBATIM, so a
9726/// tombstone-redo naming a row inserted before the last
9727/// checkpoint resolves by `RowId` across the base-snapshot
9728/// boundary (closing the coupling the Epic W WAL slices deferred
9729/// to this format bump). Because the reader routes on `version`,
9730/// the block is strictly backward-compatible: old images load
9731/// byte-for-byte as before. `SPG_MVCC_INPLACE` is unaffected —
9732/// a gate-off database's rows are all frozen/alive, so
9733/// persisting + restoring their headers is observationally a
9734/// no-op.
9735/// v7.38 (read01 P5.05) — v54 appends a CRC32C over the whole preceding
9736/// image so a corrupted `base.spg` is caught on load instead of silently
9737/// deserialising garbage. Older images (v8..=53) carry no trailer and load
9738/// unchanged.
9739/// v7.39 (round 210) — v72 appends a per-table EXCLUDE-constraint appendix
9740/// (sparse: only tables carrying an EXCLUDE write it) at the very end of the
9741/// per-table block, after the column-ACL appendix. A v71 reader stops before
9742/// it and its tables read back with no exclusion constraints, which is what
9743/// they were.
9744/// v7.39 (round 220) — v73 appends a per-table identity-RESTART appendix
9745/// (sparse: [u16 count] then per entry [u16 col_pos][i64 LE floor]) after
9746/// the EXCLUDE appendix. A v72 reader stops before it; its columns read
9747/// back with no RESTART floor, losing only an un-consumed
9748/// `ALTER … RESTART WITH` across a restart.
9749/// r1039 — v90 adds index-key tags 4 (bytea) and 5 (the canonical
9750/// numeric key), so BYTEA and NUMERIC columns carry a real B-tree
9751/// instead of falling back to a scan. A v89 reader meeting either tag
9752/// reports a corrupt catalog rather than mis-reading it, which is the
9753/// same forward-compatibility story tag 3 (uuid) had at v36.
9754/// v7.39.13 — v97 changes what a TIMETZ key CONTAINS. It held the UTC
9755/// instant alone, which files values PostgreSQL calls distinct under
9756/// one key; it now holds the instant and the offset, in the pair order
9757/// [`timetz_sort_key`] defines. Nothing before v97 could observe the
9758/// old form — `timetz` had no comparison operator, so no probe was ever
9759/// built — but a v96 file's entries are in it, so a v96 catalog has its
9760/// timetz indexes rebuilt on load.
9761const FILE_VERSION: u8 = 97;
9762
9763/// v7.37 (round 833) — the codec version to decode a row that
9764/// [`encode_row_body_dense`] has just produced.
9765///
9766/// That encoder always writes the newest form, and every decoder gate is
9767/// a `codec_version >= N` feature test, so a freshly encoded row must be
9768/// read at the current version. Cold segments carry their own version in
9769/// their header and keep passing that; this is for in-process round
9770/// trips — sort runs on temp storage — where the bytes never outlive the
9771/// build that wrote them.
9772pub const CURRENT_ROW_CODEC_VERSION: u8 = FILE_VERSION;
9773/// First version that appends the trailing CRC32C integrity trailer.
9774const FILE_VERSION_CRC_TRAILER: u8 = 54;
9775/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
9776/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
9777const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
9778
9779// IndexKey wire format (v9):
9780// tag 0 = Int → [i64 LE]
9781// tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
9782// tag 2 = Bool → [u8 0/1]
9783const INDEX_KEY_TAG_INT: u8 = 0;
9784const INDEX_KEY_TAG_TEXT: u8 = 1;
9785const INDEX_KEY_TAG_BOOL: u8 = 2;
9786/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
9787/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
9788/// catalogs.
9789const INDEX_KEY_TAG_UUID: u8 = 3;
9790/// r1039 — `IndexKey::Bytes`. Body = [u32 LE len][raw bytes].
9791/// Persisted only in FILE_VERSION 90+ catalogs.
9792const INDEX_KEY_TAG_BYTES: u8 = 4;
9793/// r1039 — `IndexKey::Numeric`. Body = [u8 class][u8 neg][i32 LE exp]
9794/// [u32 LE digit count][one byte per decimal digit, 0..=9, MSD first].
9795/// Persisted only in FILE_VERSION 90+ catalogs.
9796const INDEX_KEY_TAG_NUMERIC: u8 = 5;
9797/// v7.38.1 (L12) — `IndexKey::Null`, a NULL component inside a
9798/// composite key. No body. Persisted only inside tag-7 multi-index
9799/// payloads, FILE_VERSION 91+.
9800const INDEX_KEY_TAG_NULL: u8 = 6;
9801
9802impl Catalog {
9803 /// Serialize the whole catalog (schema + every row) into a self-contained
9804 /// byte buffer. Format is documented above the impl block.
9805 pub fn serialize(&self) -> Vec<u8> {
9806 let mut out = Vec::with_capacity(64);
9807 out.extend_from_slice(FILE_MAGIC);
9808 out.push(FILE_VERSION);
9809 write_u32(
9810 &mut out,
9811 u32::try_from(self.tables.len()).expect("≤ 4G tables"),
9812 );
9813 for t in &self.tables {
9814 write_str(&mut out, &t.schema.name);
9815 write_u16(
9816 &mut out,
9817 u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
9818 );
9819 for c in &t.schema.columns {
9820 write_str(&mut out, &c.name);
9821 write_data_type(&mut out, c.ty);
9822 out.push(u8::from(c.nullable));
9823 match &c.default {
9824 None => out.push(0),
9825 Some(v) => {
9826 out.push(1);
9827 write_value(&mut out, v);
9828 }
9829 }
9830 out.push(u8::from(c.auto_increment));
9831 }
9832 write_u32(
9833 &mut out,
9834 u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
9835 );
9836 // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
9837 // bitmap, then tightly-packed bodies. Identical wire format
9838 // as before — extracted into `encode_row_body_dense` so cold-
9839 // tier segments (v5.1+) can share the encoding.
9840 for row in &t.rows {
9841 out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
9842 }
9843 // Index definitions. Per-index payload:
9844 // [name][col_pos u16][kind u8]
9845 // kind 0 = B-tree (no params — rebuilt on load)
9846 // kind 1 = NSW graph (u16 M + serialized graph)
9847 // For NSW the graph topology travels on disk so startup
9848 // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
9849 write_u16(
9850 &mut out,
9851 u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
9852 );
9853 for idx in &t.indices {
9854 write_str(&mut out, &idx.name);
9855 write_u16(
9856 &mut out,
9857 u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
9858 );
9859 match &idx.kind {
9860 IndexKind::BTree(map) => {
9861 out.push(0);
9862 // v9: serialise the full PB map. Each entry's
9863 // RowLocator list travels with the tag-prefixed
9864 // codec from `row_locator::write_le`, so freezer-
9865 // produced Cold locators survive a snapshot
9866 // round-trip. v8 BTree wrote nothing here and
9867 // rebuilt from rows — v9 readers tolerate v8 by
9868 // version dispatch in `Catalog::deserialize`.
9869 write_u32(
9870 &mut out,
9871 u32::try_from(map.len()).expect("≤ 4G index entries/index"),
9872 );
9873 for (key, locators) in map {
9874 write_index_key(&mut out, key);
9875 write_u32(
9876 &mut out,
9877 u32::try_from(locators.len()).expect("≤ 4G locators/key"),
9878 );
9879 for loc in locators {
9880 loc.write_le(&mut out);
9881 }
9882 }
9883 }
9884 // v7.38.1 (L12) — tag byte 7 = BTreeMulti. Payload
9885 // mirrors the tag-0 BTree encoding, with each key
9886 // written as `[u16 arity]` followed by that many
9887 // `write_index_key` components. FILE_VERSION 91+;
9888 // older catalogs never carried a multi index, so no
9889 // migration shim is needed.
9890 IndexKind::BTreeMulti(map) => {
9891 out.push(7);
9892 write_u32(
9893 &mut out,
9894 u32::try_from(map.len()).expect("≤ 4G index entries/index"),
9895 );
9896 for (key, locators) in map {
9897 write_u16(
9898 &mut out,
9899 u16::try_from(key.len()).expect("≤ 65k key components"),
9900 );
9901 for component in key.iter() {
9902 write_index_key(&mut out, component);
9903 }
9904 write_u32(
9905 &mut out,
9906 u32::try_from(locators.len()).expect("≤ 4G locators/key"),
9907 );
9908 for loc in locators {
9909 loc.write_le(&mut out);
9910 }
9911 }
9912 }
9913 IndexKind::Nsw(g) => {
9914 out.push(1);
9915 write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
9916 write_nsw_graph(&mut out, g);
9917 }
9918 IndexKind::Brin { column_type, .. } => {
9919 // v6.7.1 — tag byte 2 = BRIN. Payload is the
9920 // column type code (1 byte mapping to the
9921 // shared DataType numeric encoding); no
9922 // further data — BRIN summaries live in
9923 // cold segments, not the catalog.
9924 out.push(2);
9925 write_data_type(&mut out, *column_type);
9926 }
9927 IndexKind::Gin(map) => {
9928 // v7.12.3 — tag byte 3 = GIN. Payload mirrors
9929 // the BTree encoding but with String (lexeme
9930 // word) keys instead of IndexKey. Tag-prefixed
9931 // RowLocator codec so freezer-produced Cold
9932 // locators survive snapshot round-trip.
9933 // FILE_VERSION 21+; v20 catalogs never wrote a
9934 // GIN index (the AM degraded to BTree fallback
9935 // pre-v7.12.3), so no migration shim is needed.
9936 out.push(3);
9937 write_u32(
9938 &mut out,
9939 u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
9940 );
9941 for (word, locators) in map {
9942 write_str(&mut out, word);
9943 write_u32(
9944 &mut out,
9945 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
9946 );
9947 for loc in locators {
9948 loc.write_le(&mut out);
9949 }
9950 }
9951 }
9952 IndexKind::GinTrgm(map) => {
9953 // v7.15.0 — tag byte 4 = GinTrgm
9954 // (`gin_trgm_ops` GIN over a TEXT column).
9955 // Payload shape is identical to tag-3 GIN —
9956 // `String → Vec<RowLocator>` posting lists.
9957 // The String keys are 3-byte trigrams instead
9958 // of tsvector lexemes; the deserializer
9959 // dispatches on the tag, not the key shape.
9960 // FILE_VERSION 24+; v23 catalogs never wrote
9961 // a trigram-GIN.
9962 out.push(4);
9963 write_u32(
9964 &mut out,
9965 u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
9966 );
9967 for (tri, locators) in map {
9968 write_str(&mut out, tri);
9969 write_u32(
9970 &mut out,
9971 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
9972 );
9973 for loc in locators {
9974 loc.write_le(&mut out);
9975 }
9976 }
9977 }
9978 IndexKind::GinFulltext(map) => {
9979 // v7.17.0 Phase 2.2 — tag byte 5 =
9980 // GinFulltext (MySQL `FULLTEXT KEY` GIN
9981 // over a TEXT/VARCHAR column). Payload
9982 // shape mirrors tag-3 / tag-4 GIN —
9983 // `String → Vec<RowLocator>` posting
9984 // lists keyed by lower-cased word
9985 // lexemes. FILE_VERSION 33+; v32 catalogs
9986 // never wrote a fulltext-GIN (FULLTEXT
9987 // KEY was silently dropped pre-v7.17).
9988 out.push(5);
9989 write_u32(
9990 &mut out,
9991 u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
9992 );
9993 for (lex, locators) in map {
9994 write_str(&mut out, lex);
9995 write_u32(
9996 &mut out,
9997 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
9998 );
9999 for loc in locators {
10000 loc.write_le(&mut out);
10001 }
10002 }
10003 }
10004 IndexKind::GinJsonb(map) => {
10005 // v7.37.8 — tag byte 6 = GinJsonb
10006 // (real posting-list GIN over a JSONB
10007 // column; sentori Epic 5 P2). Payload
10008 // shape mirrors tag-3 / 4 / 5 — keys are
10009 // the canonical `(path, leaf)` tokens
10010 // from `jsonb_gin::extract_tokens`.
10011 // FILE_VERSION 51+; v50 catalogs never
10012 // wrote a JSONB-GIN (the same DDL loaded
10013 // as a BTree fallback).
10014 out.push(6);
10015 write_u32(
10016 &mut out,
10017 u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
10018 );
10019 for (token, locators) in map {
10020 write_str(&mut out, token);
10021 write_u32(
10022 &mut out,
10023 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
10024 );
10025 for loc in locators {
10026 loc.write_le(&mut out);
10027 }
10028 }
10029 }
10030 }
10031 // v6.8.0 — included_columns appendix per index.
10032 // Layout: [u16 num_included][num × u16 column_position].
10033 // v11 readers stop before this u16 (deserialise loop
10034 // gated on version >= 12); v12+ readers always
10035 // consume it. Empty Vec serialises as a bare 0u16.
10036 write_u16(
10037 &mut out,
10038 u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
10039 );
10040 for col_pos in &idx.included_columns {
10041 write_u16(
10042 &mut out,
10043 u16::try_from(*col_pos).expect("≤ 65k columns/table"),
10044 );
10045 }
10046 // v6.8.1 — partial_predicate appendix per index.
10047 // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
10048 // Same v12 gate as included_columns.
10049 match &idx.partial_predicate {
10050 None => out.push(0),
10051 Some(pred) => {
10052 out.push(1);
10053 write_str(&mut out, pred);
10054 }
10055 }
10056 // v6.8.2 — expression appendix. Same shape as
10057 // partial_predicate.
10058 match &idx.expression {
10059 None => out.push(0),
10060 Some(expr) => {
10061 out.push(1);
10062 write_str(&mut out, expr);
10063 }
10064 }
10065 // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
10066 // Single byte 0/1. v15-and-below readers stop before
10067 // this byte; v16 readers always consume it. mailrs K1.
10068 out.push(u8::from(idx.is_unique));
10069 // v7.9.29 — extra_column_positions appendix.
10070 // Layout: [u16 count][count × u16 column_position].
10071 write_u16(
10072 &mut out,
10073 u16::try_from(idx.extra_column_positions.len())
10074 .expect("≤ 65k extra cols / index"),
10075 );
10076 for cp in &idx.extra_column_positions {
10077 write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
10078 }
10079 // v7.39 (read01 round 52) — nulls_not_distinct (FILE_VERSION
10080 // 62+). Appended at the end of the per-index block so the v16
10081 // layout above is untouched; v61-and-below readers stop before
10082 // this byte and default the flag to false (NULLS DISTINCT).
10083 out.push(u8::from(idx.nulls_not_distinct));
10084 // v7.39 (round 537) — the key column's ordering clause
10085 // (FILE_VERSION 83+).
10086 out.push(u8::from(idx.descending));
10087 out.push(match idx.nulls_first {
10088 None => 0,
10089 Some(true) => 1,
10090 Some(false) => 2,
10091 });
10092 // v7.39 (round 538) — the key's explicit collation
10093 // (FILE_VERSION 84+).
10094 match &idx.collation {
10095 Some(c) => {
10096 out.push(1);
10097 write_str(&mut out, c);
10098 }
10099 None => out.push(0),
10100 }
10101 // v7.39.11 — the EXTRA key columns' ordering clauses
10102 // (FILE_VERSION 95+). Appended after the collation so a
10103 // v94 reader stops before it and defaults every extra
10104 // to ascending / nulls last, which is what those
10105 // snapshots recorded.
10106 write_u16(
10107 &mut out,
10108 u16::try_from(idx.extra_orders.len()).expect("\u{2264} 65k extra cols / index"),
10109 );
10110 for o in &idx.extra_orders {
10111 out.push(u8::from(o.descending));
10112 out.push(match o.nulls_first {
10113 None => 0,
10114 Some(true) => 1,
10115 Some(false) => 2,
10116 });
10117 }
10118 // v7.39.13 — whether SPG built this index for a
10119 // constraint's non-leading columns (FILE_VERSION 96+).
10120 // A v95 reader stops before this byte and reads every
10121 // index as user-created, which is what those snapshots
10122 // recorded and what the catalog said about them.
10123 out.push(u8::from(idx.constraint_internal));
10124 out.push(u8::from(idx.constraint_backing));
10125 }
10126 // v6.7.2 — per-table hot_tier_bytes Option<u64>.
10127 // Layout: [u8 has_value][u64 LE value (if has_value)].
10128 // v10 readers stop before this byte (deserialise loop
10129 // gated on version >= 11); v11+ readers always
10130 // consume it.
10131 match t.schema.hot_tier_bytes {
10132 None => out.push(0),
10133 Some(n) => {
10134 out.push(1);
10135 out.extend_from_slice(&n.to_le_bytes());
10136 }
10137 }
10138 // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
10139 // Layout: [u16 LE fk_count]
10140 // per fk:
10141 // [u8 has_name] [str name (if has_name)]
10142 // [u16 LE local_arity] [u16 LE local_pos]*arity
10143 // [str parent_table]
10144 // [u16 LE parent_arity] [u16 LE parent_pos]*arity
10145 // [u8 on_delete_tag] [u8 on_update_tag]
10146 // Older catalogs (v12 and below) skip this block entirely;
10147 // their reader stops before this byte.
10148 write_u16(
10149 &mut out,
10150 u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
10151 );
10152 for fk in &t.schema.foreign_keys {
10153 match &fk.name {
10154 None => out.push(0),
10155 Some(n) => {
10156 out.push(1);
10157 write_str(&mut out, n);
10158 }
10159 }
10160 write_u16(
10161 &mut out,
10162 u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
10163 );
10164 for &p in &fk.local_columns {
10165 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10166 }
10167 write_str(&mut out, &fk.parent_table);
10168 write_u16(
10169 &mut out,
10170 u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
10171 );
10172 for &p in &fk.parent_columns {
10173 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10174 }
10175 out.push(fk.on_delete.tag());
10176 out.push(fk.on_update.tag());
10177 // v7.38 (read01, T29) — MATCH type tag (FILE_VERSION 55+).
10178 out.push(fk.match_type.tag());
10179 // v7.39 (round 288) — constraint timing (FILE_VERSION 79+).
10180 // One byte, bit 0 = DEFERRABLE, bit 1 = INITIALLY DEFERRED.
10181 out.push(u8::from(fk.deferrable) | (u8::from(fk.initially_deferred) << 1));
10182 }
10183 // v7.9.19 — UniquenessConstraint appendix (catalog
10184 // FILE_VERSION 15+). Layout per table after the FK
10185 // block:
10186 // [u16 count]
10187 // per constraint:
10188 // [u8 is_primary_key]
10189 // [u16 arity][u16 col_pos]*arity
10190 // Older catalogs (v14 and below) skip this block.
10191 write_u16(
10192 &mut out,
10193 u16::try_from(t.schema.uniqueness_constraints.len())
10194 .expect("≤ 65k uniqueness constraints/table"),
10195 );
10196 for uc in &t.schema.uniqueness_constraints {
10197 out.push(u8::from(uc.is_primary_key));
10198 write_u16(
10199 &mut out,
10200 u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
10201 );
10202 for &p in &uc.columns {
10203 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10204 }
10205 // v7.13.0 — `nulls_not_distinct` flag
10206 // (FILE_VERSION 23+). Always written by writers at
10207 // version 23+; deserialise gates on `version >= 23`
10208 // so v22-and-below catalogs round-trip cleanly.
10209 out.push(u8::from(uc.nulls_not_distinct));
10210 }
10211 // v7.9.21 — runtime_default appendix per table.
10212 // Layout: [u16 count] then for each:
10213 // [u16 col_pos][str expr]
10214 // Only columns whose runtime_default is Some land here;
10215 // catalog stays compact for the common literal-default
10216 // case.
10217 let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
10218 for (i, c) in t.schema.columns.iter().enumerate() {
10219 if let Some(e) = &c.runtime_default {
10220 rt_defaults.push((i, e.as_str()));
10221 }
10222 }
10223 write_u16(
10224 &mut out,
10225 u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
10226 );
10227 for (pos, expr) in rt_defaults {
10228 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10229 write_str(&mut out, expr);
10230 }
10231 // v7.13.0 — CHECK constraint appendix per table.
10232 // Layout: [u16 count] then `count` Display-form
10233 // expression strings. Re-parsed on every INSERT/UPDATE
10234 // by the engine. FILE_VERSION 23+ only; v22 readers
10235 // never reach this block because the writer also moves
10236 // to v23 in lock-step.
10237 write_u16(
10238 &mut out,
10239 u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
10240 );
10241 for c in &t.schema.checks {
10242 // v7.39 (read01 round 48) — the expr stays in this v23
10243 // appendix (byte layout unchanged for old readers); the
10244 // name rides the v60 constraint-name appendix at the tail.
10245 write_str(&mut out, c.expr.as_str());
10246 }
10247 // v7.17.0 Phase 1.4 — per-table user_enum_type
10248 // appendix. Layout: [u16 count] then
10249 // [u16 col_pos][str enum_name] per binding. Only
10250 // columns whose user_enum_type is Some land here.
10251 let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
10252 for (i, c) in t.schema.columns.iter().enumerate() {
10253 if let Some(e) = &c.user_enum_type {
10254 enum_bindings.push((i, e.as_str()));
10255 }
10256 }
10257 write_u16(
10258 &mut out,
10259 u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
10260 );
10261 for (pos, ename) in enum_bindings {
10262 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10263 write_str(&mut out, ename);
10264 }
10265 // v7.17.0 Phase 1.5 — per-table user_domain_type
10266 // appendix. Same layout as the enum one. v29-and-
10267 // below readers stop after the enum appendix.
10268 let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
10269 for (i, c) in t.schema.columns.iter().enumerate() {
10270 if let Some(d) = &c.user_domain_type {
10271 domain_bindings.push((i, d.as_str()));
10272 }
10273 }
10274 write_u16(
10275 &mut out,
10276 u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
10277 );
10278 for (pos, dname) in domain_bindings {
10279 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10280 write_str(&mut out, dname);
10281 }
10282 // v7.17.0 Phase 2.1 — per-table on_update_runtime
10283 // appendix. Sparse: only ON UPDATE-bound columns.
10284 let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
10285 for (i, c) in t.schema.columns.iter().enumerate() {
10286 if let Some(e) = &c.on_update_runtime {
10287 on_update_bindings.push((i, e.as_str()));
10288 }
10289 }
10290 write_u16(
10291 &mut out,
10292 u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
10293 );
10294 for (pos, expr_src) in on_update_bindings {
10295 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10296 write_str(&mut out, expr_src);
10297 }
10298 // v7.17.0 Phase 2.5 — per-table collation appendix.
10299 // Sparse: only non-Binary columns land. Layout:
10300 // `[u16 count][u16 col_pos][u8 tag] × count`.
10301 let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
10302 for (i, c) in t.schema.columns.iter().enumerate() {
10303 let tag = match c.collation {
10304 Collation::Binary => continue,
10305 Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
10306 };
10307 coll_bindings.push((i, tag));
10308 }
10309 write_u16(
10310 &mut out,
10311 u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
10312 );
10313 for (pos, tag) in coll_bindings {
10314 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10315 out.push(tag);
10316 }
10317 // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
10318 // Sparse: only UNSIGNED columns land. Layout:
10319 // `[u16 count][u16 col_pos] × count`.
10320 let mut unsigned_bindings: Vec<usize> = Vec::new();
10321 for (i, c) in t.schema.columns.iter().enumerate() {
10322 if c.is_unsigned {
10323 unsigned_bindings.push(i);
10324 }
10325 }
10326 write_u16(
10327 &mut out,
10328 u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
10329 );
10330 for pos in unsigned_bindings {
10331 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10332 }
10333 // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
10334 // appendix. Sparse: only ENUM columns land. Layout:
10335 // `[u16 count] then per binding [u16 col_pos]
10336 // [u16 variant_count] then variant strings`.
10337 // FILE_VERSION 41+; v40 readers never reach this block.
10338 let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
10339 for (i, c) in t.schema.columns.iter().enumerate() {
10340 if let Some(vs) = &c.inline_enum_variants {
10341 enum_inline_bindings.push((i, vs.as_slice()));
10342 }
10343 }
10344 write_u16(
10345 &mut out,
10346 u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
10347 );
10348 for (pos, variants) in enum_inline_bindings {
10349 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10350 write_u16(
10351 &mut out,
10352 u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
10353 );
10354 for v in variants {
10355 write_str(&mut out, v.as_str());
10356 }
10357 }
10358 // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
10359 // appendix. Same layout as the inline ENUM block.
10360 // FILE_VERSION 42+; v41 readers never reach this block.
10361 let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
10362 for (i, c) in t.schema.columns.iter().enumerate() {
10363 if let Some(vs) = &c.inline_set_variants {
10364 set_inline_bindings.push((i, vs.as_slice()));
10365 }
10366 }
10367 write_u16(
10368 &mut out,
10369 u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
10370 );
10371 for (pos, variants) in set_inline_bindings {
10372 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10373 write_u16(
10374 &mut out,
10375 u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
10376 );
10377 for v in variants {
10378 write_str(&mut out, v.as_str());
10379 }
10380 }
10381 // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
10382 // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
10383 write_partition_role(&mut out, t.schema.partition_role.as_ref());
10384 // v7.37.7 — per-table generated_stored_expr appendix
10385 // (FILE_VERSION 50+). Sparse: only columns whose
10386 // generated_stored_expr is Some land here.
10387 let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
10388 for (i, c) in t.schema.columns.iter().enumerate() {
10389 if let Some(src) = &c.generated_stored_expr {
10390 gen_bindings.push((i, src.as_str()));
10391 }
10392 }
10393 write_u16(
10394 &mut out,
10395 u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
10396 );
10397 for (pos, src) in gen_bindings {
10398 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10399 write_str(&mut out, src);
10400 }
10401 // v7.38 (read01) — per-table default_text appendix
10402 // (FILE_VERSION 58+). Sparse: only columns whose default_text
10403 // is Some land here. Mirrors the generated_stored_expr shape.
10404 let mut default_texts: Vec<(usize, &str)> = Vec::new();
10405 for (i, c) in t.schema.columns.iter().enumerate() {
10406 if let Some(src) = &c.default_text {
10407 default_texts.push((i, src.as_str()));
10408 }
10409 }
10410 write_u16(
10411 &mut out,
10412 u16::try_from(default_texts.len()).expect("≤ 65k defaulted columns/table"),
10413 );
10414 for (pos, src) in default_texts {
10415 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10416 write_str(&mut out, src);
10417 }
10418 // v7.39 (RLS) — per-table policy appendix + the two RLS flags
10419 // (FILE_VERSION 59+). Written after the default_text block and
10420 // before the MVCC row appendix, so a v58 reader stops before it.
10421 // Layout: [u8 row_security][u8 force] [u16 policy_count] then per
10422 // policy: [str name][u8 cmd][u8 permissive][u16 role_count]
10423 // (role_count × str) [u8 has_using](+str)[u8 has_check](+str).
10424 out.push(u8::from(t.schema.row_security));
10425 out.push(u8::from(t.schema.force_row_security));
10426 write_u16(
10427 &mut out,
10428 u16::try_from(t.schema.policies.len()).expect("≤ 65k policies/table"),
10429 );
10430 for p in &t.schema.policies {
10431 write_str(&mut out, &p.name);
10432 out.push(p.cmd.to_wire_byte());
10433 out.push(u8::from(p.permissive));
10434 write_u16(
10435 &mut out,
10436 u16::try_from(p.roles.len()).expect("≤ 65k roles/policy"),
10437 );
10438 for r in &p.roles {
10439 write_str(&mut out, r);
10440 }
10441 match &p.using_expr {
10442 Some(s) => {
10443 out.push(1);
10444 write_str(&mut out, s);
10445 }
10446 None => out.push(0),
10447 }
10448 match &p.with_check_expr {
10449 Some(s) => {
10450 out.push(1);
10451 write_str(&mut out, s);
10452 }
10453 None => out.push(0),
10454 }
10455 }
10456 // v7.37.16 (Epic W) — per-row MVCC header + stable RowId
10457 // appendix (FILE_VERSION 53+). Persists xmin/xmax/flags +
10458 // RowId for every row so a tombstone naming a pre-checkpoint
10459 // row survives a serialize→deserialize base restore
10460 // (cross-checkpoint tombstone durability). `headers` /
10461 // `rowids` are lock-step parallel to `rows` (invariant held
10462 // at every mutation boundary), so the count is `rows.len()`
10463 // and the zipped walk visits them in physical row order —
10464 // the same order the rows block above was written in. v52
10465 // readers never reach this block (the writer also moves to
10466 // v53 in lock-step); a v53 reader restores headers + ids
10467 // verbatim instead of freezing + dense-assigning.
10468 debug_assert_eq!(
10469 t.rows.len(),
10470 t.headers.len(),
10471 "headers must be lock-step with rows at serialize"
10472 );
10473 debug_assert_eq!(
10474 t.rows.len(),
10475 t.rowids.len(),
10476 "rowids must be lock-step with rows at serialize"
10477 );
10478 write_u32(
10479 &mut out,
10480 u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
10481 );
10482 for (h, rid) in t.headers.iter().zip(t.rowids.iter()) {
10483 out.extend_from_slice(&h.xmin.to_le_bytes());
10484 out.extend_from_slice(&h.xmax.to_le_bytes());
10485 out.push(h.flags);
10486 out.extend_from_slice(&rid.0.to_le_bytes());
10487 }
10488 out.extend_from_slice(
10489 &t.next_rowid
10490 .load(core::sync::atomic::Ordering::Relaxed)
10491 .to_le_bytes(),
10492 );
10493 // v7.39 (read01 round 48) — constraint-name appendix
10494 // (FILE_VERSION 60+). Index-aligned to the CHECK and
10495 // uniqueness-constraint appendices written above, so the
10496 // existing byte layouts stay untouched and a v59 catalog still
10497 // decodes (its constraints just come back unnamed).
10498 // Layout: [u16 check_count] then per check
10499 // [u8 has_name] ([str name] when has_name)
10500 // [u16 uc_count] then per uc the same pair.
10501 write_u16(
10502 &mut out,
10503 u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
10504 );
10505 for c in &t.schema.checks {
10506 match &c.name {
10507 Some(n) => {
10508 out.push(1);
10509 write_str(&mut out, n);
10510 }
10511 None => out.push(0),
10512 }
10513 }
10514 write_u16(
10515 &mut out,
10516 u16::try_from(t.schema.uniqueness_constraints.len())
10517 .expect("≤ 65k uniqueness constraints/table"),
10518 );
10519 for uc in &t.schema.uniqueness_constraints {
10520 match &uc.name {
10521 Some(n) => {
10522 out.push(1);
10523 write_str(&mut out, n);
10524 }
10525 None => out.push(0),
10526 }
10527 }
10528 // v7.39 (read01 round 56) — user_composite_type appendix
10529 // (FILE_VERSION 63+). Sparse, at the very end of the per-table
10530 // block: only composite-typed columns land here, so a v62 reader
10531 // stops before it and its composite columns stay plain JSON.
10532 let mut comp_bindings: Vec<(usize, &str)> = Vec::new();
10533 for (i, c) in t.schema.columns.iter().enumerate() {
10534 if let Some(n) = &c.user_composite_type {
10535 comp_bindings.push((i, n.as_str()));
10536 }
10537 }
10538 write_u16(
10539 &mut out,
10540 u16::try_from(comp_bindings.len()).expect("≤ 65k composite-typed columns/table"),
10541 );
10542 for (pos, n) in comp_bindings {
10543 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10544 write_str(&mut out, n);
10545 }
10546 // v7.39 (read01 round 57) — owner + ACL appendix (FILE_VERSION
10547 // 64+), at the very end of the per-table block so a v63 reader
10548 // stops before it (its tables then read back owner-less, i.e.
10549 // owned by the login role, with no grants — which is exactly what
10550 // they were).
10551 match &t.schema.owner {
10552 Some(o) => {
10553 out.push(1);
10554 write_str(&mut out, o);
10555 }
10556 None => out.push(0),
10557 }
10558 write_u16(
10559 &mut out,
10560 u16::try_from(t.schema.acl.len()).expect("≤ 65k aclitems/table"),
10561 );
10562 for a in &t.schema.acl {
10563 write_str(&mut out, &a.grantee);
10564 write_u16(&mut out, a.privs);
10565 write_u16(&mut out, a.grantable);
10566 write_str(&mut out, &a.grantor);
10567 }
10568 // v7.39 (read01 round 59) — COLUMN acl appendix (FILE_VERSION 65+),
10569 // sparse: only columns that carry a grant land here, so a v64 reader
10570 // stops before it and its columns read back un-granted, which is
10571 // what they were.
10572 let granted: Vec<(usize, &ColumnSchema)> = t
10573 .schema
10574 .columns
10575 .iter()
10576 .enumerate()
10577 .filter(|(_, c)| !c.acl.is_empty())
10578 .collect();
10579 write_u16(
10580 &mut out,
10581 u16::try_from(granted.len()).expect("≤ 65k granted columns/table"),
10582 );
10583 for (pos, c) in granted {
10584 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10585 write_u16(
10586 &mut out,
10587 u16::try_from(c.acl.len()).expect("≤ 65k aclitems/column"),
10588 );
10589 for a in &c.acl {
10590 write_str(&mut out, &a.grantee);
10591 write_u16(&mut out, a.privs);
10592 write_u16(&mut out, a.grantable);
10593 write_str(&mut out, &a.grantor);
10594 }
10595 }
10596 // v7.39 (round 210) — EXCLUDE-constraint appendix (FILE_VERSION
10597 // 72+), at the very end of the per-table block so a v71 reader
10598 // stops before it and its tables read back with no exclusion
10599 // constraints. Layout: [u16 excl_count] then per constraint
10600 // [str name] [u8 has_method](+str) [u16 elem_count] then per
10601 // element [u16 col_pos][str op].
10602 write_u16(
10603 &mut out,
10604 u16::try_from(t.schema.exclusion_constraints.len())
10605 .expect("≤ 65k exclusion constraints/table"),
10606 );
10607 for ex in &t.schema.exclusion_constraints {
10608 write_str(&mut out, &ex.name);
10609 match &ex.method {
10610 Some(m) => {
10611 out.push(1);
10612 write_str(&mut out, m);
10613 }
10614 None => out.push(0),
10615 }
10616 write_u16(
10617 &mut out,
10618 u16::try_from(ex.elements.len()).expect("≤ 65k elements/exclusion"),
10619 );
10620 for (pos, op) in &ex.elements {
10621 write_u16(&mut out, u16::try_from(*pos).expect("≤ 65k columns/table"));
10622 write_str(&mut out, op);
10623 }
10624 }
10625 // v7.39 (round 220) — identity-RESTART appendix (FILE_VERSION
10626 // 73+), sparse: only columns carrying a RESTART floor land here.
10627 let restarts: Vec<(usize, i64)> = t
10628 .schema
10629 .columns
10630 .iter()
10631 .enumerate()
10632 .filter_map(|(i, c)| c.auto_restart.map(|n| (i, n)))
10633 .collect();
10634 write_u16(
10635 &mut out,
10636 u16::try_from(restarts.len()).expect("≤ 65k restart columns/table"),
10637 );
10638 for (pos, n) in restarts {
10639 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10640 out.extend_from_slice(&n.to_le_bytes());
10641 }
10642 // v7.39 (round 386, type-fidelity epic P1) — per-table
10643 // mysql_int_width appendix (FILE_VERSION 81+). Sparse: only
10644 // TINYINT / MEDIUMINT columns land. Layout:
10645 // `[u16 count]([u16 col_pos][u8 width_tag]) × count`
10646 // (tag 0 = Tiny, 1 = Medium). v80-and-below readers stop after
10647 // the identity-RESTART appendix, leaving every column at None.
10648 let int_widths: Vec<(usize, u8)> = t
10649 .schema
10650 .columns
10651 .iter()
10652 .enumerate()
10653 .filter_map(|(i, c)| {
10654 c.mysql_int_width.map(|w| {
10655 let tag = match w {
10656 MysqlIntWidth::Tiny => 0u8,
10657 MysqlIntWidth::Medium => 1u8,
10658 MysqlIntWidth::Small => 2u8,
10659 MysqlIntWidth::Int => 3u8,
10660 MysqlIntWidth::Big => 4u8,
10661 };
10662 (i, tag)
10663 })
10664 })
10665 .collect();
10666 write_u16(
10667 &mut out,
10668 u16::try_from(int_widths.len()).expect("≤ 65k narrow-int columns/table"),
10669 );
10670 for (pos, tag) in int_widths {
10671 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10672 out.push(tag);
10673 }
10674 // v7.39 (round 424, type-fidelity epic) — per-table mysql_fsp
10675 // appendix (FILE_VERSION 82+). Sparse: only MySQL-declared
10676 // temporal columns land. Layout:
10677 // `[u16 count]([u16 col_pos][u8 fsp]) × count`, fsp in 0..=6.
10678 // v81-and-below readers stop after the int-width appendix,
10679 // leaving every column at None (PG microsecond behaviour).
10680 let fsps: Vec<(usize, u8)> = t
10681 .schema
10682 .columns
10683 .iter()
10684 .enumerate()
10685 .filter_map(|(i, c)| c.mysql_fsp.map(|p| (i, p)))
10686 .collect();
10687 write_u16(
10688 &mut out,
10689 u16::try_from(fsps.len()).expect("≤ 65k temporal columns/table"),
10690 );
10691 for (pos, fsp) in fsps {
10692 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10693 out.push(fsp);
10694 }
10695 // v7.39.2 — the declared-TIMESTAMP appendix (FILE_VERSION
10696 // 93+). Sparse: only the columns written as `TIMESTAMP` in a
10697 // MySQL session. Layout: `[u16 count]([u16 col_pos]) × count`.
10698 // v92-and-below readers stop after the CHECK appendix below,
10699 // leaving every column at `false` — which is what they meant.
10700 let declared_ts: Vec<usize> = t
10701 .schema
10702 .columns
10703 .iter()
10704 .enumerate()
10705 .filter_map(|(i, c)| c.mysql_declared_timestamp.then_some(i))
10706 .collect();
10707 write_u16(
10708 &mut out,
10709 u16::try_from(declared_ts.len()).expect("≤ 65k timestamp columns/table"),
10710 );
10711 for pos in declared_ts {
10712 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10713 }
10714 // v7.39.3 — the FLOAT/DOUBLE (m,d) appendix (FILE_VERSION
10715 // 94+). Sparse: only columns declared with the pair.
10716 // Layout: `[u16 count]([u16 col_pos][u8 m][u8 d]) × count`.
10717 let float_mds: Vec<(usize, u8, u8)> = t
10718 .schema
10719 .columns
10720 .iter()
10721 .enumerate()
10722 .filter_map(|(i, c)| c.mysql_float_md.map(|(m, d)| (i, m, d)))
10723 .collect();
10724 write_u16(
10725 &mut out,
10726 u16::try_from(float_mds.len()).expect("≤ 65k (m,d) columns/table"),
10727 );
10728 for (pos, m, d) in float_mds {
10729 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10730 out.push(m);
10731 out.push(d);
10732 }
10733 // v7.39 (round 652) — CHECK-validated appendix (FILE_VERSION
10734 // 87+). Sparse the other way round from the ones above: the
10735 // common case is every constraint validated, so only the
10736 // NOT VALID ones are written, by their index into the CHECK
10737 // appendix. Layout: `[u16 count]([u16 check_idx]) × count`.
10738 let unvalidated: Vec<usize> = t
10739 .schema
10740 .checks
10741 .iter()
10742 .enumerate()
10743 .filter_map(|(i, c)| (!c.validated).then_some(i))
10744 .collect();
10745 write_u16(
10746 &mut out,
10747 u16::try_from(unvalidated.len()).expect("≤ 65k CHECK constraints/table"),
10748 );
10749 for idx in unvalidated {
10750 write_u16(&mut out, u16::try_from(idx).expect("≤ 65k CHECK/table"));
10751 }
10752 // v7.39 (round 677) — per-column collation names (FILE_VERSION
10753 // 88+). Sparse: only the columns that were written with an
10754 // explicit `COLLATE` appear, so a table that declares none pays
10755 // two bytes. Layout: `[u16 count]([u16 col_idx][str]) × count`.
10756 //
10757 // Without this the declaration survives CREATE TABLE and dies
10758 // at the next restart — measured: a column declared
10759 // `COLLATE "C"` reported attcollation 950 in the session that
10760 // created it and 100 after a reload.
10761 let collated: Vec<(usize, &str)> = t
10762 .schema
10763 .columns
10764 .iter()
10765 .enumerate()
10766 .filter_map(|(i, c)| c.collation_name.as_deref().map(|n| (i, n)))
10767 .collect();
10768 write_u16(
10769 &mut out,
10770 u16::try_from(collated.len()).expect("≤ 65k columns/table"),
10771 );
10772 for (idx, name) in collated {
10773 write_u16(&mut out, u16::try_from(idx).expect("≤ 65k columns/table"));
10774 write_str(&mut out, name);
10775 }
10776 // v7.39 (round 711) — PK/UNIQUE constraint timing (FILE_VERSION
10777 // 89+). Dense, one byte per uniqueness constraint in
10778 // declaration order, the same bit layout the FK block has
10779 // carried since round 288: bit 0 = DEFERRABLE, bit 1 =
10780 // INITIALLY DEFERRED. A v88 reader stops before it.
10781 write_u16(
10782 &mut out,
10783 u16::try_from(t.schema.uniqueness_constraints.len())
10784 .expect("≤ 65k uniqueness constraints/table"),
10785 );
10786 for uc in &t.schema.uniqueness_constraints {
10787 out.push(u8::from(uc.deferrable) | (u8::from(uc.initially_deferred) << 1));
10788 }
10789 }
10790 // v7.12.4 — catalog-wide appendix: user-defined functions
10791 // then triggers. FILE_VERSION 22+ only. v21 and earlier
10792 // readers stop after the last table; v22 readers always
10793 // consume two `u32` counts (possibly zero).
10794 //
10795 // Function entry layout:
10796 // [str name] [str args_repr] [str returns]
10797 // [str language] [str body]
10798 // Trigger entry layout:
10799 // [str name] [str table] [str timing]
10800 // [u16 event_count] (event_count × str)
10801 // [str for_each] [str function]
10802 write_u32(
10803 &mut out,
10804 u32::try_from(self.functions.len()).expect("≤ 4G functions"),
10805 );
10806 for fd in self.functions.values() {
10807 write_str(&mut out, &fd.name);
10808 write_str(&mut out, &fd.args_repr);
10809 write_str(&mut out, &fd.returns);
10810 write_str(&mut out, &fd.language);
10811 write_str_long(&mut out, &fd.body);
10812 }
10813 write_u32(
10814 &mut out,
10815 u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
10816 );
10817 for td in &self.triggers {
10818 write_str(&mut out, &td.name);
10819 write_str(&mut out, &td.table);
10820 write_str(&mut out, &td.timing);
10821 write_u16(
10822 &mut out,
10823 u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
10824 );
10825 for ev in &td.events {
10826 write_str(&mut out, ev);
10827 }
10828 write_str(&mut out, &td.for_each);
10829 write_str(&mut out, &td.function);
10830 // v7.13.0 — `UPDATE OF cols` filter
10831 // (FILE_VERSION 23+). v22 readers omit; v23 writers
10832 // always emit (possibly zero).
10833 write_u16(
10834 &mut out,
10835 u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
10836 );
10837 for c in &td.update_columns {
10838 write_str(&mut out, c);
10839 }
10840 // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
10841 out.push(u8::from(td.enabled));
10842 // v7.39 (round 138) — WHEN condition text (FILE_VERSION 70+).
10843 write_str(&mut out, &td.when_condition);
10844 }
10845 // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
10846 write_u32(
10847 &mut out,
10848 u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
10849 );
10850 for seq in self.sequences.values() {
10851 write_str(&mut out, &seq.name);
10852 out.push(match seq.data_type {
10853 SequenceDataType::SmallInt => 0,
10854 SequenceDataType::Int => 1,
10855 SequenceDataType::BigInt => 2,
10856 });
10857 out.extend_from_slice(&seq.start.to_le_bytes());
10858 out.extend_from_slice(&seq.increment.to_le_bytes());
10859 out.extend_from_slice(&seq.min_value.to_le_bytes());
10860 out.extend_from_slice(&seq.max_value.to_le_bytes());
10861 out.extend_from_slice(&seq.cache.to_le_bytes());
10862 out.push(u8::from(seq.cycle));
10863 match &seq.owned_by {
10864 None => out.push(0),
10865 Some((table, column)) => {
10866 out.push(1);
10867 write_str(&mut out, table);
10868 write_str(&mut out, column);
10869 }
10870 }
10871 out.extend_from_slice(&seq.last_value.to_le_bytes());
10872 out.push(u8::from(seq.is_called));
10873 }
10874 // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
10875 write_u32(
10876 &mut out,
10877 u32::try_from(self.views.len()).expect("≤ 4G views"),
10878 );
10879 for view in self.views.values() {
10880 write_str(&mut out, &view.name);
10881 write_u16(
10882 &mut out,
10883 u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
10884 );
10885 for c in &view.columns {
10886 write_str(&mut out, c);
10887 }
10888 write_str_long(&mut out, &view.body);
10889 // v7.39 (round 132, FILE_VERSION 69+) — WITH CHECK OPTION marker.
10890 out.push(view.check_option);
10891 }
10892 // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
10893 // (FILE_VERSION 28+). The backing rows live as a regular
10894 // table of the same name already in the tables block.
10895 write_u32(
10896 &mut out,
10897 u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
10898 );
10899 for (name, body) in &self.materialized_views {
10900 write_str(&mut out, name);
10901 write_str_long(&mut out, body);
10902 }
10903 // v7.17.0 Phase 1.4 — ENUM types catalog block
10904 // (FILE_VERSION 29+).
10905 write_u32(
10906 &mut out,
10907 u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
10908 );
10909 for e in self.enum_types.values() {
10910 write_str(&mut out, &e.name);
10911 write_u16(
10912 &mut out,
10913 u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
10914 );
10915 for l in &e.labels {
10916 write_str(&mut out, l);
10917 }
10918 }
10919 // v7.17.0 Phase 1.5 — DOMAIN types catalog block
10920 // (FILE_VERSION 30+).
10921 write_u32(
10922 &mut out,
10923 u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
10924 );
10925 for d in self.domain_types.values() {
10926 write_str(&mut out, &d.name);
10927 write_data_type(&mut out, d.base_type);
10928 out.push(u8::from(d.nullable));
10929 match &d.default {
10930 None => out.push(0),
10931 Some(s) => {
10932 out.push(1);
10933 write_str(&mut out, s);
10934 }
10935 }
10936 write_u16(
10937 &mut out,
10938 u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
10939 );
10940 for c in &d.checks {
10941 write_str(&mut out, &c.expr);
10942 // v7.39 (round 260) — the constraint name (FILE_VERSION 75+).
10943 write_str(&mut out, &c.name);
10944 }
10945 // v7.39 (round 259) — the parent domain (FILE_VERSION 74+).
10946 match &d.base_domain {
10947 None => out.push(0),
10948 Some(s) => {
10949 out.push(1);
10950 write_str(&mut out, s);
10951 }
10952 }
10953 }
10954 // v7.17.0 Phase 1.6 — user-schemas registry
10955 // (FILE_VERSION 31+). Built-ins are hardcoded in
10956 // `is_builtin_schema` and not persisted.
10957 write_u32(
10958 &mut out,
10959 u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
10960 );
10961 for name in &self.schemas {
10962 write_str(&mut out, name);
10963 }
10964 // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
10965 // (FILE_VERSION 52+). Each entry: name, u16 field_count,
10966 // then field_count `[str field_name][data_type]` pairs.
10967 write_u32(
10968 &mut out,
10969 u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
10970 );
10971 for c in self.composite_types.values() {
10972 write_str(&mut out, &c.name);
10973 write_u16(
10974 &mut out,
10975 u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
10976 );
10977 for (i, (fname, fty)) in c.fields.iter().enumerate() {
10978 write_str(&mut out, fname);
10979 write_data_type(&mut out, *fty);
10980 // v7.39 (round 264) — the field's user type (v76+).
10981 match c.field_user_types.get(i).and_then(Option::as_ref) {
10982 None => out.push(0),
10983 Some(n) => {
10984 out.push(1);
10985 write_str(&mut out, n);
10986 }
10987 }
10988 }
10989 }
10990 // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
10991 // Catalog-wide, written last (before the CRC trailer) so every older
10992 // reader stops before it. Layout: [u32 count] then [str key][str text].
10993 write_u32(
10994 &mut out,
10995 u32::try_from(self.comments.len()).expect("≤ 4G comments"),
10996 );
10997 for (k, v) in &self.comments {
10998 write_str(&mut out, k);
10999 write_str_long(&mut out, v);
11000 }
11001 // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+), catalog-
11002 // wide and written last so a v65 reader stops before them. The sequence
11003 // block itself sits mid-image and cannot grow without breaking older
11004 // readers, so a sequence's owner + ACL rides here, keyed by name.
11005 let acl_out = |out: &mut Vec<u8>, acl: &[AclItem]| {
11006 write_u16(out, u16::try_from(acl.len()).expect("≤ 65k aclitems"));
11007 for a in acl {
11008 write_str(out, &a.grantee);
11009 write_u16(out, a.privs);
11010 write_u16(out, a.grantable);
11011 write_str(out, &a.grantor);
11012 }
11013 };
11014 let owned: Vec<&SequenceDef> = self
11015 .sequences
11016 .values()
11017 .filter(|s| s.owner.is_some() || !s.acl.is_empty())
11018 .collect();
11019 write_u32(
11020 &mut out,
11021 u32::try_from(owned.len()).expect("≤ 4G sequences"),
11022 );
11023 for seq in owned {
11024 write_str(&mut out, &seq.name);
11025 match &seq.owner {
11026 Some(o) => {
11027 out.push(1);
11028 write_str(&mut out, o);
11029 }
11030 None => out.push(0),
11031 }
11032 acl_out(&mut out, &seq.acl);
11033 }
11034 acl_out(&mut out, &self.schema_acl);
11035 acl_out(&mut out, &self.database_acl);
11036 // v7.39 (read01 round 61) — FUNCTION owner + ACL (FILE_VERSION 67+).
11037 // The function block sits mid-image like the sequence one, so this
11038 // rides the catalog-wide tail too, keyed by name.
11039 let fns: Vec<&FunctionDef> = self
11040 .functions
11041 .values()
11042 .filter(|f| f.owner.is_some() || !f.acl.is_empty())
11043 .collect();
11044 write_u32(&mut out, u32::try_from(fns.len()).expect("≤ 4G functions"));
11045 for f in fns {
11046 // v7.39 (read01 round 62) — keyed by SIGNATURE now: two overloads
11047 // have two ACLs.
11048 write_str(&mut out, &function_signature_key(&f.name, &f.args_repr));
11049 match &f.owner {
11050 Some(o) => {
11051 out.push(1);
11052 write_str(&mut out, o);
11053 }
11054 None => out.push(0),
11055 }
11056 acl_out(&mut out, &f.acl);
11057 }
11058 // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), catalog-
11059 // wide and written last (right before the CRC trailer) so every older
11060 // reader stops cleanly before it. Layout: [u32 count] then per rule
11061 // [str name][str table][str event][u8 instead][str when]
11062 // [u16 cmd_count]([str cmd] × cmd_count).
11063 write_u32(
11064 &mut out,
11065 u32::try_from(self.rules.len()).expect("≤ 4G rules"),
11066 );
11067 for r in &self.rules {
11068 write_str(&mut out, &r.name);
11069 write_str(&mut out, &r.table);
11070 write_str(&mut out, &r.event);
11071 out.push(u8::from(r.instead));
11072 write_str(&mut out, &r.when_condition);
11073 write_u16(
11074 &mut out,
11075 u16::try_from(r.commands.len()).expect("≤ 65k commands / rule"),
11076 );
11077 for c in &r.commands {
11078 write_str(&mut out, c);
11079 }
11080 }
11081 // v7.39 (round 280) — extended-statistics block (FILE_VERSION
11082 // 77+), appended after the RULE block for the same reason: an
11083 // older reader stops cleanly before it. Layout: [u32 count]
11084 // then per object [str name][str table][u16 n]([str kind] × n)
11085 // [u16 m]([str column] × m).
11086 write_u32(
11087 &mut out,
11088 u32::try_from(self.statistics_ext.len()).expect("≤ 4G statistics objects"),
11089 );
11090 for st in &self.statistics_ext {
11091 write_str(&mut out, &st.name);
11092 write_str(&mut out, &st.table);
11093 write_u16(
11094 &mut out,
11095 u16::try_from(st.kinds.len()).expect("≤ 65k kinds"),
11096 );
11097 for k in &st.kinds {
11098 write_str(&mut out, k);
11099 }
11100 write_u16(
11101 &mut out,
11102 u16::try_from(st.columns.len()).expect("≤ 65k columns"),
11103 );
11104 for c in &st.columns {
11105 write_str(&mut out, c);
11106 }
11107 }
11108 // v7.39 (round 287) — large-object block (FILE_VERSION 78+),
11109 // appended after the statistics block for the same reason: an
11110 // older reader stops cleanly before it. Layout: [u32 count]
11111 // then per object [u32 oid][u32 len][len bytes].
11112 write_u32(
11113 &mut out,
11114 u32::try_from(self.large_objects.len()).expect("≤ 4G large objects"),
11115 );
11116 for (oid, bytes) in &self.large_objects {
11117 write_u32(&mut out, *oid);
11118 write_u32(
11119 &mut out,
11120 u32::try_from(bytes.len()).expect("≤ 4G per object"),
11121 );
11122 out.extend_from_slice(bytes);
11123 }
11124 // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
11125 // 80+), appended last for the same reason as every block before
11126 // it: an older reader stops cleanly ahead of it and simply sees
11127 // functions with PG's default attributes. Only functions that
11128 // declared something non-default are written. Layout: [u32 count]
11129 // then per function [str signature_key][u8 volatility][u8 flags]
11130 // [u8 parallel][f64 cost or NaN][f64 rows or NaN], where flags bit
11131 // 0 = strict, 1 = security definer, 2 = leakproof.
11132 let attr_fns: Vec<(&String, &FunctionDef)> = self
11133 .functions
11134 .iter()
11135 .filter(|(_, f)| {
11136 f.volatility != FN_VOLATILE
11137 || f.strict
11138 || f.security_definer
11139 || f.leakproof
11140 || f.parallel != FN_PARALLEL_UNSAFE
11141 || f.cost.is_some()
11142 || f.rows.is_some()
11143 })
11144 .collect();
11145 write_u32(
11146 &mut out,
11147 u32::try_from(attr_fns.len()).expect("≤ 4G functions"),
11148 );
11149 for (key, f) in attr_fns {
11150 write_str(&mut out, key);
11151 out.push(f.volatility);
11152 let flags = u8::from(f.strict)
11153 | (u8::from(f.security_definer) << 1)
11154 | (u8::from(f.leakproof) << 2);
11155 out.push(flags);
11156 out.push(f.parallel);
11157 out.extend_from_slice(&f.cost.unwrap_or(f64::NAN).to_le_bytes());
11158 out.extend_from_slice(&f.rows.unwrap_or(f64::NAN).to_le_bytes());
11159 }
11160 // v7.38 (read01 P5.05) — CRC32C trailer over the whole image so a
11161 // corrupted snapshot is rejected on load. FILE_VERSION is >= the
11162 // trailer version, so this always runs for freshly-written images.
11163 // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+),
11164 // catalog-wide and written LAST so a v84 reader stops before it.
11165 // Layout: [u32 scopes] then [str database][str role][u32 params]
11166 // then [str name][str value] per param.
11167 write_u32(
11168 &mut out,
11169 u32::try_from(self.db_role_settings.len()).expect("≤ 4G scopes"),
11170 );
11171 for ((db, role), params) in &self.db_role_settings {
11172 write_str(&mut out, db);
11173 write_str(&mut out, role);
11174 write_u32(&mut out, u32::try_from(params.len()).expect("≤ 4G params"));
11175 for (name, value) in params {
11176 write_str(&mut out, name);
11177 write_str(&mut out, value);
11178 }
11179 }
11180 // v7.39 (round 550) — replication slots (FILE_VERSION 86+),
11181 // written LAST so a v85 reader stops before them.
11182 write_u32(
11183 &mut out,
11184 u32::try_from(self.replication_slots.len()).expect("≤ 4G slots"),
11185 );
11186 for (name, (plugin, slot_type)) in &self.replication_slots {
11187 write_str(&mut out, name);
11188 write_str(&mut out, plugin);
11189 write_str(&mut out, slot_type);
11190 }
11191 // v7.38.18 (S1) — the database collation (FILE_VERSION 92+).
11192 // Absent on an older image, which reads back as `C`.
11193 match &self.db_collation {
11194 None => out.push(0),
11195 Some(c) => {
11196 out.push(1);
11197 write_str(&mut out, c);
11198 }
11199 }
11200 let crc = spg_crypto::crc32c::crc32c(&out);
11201 write_u32(&mut out, crc);
11202 out
11203 }
11204
11205 /// Deserialize a previously-serialized catalog. Rejects bad magic, version
11206 /// mismatch, unknown tags, truncation, and trailing bytes.
11207 pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
11208 let mut cur = Cursor::new(buf);
11209 let magic = cur.take(8)?;
11210 if magic != FILE_MAGIC {
11211 return Err(StorageError::Corrupt(format!(
11212 "bad magic: expected SPGDB001, got {magic:?}"
11213 )));
11214 }
11215 let version = cur.read_u8()?;
11216 if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
11217 return Err(StorageError::Corrupt(format!(
11218 "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
11219 )));
11220 }
11221 // v7.23/v7.27 — escape decoding is version-gated (see
11222 // STR_LEN_ESCAPE / Cursor::codec_version).
11223 cur.codec_version = version;
11224 let table_count = cur.read_u32()? as usize;
11225 let mut cat = Self::new();
11226 for _ in 0..table_count {
11227 deserialize_table(&mut cur, &mut cat, version)?;
11228 }
11229 // v7.37.15 (Phase C.1) — stamp dense stable RelIds on load.
11230 // Pre-V6 envelopes carry no ids; a dense 1..=N assignment is
11231 // sufficient while RelId is process-local bookkeeping (the V6
11232 // envelope, Phase C.6, will round-trip real ids). Sets the
11233 // allocator above the loaded ids so a post-load CREATE TABLE
11234 // never collides.
11235 for (i, t) in cat.tables.iter_mut().enumerate() {
11236 t.set_rel_id(row_header::RelId((i as u64) + 1));
11237 }
11238 // v7.39.13 — a pre-v97 catalog's TIMETZ index entries are keyed
11239 // by the UTC instant alone (see `timetz_sort_key`), and a v97
11240 // probe is keyed by the pair. Reading one with the other finds
11241 // nothing, which is the failure this whole layer exists to
11242 // prevent, so the entries are rebuilt from the rows.
11243 //
11244 // Only timetz, and only from below v97: `rebuild_indices_pub`
11245 // rebuilds every index on the table, so this asks first.
11246 if version < 97 {
11247 for t in cat.tables.iter_mut() {
11248 let cols = &t.schema().columns;
11249 let touched = t.indices().iter().any(|idx| {
11250 core::iter::once(idx.column_position)
11251 .chain(idx.extra_column_positions.iter().copied())
11252 .any(|p| {
11253 cols.get(p)
11254 .is_some_and(|c| matches!(c.ty, DataType::TimeTz))
11255 })
11256 });
11257 if touched {
11258 t.rebuild_indices_pub();
11259 }
11260 }
11261 }
11262 cat.next_rel_id = cat.tables.len() as u64;
11263 // v7.12.4 — catalog-wide function + trigger appendix.
11264 // FILE_VERSION 22+ only; v21 and earlier catalogs stop
11265 // after the last table.
11266 if version >= 22 {
11267 let fn_count = cur.read_u32()? as usize;
11268 for _ in 0..fn_count {
11269 let name = cur.read_str()?;
11270 let args_repr = cur.read_str()?;
11271 let returns = cur.read_str()?;
11272 let language = cur.read_str()?;
11273 let body = cur.read_str_long()?;
11274 let key = function_signature_key(&name, &args_repr);
11275 cat.functions.insert(
11276 key,
11277 FunctionDef {
11278 name,
11279 args_repr,
11280 returns,
11281 language,
11282 body,
11283 owner: None,
11284 acl: Vec::new(),
11285 volatility: FN_VOLATILE,
11286 strict: false,
11287 security_definer: false,
11288 leakproof: false,
11289 parallel: FN_PARALLEL_UNSAFE,
11290 cost: None,
11291 rows: None,
11292 },
11293 );
11294 }
11295 let trg_count = cur.read_u32()? as usize;
11296 for _ in 0..trg_count {
11297 let name = cur.read_str()?;
11298 let table = cur.read_str()?;
11299 let timing = cur.read_str()?;
11300 let ev_count = cur.read_u16()? as usize;
11301 let mut events = Vec::with_capacity(ev_count);
11302 for _ in 0..ev_count {
11303 events.push(cur.read_str()?);
11304 }
11305 let for_each = cur.read_str()?;
11306 let function = cur.read_str()?;
11307 // v7.13.0 — trailing `UPDATE OF cols` filter
11308 // (FILE_VERSION 23+ only; v22 catalogs omit and
11309 // deserialise with an empty vec).
11310 let update_columns = if version >= 23 {
11311 let n = cur.read_u16()? as usize;
11312 let mut cols = Vec::with_capacity(n);
11313 for _ in 0..n {
11314 cols.push(cur.read_str()?);
11315 }
11316 cols
11317 } else {
11318 Vec::new()
11319 };
11320 // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
11321 // v24-and-below catalogs deserialise with `true`
11322 // — pre-v7.16.1 every trigger always fired.
11323 let enabled = if version >= 25 {
11324 cur.read_u8()? != 0
11325 } else {
11326 true
11327 };
11328 // v7.39 (round 138) — WHEN condition text added at FILE_VERSION
11329 // 70; older catalogs read back empty (no WHEN filter).
11330 let when_condition = if version >= 70 {
11331 cur.read_str()?
11332 } else {
11333 String::new()
11334 };
11335 cat.triggers.push(TriggerDef {
11336 name,
11337 table,
11338 timing,
11339 events,
11340 for_each,
11341 function,
11342 update_columns,
11343 enabled,
11344 when_condition,
11345 });
11346 }
11347 }
11348 // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
11349 // v25-and-below catalogs omit; we leave the map empty.
11350 if version >= 26 {
11351 let seq_count = cur.read_u32()? as usize;
11352 for _ in 0..seq_count {
11353 let name = cur.read_str()?;
11354 let data_type = match cur.read_u8()? {
11355 0 => SequenceDataType::SmallInt,
11356 1 => SequenceDataType::Int,
11357 2 => SequenceDataType::BigInt,
11358 other => {
11359 return Err(StorageError::Corrupt(format!(
11360 "unknown SEQUENCE data-type tag {other}"
11361 )));
11362 }
11363 };
11364 let start = cur.read_i64()?;
11365 let increment = cur.read_i64()?;
11366 let min_value = cur.read_i64()?;
11367 let max_value = cur.read_i64()?;
11368 let cache = cur.read_i64()?;
11369 let cycle = cur.read_u8()? != 0;
11370 let owned_by = match cur.read_u8()? {
11371 0 => None,
11372 1 => {
11373 let t = cur.read_str()?;
11374 let c = cur.read_str()?;
11375 Some((t, c))
11376 }
11377 other => {
11378 return Err(StorageError::Corrupt(format!(
11379 "unknown SEQUENCE owned-by tag {other}"
11380 )));
11381 }
11382 };
11383 let last_value = cur.read_i64()?;
11384 let is_called = cur.read_u8()? != 0;
11385 cat.sequences.insert(
11386 name.clone(),
11387 SequenceDef {
11388 name,
11389 data_type,
11390 start,
11391 increment,
11392 min_value,
11393 max_value,
11394 cache,
11395 cycle,
11396 owned_by,
11397 last_value,
11398 is_called,
11399 owner: None,
11400 acl: Vec::new(),
11401 },
11402 );
11403 }
11404 }
11405 // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
11406 // v26-and-below catalogs omit; we leave the map empty.
11407 if version >= 27 {
11408 let view_count = cur.read_u32()? as usize;
11409 for _ in 0..view_count {
11410 let name = cur.read_str()?;
11411 let col_count = cur.read_u16()? as usize;
11412 let mut columns = Vec::with_capacity(col_count);
11413 for _ in 0..col_count {
11414 columns.push(cur.read_str()?);
11415 }
11416 let body = cur.read_str_long()?;
11417 // v7.39 (round 132) — check-option marker added at FILE_VERSION
11418 // 69; older catalogs default to 0 (no check option).
11419 let check_option = if version >= 69 { cur.read_u8()? } else { 0 };
11420 cat.views.insert(
11421 name.clone(),
11422 ViewDef {
11423 name,
11424 columns,
11425 body,
11426 check_option,
11427 },
11428 );
11429 }
11430 }
11431 // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
11432 // (FILE_VERSION 28+). v27-and-below catalogs omit.
11433 if version >= 28 {
11434 let mv_count = cur.read_u32()? as usize;
11435 for _ in 0..mv_count {
11436 let name = cur.read_str()?;
11437 let body = cur.read_str_long()?;
11438 cat.materialized_views.insert(name, body);
11439 }
11440 }
11441 // v7.17.0 Phase 1.4 — ENUM types catalog block
11442 // (FILE_VERSION 29+).
11443 if version >= 29 {
11444 let etype_count = cur.read_u32()? as usize;
11445 for _ in 0..etype_count {
11446 let name = cur.read_str()?;
11447 let label_count = cur.read_u16()? as usize;
11448 let mut labels = Vec::with_capacity(label_count);
11449 for _ in 0..label_count {
11450 labels.push(cur.read_str()?);
11451 }
11452 cat.enum_types
11453 .insert(name.clone(), EnumDef { name, labels });
11454 }
11455 }
11456 // v7.17.0 Phase 1.5 — DOMAIN types catalog block
11457 // (FILE_VERSION 30+).
11458 if version >= 30 {
11459 let dtype_count = cur.read_u32()? as usize;
11460 for _ in 0..dtype_count {
11461 let name = cur.read_str()?;
11462 let base_type = cur.read_data_type()?;
11463 let nullable = cur.read_u8()? != 0;
11464 let default = match cur.read_u8()? {
11465 0 => None,
11466 1 => Some(cur.read_str()?),
11467 other => {
11468 return Err(StorageError::Corrupt(format!(
11469 "unknown DOMAIN default tag {other}"
11470 )));
11471 }
11472 };
11473 let check_count = cur.read_u16()? as usize;
11474 let mut checks: Vec<DomainCheck> = Vec::with_capacity(check_count);
11475 for i in 0..check_count {
11476 let expr = cur.read_str()?;
11477 // v7.39 (round 260) — names arrived in FILE_VERSION 75.
11478 // An older catalog gets PG's auto-naming applied to the
11479 // checks it stored, which is what they would have been.
11480 let cname = if version >= 75 {
11481 cur.read_str()?
11482 } else if i == 0 {
11483 alloc::format!("{name}_check")
11484 } else {
11485 alloc::format!("{name}_check{i}")
11486 };
11487 checks.push(DomainCheck { name: cname, expr });
11488 }
11489 // v7.39 (round 259) — the parent domain. Absent before
11490 // FILE_VERSION 74; an older catalog reads as a domain over
11491 // a scalar, which is what it was.
11492 let base_domain = if version >= 74 {
11493 match cur.read_u8()? {
11494 0 => None,
11495 1 => Some(cur.read_str()?),
11496 other => {
11497 return Err(StorageError::Corrupt(alloc::format!(
11498 "domain base_domain tag {other}"
11499 )));
11500 }
11501 }
11502 } else {
11503 None
11504 };
11505 cat.domain_types.insert(
11506 name.clone(),
11507 DomainDef {
11508 name,
11509 base_type,
11510 nullable,
11511 default,
11512 checks,
11513 base_domain,
11514 },
11515 );
11516 }
11517 }
11518 // v7.17.0 Phase 1.6 — user-schemas registry
11519 // (FILE_VERSION 31+).
11520 if version >= 31 {
11521 let sch_count = cur.read_u32()? as usize;
11522 for _ in 0..sch_count {
11523 let name = cur.read_str()?;
11524 cat.schemas.insert(name);
11525 }
11526 }
11527 // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
11528 // (FILE_VERSION 52+). v51-and-below readers stop at the
11529 // user-schemas block; v52 readers fed a v51 catalog see no
11530 // composite block and default to an empty map.
11531 if version >= 52 {
11532 let ctype_count = cur.read_u32()? as usize;
11533 for _ in 0..ctype_count {
11534 let name = cur.read_str()?;
11535 let field_count = cur.read_u16()? as usize;
11536 let mut fields = Vec::with_capacity(field_count);
11537 let mut field_user_types: Vec<Option<String>> = Vec::with_capacity(field_count);
11538 for _ in 0..field_count {
11539 let fname = cur.read_str()?;
11540 let fty = cur.read_data_type()?;
11541 // v7.39 (round 264) — present from FILE_VERSION 76.
11542 let ut = if version >= 76 {
11543 match cur.read_u8()? {
11544 0 => None,
11545 1 => Some(cur.read_str()?),
11546 other => {
11547 return Err(StorageError::Corrupt(alloc::format!(
11548 "composite field user-type tag {other}"
11549 )));
11550 }
11551 }
11552 } else {
11553 None
11554 };
11555 fields.push((fname, fty));
11556 field_user_types.push(ut);
11557 }
11558 cat.composite_types.insert(
11559 name.clone(),
11560 CompositeDef {
11561 name,
11562 fields,
11563 field_user_types,
11564 },
11565 );
11566 }
11567 }
11568 // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
11569 if version >= 61 {
11570 let comment_count = cur.read_u32()? as usize;
11571 for _ in 0..comment_count {
11572 let key = cur.read_str()?;
11573 let text = cur.read_str_long()?;
11574 cat.comments.insert(key, text);
11575 }
11576 }
11577 // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+).
11578 if version >= 66 {
11579 let read_acl = |cur: &mut Cursor| -> Result<Vec<AclItem>, StorageError> {
11580 let n = cur.read_u16()? as usize;
11581 let mut acl = Vec::with_capacity(n);
11582 for _ in 0..n {
11583 let grantee = cur.read_str()?;
11584 let privs = cur.read_u16()?;
11585 let grantable = cur.read_u16()?;
11586 let grantor = cur.read_str()?;
11587 acl.push(AclItem {
11588 grantee,
11589 privs,
11590 grantable,
11591 grantor,
11592 });
11593 }
11594 Ok(acl)
11595 };
11596 let seq_count = cur.read_u32()? as usize;
11597 for _ in 0..seq_count {
11598 let name = cur.read_str()?;
11599 let owner = if cur.read_u8()? == 1 {
11600 Some(cur.read_str()?)
11601 } else {
11602 None
11603 };
11604 let acl = read_acl(&mut cur)?;
11605 if let Some(seq) = cat.sequences.get_mut(&name) {
11606 seq.owner = owner;
11607 seq.acl = acl;
11608 }
11609 }
11610 cat.schema_acl = read_acl(&mut cur)?;
11611 cat.database_acl = read_acl(&mut cur)?;
11612 // v7.39 (read01 round 61) — FUNCTION owner + ACL (v67+; keyed by
11613 // signature from v68, when overloads became possible).
11614 if version >= 67 {
11615 let fn_count = cur.read_u32()? as usize;
11616 for _ in 0..fn_count {
11617 let name = cur.read_str()?;
11618 let owner = if cur.read_u8()? == 1 {
11619 Some(cur.read_str()?)
11620 } else {
11621 None
11622 };
11623 let acl = read_acl(&mut cur)?;
11624 // v7.39 (round 315, V19) — the stored key was computed
11625 // by whichever formula was current when the image was
11626 // written. A miss is not "no such function": before the
11627 // multi-word fix, `f(double precision)` keyed as
11628 // `f(precision)`, so an older image's grants would land
11629 // nowhere and vanish silently. Fall back to matching by
11630 // the old formula, which re-attaches them.
11631 let target = resolve_stored_function_key(&cat.functions, &name);
11632 if let Some(k) = target
11633 && let Some(f) = cat.functions.get_mut(&k)
11634 {
11635 f.owner = owner;
11636 f.acl = acl;
11637 }
11638 }
11639 }
11640 }
11641 // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), read from
11642 // the tail right before the CRC trailer. Pre-71 images stop before it.
11643 if version >= 71 {
11644 let rule_count = cur.read_u32()? as usize;
11645 for _ in 0..rule_count {
11646 let name = cur.read_str()?;
11647 let table = cur.read_str()?;
11648 let event = cur.read_str()?;
11649 let instead = cur.read_u8()? != 0;
11650 let when_condition = cur.read_str()?;
11651 let cmd_count = cur.read_u16()? as usize;
11652 let mut commands = Vec::with_capacity(cmd_count);
11653 for _ in 0..cmd_count {
11654 commands.push(cur.read_str()?);
11655 }
11656 cat.rules.push(RuleDef {
11657 name,
11658 table,
11659 event,
11660 instead,
11661 when_condition,
11662 commands,
11663 });
11664 }
11665 }
11666 // v7.39 (round 280) — extended-statistics block (FILE_VERSION
11667 // 77+). Pre-77 images stop before it.
11668 if version >= 77 {
11669 let count = cur.read_u32()? as usize;
11670 for _ in 0..count {
11671 let name = cur.read_str()?;
11672 let table = cur.read_str()?;
11673 let nk = cur.read_u16()? as usize;
11674 let mut kinds = Vec::with_capacity(nk);
11675 for _ in 0..nk {
11676 kinds.push(cur.read_str()?);
11677 }
11678 let nc = cur.read_u16()? as usize;
11679 let mut columns = Vec::with_capacity(nc);
11680 for _ in 0..nc {
11681 columns.push(cur.read_str()?);
11682 }
11683 cat.statistics_ext.push(StatisticsExtDef {
11684 name,
11685 table,
11686 kinds,
11687 columns,
11688 });
11689 }
11690 }
11691 // v7.39 (round 287) — large-object block (FILE_VERSION 78+).
11692 // Pre-78 images stop before it.
11693 if version >= 78 {
11694 let count = cur.read_u32()? as usize;
11695 for _ in 0..count {
11696 let oid = cur.read_u32()?;
11697 let len = cur.read_u32()? as usize;
11698 let bytes = cur.read_bytes(len)?;
11699 cat.large_objects.insert(oid, bytes);
11700 }
11701 }
11702 // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
11703 // 80+). Pre-80 images stop before it and keep PG's defaults.
11704 if version >= 80 {
11705 let count = cur.read_u32()? as usize;
11706 for _ in 0..count {
11707 let key = cur.read_str()?;
11708 let volatility = cur.read_u8()?;
11709 let flags = cur.read_u8()?;
11710 let parallel = cur.read_u8()?;
11711 let cost = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
11712 let rows = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
11713 if let Some(f) = cat.functions.get_mut(&key) {
11714 f.volatility = volatility;
11715 f.strict = flags & 1 != 0;
11716 f.security_definer = flags & 2 != 0;
11717 f.leakproof = flags & 4 != 0;
11718 f.parallel = parallel;
11719 f.cost = (!cost.is_nan()).then_some(cost);
11720 f.rows = (!rows.is_nan()).then_some(rows);
11721 }
11722 }
11723 }
11724 // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+).
11725 // Pre-85 images stop before it and carry no GUC defaults.
11726 if version >= 85 {
11727 let scopes = cur.read_u32()? as usize;
11728 for _ in 0..scopes {
11729 let db = cur.read_str()?;
11730 let role = cur.read_str()?;
11731 let params = cur.read_u32()? as usize;
11732 let mut m: BTreeMap<String, String> = BTreeMap::new();
11733 for _ in 0..params {
11734 let name = cur.read_str()?;
11735 let value = cur.read_str()?;
11736 m.insert(name, value);
11737 }
11738 if !m.is_empty() {
11739 cat.db_role_settings.insert((db, role), m);
11740 }
11741 }
11742 }
11743 // v7.39 (round 550) — replication slots (FILE_VERSION 86+).
11744 if version >= 86 {
11745 let count = cur.read_u32()? as usize;
11746 for _ in 0..count {
11747 let name = cur.read_str()?;
11748 let plugin = cur.read_str()?;
11749 let slot_type = cur.read_str()?;
11750 cat.replication_slots.insert(name, (plugin, slot_type));
11751 }
11752 }
11753 // v7.38.18 (S1) — the database collation (FILE_VERSION 92+).
11754 if version >= 92 {
11755 match cur.read_u8()? {
11756 0 => {}
11757 1 => cat.db_collation = Some(cur.read_str()?),
11758 other => {
11759 return Err(StorageError::Corrupt(format!(
11760 "db_collation tag: unknown byte {other}"
11761 )));
11762 }
11763 }
11764 }
11765 // v7.38.18 (S3) — a database created under a collation this
11766 // build cannot perform does not open.
11767 //
11768 // Falling back to bytes would answer with a different comparator
11769 // than every index key in it was built under, which is the one
11770 // failure this whole layer exists to prevent — and it would do
11771 // it silently, since a byte-ordered answer looks exactly like a
11772 // correct one. The check is a NAME classification here; the
11773 // engine, which owns the collator, verifies it can actually
11774 // perform the name before recording it.
11775 if let Some(c) = &cat.db_collation
11776 && c.trim().is_empty()
11777 {
11778 return Err(StorageError::Corrupt(format!(
11779 "database collation is recorded as {c:?}, which names nothing"
11780 )));
11781 }
11782 // v7.38.18 (S2) — and every table read back learns it, because a
11783 // table decides for itself which of its indexes key under a
11784 // collation. Done here rather than per-table in the loop above
11785 // because the byte that says so is written after the tables.
11786 let db_coll = cat.db_collation().to_string();
11787 for t in &mut cat.tables {
11788 t.set_db_collation(&db_coll);
11789 }
11790 // v7.38 (read01 P5.05) — v54+ images end with a CRC32C over every
11791 // preceding byte; verify it before accepting the snapshot. Older
11792 // images have no trailer and fall through to the trailing-byte check.
11793 if version >= FILE_VERSION_CRC_TRAILER {
11794 let crc_start = cur.pos;
11795 let stored = cur.read_u32()?;
11796 let computed = spg_crypto::crc32c::crc32c(&buf[..crc_start]);
11797 if computed != stored {
11798 return Err(StorageError::Corrupt(format!(
11799 "base snapshot CRC mismatch: computed {computed:#010x}, stored {stored:#010x}"
11800 )));
11801 }
11802 }
11803 if cur.pos < buf.len() {
11804 return Err(StorageError::Corrupt(format!(
11805 "trailing bytes: {} unread",
11806 buf.len() - cur.pos
11807 )));
11808 }
11809 Ok(cat)
11810 }
11811}
11812
11813#[cfg(test)]
11814mod tests;