nedb_engine/pgcatalog.rs
1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! `pg_catalog` and `information_schema` as REAL QUERYABLE TABLES.
6//!
7//! # Why this exists
8//!
9//! `\dt` in psql and the schema browser in DBeaver came back **empty**. That
10//! is an evaluator's first ten minutes, and an empty table list does not read
11//! as "unsupported" — it reads as "this database is broken" or "my data is
12//! gone".
13//!
14//! # Why it is built this way
15//!
16//! The cheap implementation is to recognise psql's exact query text and answer
17//! it from a fixed table. Several pgwire-compatible engines do that. It is the
18//! wrong choice here for a specific reason: **it breaks silently.** psql
19//! changes its catalogue queries between versions, and when the pattern stops
20//! matching, the result is an empty table list — indistinguishable from a
21//! database that genuinely has no tables. That is the exact class of
22//! confidently-wrong answer this engine has spent its life removing.
23//!
24//! So the catalogue is a set of real tables, synthesised from the live
25//! database, and queried through the ordinary predicate path
26//! (`nql::query_rows`). `WHERE`, `ORDER BY`, `LIMIT` and the `~`/`!~`
27//! operators all work on them because they are the same operators, not a
28//! second implementation.
29//!
30//! # What a schemaless engine can honestly report
31//!
32//! NEDB has no schema, so the catalogue is *derived*, and that derivation is
33//! stated rather than hidden:
34//!
35//! * a **collection** is a table in `pg_class` / `information_schema.tables`;
36//! * a **field observed in a sampled document** is a column in
37//! `pg_attribute` / `information_schema.columns`, typed the way the pgwire
38//! layer types it on the wire;
39//! * everything Postgres tracks that NEDB does not have — owners, tablespaces,
40//! ACLs, statistics — reports a fixed, plainly-wrong-if-you-look value
41//! (`10`, `0`, empty) rather than a fabricated plausible one.
42//!
43//! Column order and the sampled field set come from the same code the wire
44//! protocol uses, so a client is never told about a column the data does not
45//! produce.
46
47use std::sync::Arc;
48
49use serde_json::{json, Map, Value};
50
51use crate::db::Db;
52
53/// The fixed OID NEDB reports for anything Postgres owns and NEDB does not.
54///
55/// `10` is Postgres's own `bootstrap superuser` OID. Reporting a real-looking
56/// owner is less misleading than reporting `0`, which some clients render as a
57/// missing row rather than an unknown one.
58const OWNER_OID: i64 = 10;
59
60/// The one schema NEDB presents. A collection has no namespace of its own, so
61/// inventing several would be inventing structure.
62const PUBLIC_NS_OID: i64 = 2200; // Postgres's own oid for `public`
63const CATALOG_NS_OID: i64 = 11;
64const INFO_NS_OID: i64 = 13000;
65
66/// How many documents to sample when deriving a table's columns.
67///
68/// Bounded, because `\d` on a large collection must not turn into a scan. It
69/// is a sample, and the module doc says so — a field that appears only outside
70/// it is absent from the catalogue, which is the honest failure for a store
71/// with no declared schema.
72const COLUMN_SAMPLE: usize = 200;
73
74/// Is `table` a catalogue relation this module serves?
75///
76/// Matched on the BARE name, because the pgwire layer strips the schema
77/// qualification before it gets here (`pg_catalog.pg_class` → `pg_class`).
78/// An `information_schema.` prefix is kept on those names by the caller, since
79/// `tables` and `columns` are words a user could plausibly name a collection.
80pub fn is_catalog(table: &str) -> bool {
81 matches!(
82 table,
83 "pg_class" | "pg_namespace" | "pg_attribute" | "pg_type" | "pg_database"
84 | "pg_am" | "pg_roles" | "pg_user" | "pg_settings" | "pg_index"
85 | "pg_description" | "pg_constraint" | "pg_tablespace"
86 | "information_schema.tables"
87 | "information_schema.columns"
88 | "information_schema.schemata"
89 | "information_schema.key_column_usage"
90 | "information_schema.table_constraints"
91 )
92}
93
94/// A stable synthetic OID for a name.
95///
96/// Postgres clients use an OID to correlate rows between catalogue tables
97/// (`pg_attribute.attrelid` → `pg_class.oid`), so it has to be *consistent*
98/// within a connection, not globally meaningful. Derived from the name so the
99/// same collection gets the same OID on every query without any state to keep.
100/// Offset above Postgres's own reserved range so a synthetic OID cannot
101/// collide with a real one a client has hard-coded.
102fn oid_for(name: &str) -> i64 {
103 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
104 for b in name.as_bytes() {
105 h ^= *b as u64;
106 h = h.wrapping_mul(0x100_0000_01b3);
107 }
108 // Keep it comfortably inside i32 (Postgres OIDs are 32-bit unsigned) and
109 // above the reserved floor.
110 16_384 + (h % 2_000_000_000) as i64
111}
112
113/// The collections in the database, sorted so a listing is stable run to run.
114fn collections(db: Option<&Arc<Db>>) -> Vec<String> {
115 let mut out = match db {
116 Some(db) => db.id_index.collections(),
117 None => vec![],
118 };
119 // Internal bookkeeping is not a user table. `__links__` holds relation
120 // edges; showing it in `\dt` would invite someone to query or trust it.
121 out.retain(|c| !c.starts_with("__") && !c.is_empty());
122 out.sort();
123 out
124}
125
126/// Field name → wire type, derived from a bounded sample of the collection.
127///
128/// Uses the pgwire layer's own typing so the catalogue cannot disagree with
129/// what the wire actually sends: a column reported as `bigint` here is a
130/// column the protocol advertises as `int8`.
131fn columns_of(db: Option<&Arc<Db>>, coll: &str) -> Vec<(String, i32)> {
132 let db = match db {
133 Some(db) => db,
134 None => return vec![],
135 };
136 let rows = match crate::nql::query(db, &format!("FROM {} LIMIT {}", coll, COLUMN_SAMPLE)) {
137 Ok((rows, _)) => rows,
138 Err(_) => return vec![],
139 };
140 let mut names: Vec<String> = vec![];
141 for r in &rows {
142 if let Value::Object(m) = r {
143 for k in m.keys() {
144 if !names.iter().any(|n| n == k) {
145 names.push(k.clone());
146 }
147 }
148 }
149 }
150 names.sort();
151 names
152 .into_iter()
153 .map(|n| {
154 let oid = crate::pgwire::oid_for_column(&rows, &n);
155 (n, oid)
156 })
157 .collect()
158}
159
160/// The Postgres type name for an OID we hand out — the `data_type` a client
161/// reads in `information_schema.columns`.
162/// Public alias so `format_type()` in the SQL engine names a type the same
163/// way `information_schema.columns` does.
164pub fn type_name_pub(oid: i32) -> &'static str {
165 type_name(oid)
166}
167
168fn type_name(oid: i32) -> &'static str {
169 match oid {
170 16 => "boolean",
171 20 => "bigint",
172 21 => "smallint",
173 23 => "integer",
174 700 => "real",
175 701 => "double precision",
176 1043 => "character varying",
177 _ => "text",
178 }
179}
180
181fn row(pairs: Vec<(&str, Value)>) -> Value {
182 let mut m = Map::new();
183 for (k, v) in pairs {
184 m.insert(k.to_string(), v);
185 }
186 Value::Object(m)
187}
188
189/// The rows of a catalogue relation, or `None` if it is not one.
190///
191/// Every column a real Postgres exposes is present where a client might read
192/// it, because a missing column is a hard error in the middle of someone
193/// else's generated SQL — far worse than a column reporting a fixed value.
194pub fn rows(table: &str, db: Option<&Arc<Db>>) -> Option<Vec<Value>> {
195 let colls = collections(db);
196
197 Some(match table {
198 // ── pg_namespace ────────────────────────────────────────────────────
199 // What `\dn` reads. The one table that needs no JOIN, which is why it
200 // was the first milestone.
201 "pg_namespace" | "information_schema.schemata" => {
202 let is_info = table.starts_with("information_schema");
203 [("public", PUBLIC_NS_OID), ("pg_catalog", CATALOG_NS_OID),
204 ("information_schema", INFO_NS_OID)]
205 .iter()
206 .map(|(name, oid)| {
207 if is_info {
208 row(vec![
209 ("catalog_name", json!("nedb")),
210 ("schema_name", json!(name)),
211 ("schema_owner", json!("nedb")),
212 ("default_character_set_catalog", Value::Null),
213 ("default_character_set_schema", Value::Null),
214 ("default_character_set_name", Value::Null),
215 ("sql_path", Value::Null),
216 ])
217 } else {
218 row(vec![
219 ("oid", json!(oid)),
220 ("nspname", json!(name)),
221 ("nspowner", json!(OWNER_OID)),
222 ("nspacl", Value::Null),
223 ])
224 }
225 })
226 .collect()
227 }
228
229 // ── pg_class — one row per collection ───────────────────────────────
230 "pg_class" => colls
231 .iter()
232 .map(|c| {
233 row(vec![
234 ("oid", json!(oid_for(c))),
235 ("relname", json!(c)),
236 ("relnamespace", json!(PUBLIC_NS_OID)),
237 // 'r' = ordinary table. A collection is writable and has
238 // rows, so any other relkind would be a lie.
239 ("relkind", json!("r")),
240 ("relowner", json!(OWNER_OID)),
241 ("relam", json!(2)), // heap
242 ("reltuples", json!(-1.0)), // -1 = never analysed, which is true
243 ("relhasindex", json!(false)),
244 ("relpersistence", json!("p")),
245 ("reltablespace", json!(0)),
246 ("relispartition", json!(false)),
247 ("reltoastrelid", json!(0)),
248 ("relnatts", json!(columns_of(db, c).len() as i64)),
249 ])
250 })
251 .collect(),
252
253 // ── pg_attribute — one row per observed field ───────────────────────
254 "pg_attribute" => colls
255 .iter()
256 .flat_map(|c| {
257 let rel = oid_for(c);
258 columns_of(db, c)
259 .into_iter()
260 .enumerate()
261 .map(move |(i, (name, oid))| {
262 row(vec![
263 ("attrelid", json!(rel)),
264 ("attname", json!(name)),
265 // 1-based, as Postgres numbers them.
266 ("attnum", json!(i as i64 + 1)),
267 ("atttypid", json!(oid as i64)),
268 ("attlen", json!(-1)),
269 ("atttypmod", json!(-1)),
270 // Nothing is NOT NULL in a schemaless store: any
271 // document may omit any field.
272 ("attnotnull", json!(false)),
273 ("atthasdef", json!(false)),
274 ("attisdropped", json!(false)),
275 ("attidentity", json!("")),
276 ("attgenerated", json!("")),
277 ])
278 })
279 .collect::<Vec<_>>()
280 })
281 .collect(),
282
283 // ── information_schema.tables / .columns — the standard-SQL view ────
284 // What JDBC's DatabaseMetaData and most BI tools read first.
285 "information_schema.tables" => colls
286 .iter()
287 .map(|c| {
288 row(vec![
289 ("table_catalog", json!("nedb")),
290 ("table_schema", json!("public")),
291 ("table_name", json!(c)),
292 ("table_type", json!("BASE TABLE")),
293 ("self_referencing_column_name", Value::Null),
294 ("reference_generation", Value::Null),
295 ("user_defined_type_catalog", Value::Null),
296 ("user_defined_type_schema", Value::Null),
297 ("user_defined_type_name", Value::Null),
298 ("is_insertable_into", json!("YES")),
299 ("is_typed", json!("NO")),
300 ("commit_action", Value::Null),
301 ])
302 })
303 .collect(),
304
305 "information_schema.columns" => colls
306 .iter()
307 .flat_map(|c| {
308 columns_of(db, c)
309 .into_iter()
310 .enumerate()
311 .map(move |(i, (name, oid))| {
312 row(vec![
313 ("table_catalog", json!("nedb")),
314 ("table_schema", json!("public")),
315 ("table_name", json!(c)),
316 ("column_name", json!(name)),
317 ("ordinal_position", json!(i as i64 + 1)),
318 ("column_default", Value::Null),
319 // Always YES: any document may omit any field.
320 ("is_nullable", json!("YES")),
321 ("data_type", json!(type_name(oid))),
322 ("character_maximum_length", Value::Null),
323 ("numeric_precision", Value::Null),
324 ("numeric_scale", Value::Null),
325 ("datetime_precision", Value::Null),
326 ("udt_catalog", json!("nedb")),
327 ("udt_schema", json!("pg_catalog")),
328 ("udt_name", json!(type_name(oid))),
329 ("is_updatable", json!("YES")),
330 ])
331 })
332 .collect::<Vec<_>>()
333 })
334 .collect(),
335
336 // ── pg_type — only the types this endpoint actually hands out ───────
337 // Listing Postgres's full type table would be inventing support for
338 // types the wire layer cannot encode.
339 "pg_type" => [
340 (16, "bool"), (20, "int8"), (21, "int2"), (23, "int4"),
341 (25, "text"), (700, "float4"), (701, "float8"), (1043, "varchar"),
342 ]
343 .iter()
344 .map(|(oid, name)| {
345 row(vec![
346 ("oid", json!(*oid as i64)),
347 ("typname", json!(name)),
348 ("typnamespace", json!(CATALOG_NS_OID)),
349 ("typowner", json!(OWNER_OID)),
350 ("typlen", json!(-1)),
351 ("typtype", json!("b")),
352 ("typcategory", json!("S")),
353 ("typelem", json!(0)),
354 ("typrelid", json!(0)),
355 ])
356 })
357 .collect(),
358
359 // ── pg_database — the databases the server has open ─────────────────
360 "pg_database" => vec![row(vec![
361 ("oid", json!(oid_for("nedb"))),
362 ("datname", json!("nedb")),
363 ("datdba", json!(OWNER_OID)),
364 ("encoding", json!(6)), // 6 = UTF8 in Postgres's encoding table
365 ("datcollate", json!("C")),
366 ("datctype", json!("C")),
367 ("datlocprovider", json!("c")),
368 ("daticulocale", Value::Null),
369 ("daticurules", Value::Null),
370 ("datistemplate", json!(false)),
371 ("datallowconn", json!(true)),
372 ("datconnlimit", json!(-1)),
373 ("datacl", Value::Null),
374 ])],
375
376 "pg_am" => vec![row(vec![
377 ("oid", json!(2)),
378 ("amname", json!("heap")),
379 ("amhandler", json!(0)),
380 ("amtype", json!("t")),
381 ])],
382
383 "pg_roles" | "pg_user" => vec![row(vec![
384 ("oid", json!(OWNER_OID)),
385 ("rolname", json!("nedb")),
386 ("usename", json!("nedb")),
387 ("rolsuper", json!(true)),
388 ("usesuper", json!(true)),
389 ("rolcanlogin", json!(true)),
390 ("rolcreatedb", json!(true)),
391 ("rolvaliduntil", Value::Null),
392 ])],
393
394 // ── the ones that are genuinely EMPTY, and should say so ────────────
395 // An empty catalogue table is the truthful answer here: NEDB has no
396 // secondary indexes visible to SQL, no constraints, no comments and
397 // no tablespaces. Returning rows would fabricate structure; refusing
398 // the query would break generated SQL that only wants to find none.
399 "pg_index" | "pg_description" | "pg_constraint" | "pg_tablespace"
400 | "pg_settings" | "information_schema.key_column_usage"
401 | "information_schema.table_constraints" => vec![],
402
403 _ => return None,
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use tempfile::tempdir;
411
412 fn db_with(colls: &[(&str, &str, Value)]) -> (tempfile::TempDir, Arc<Db>) {
413 let dir = tempdir().unwrap();
414 let db = Arc::new(Db::open(dir.path(), None).unwrap());
415 for (coll, id, doc) in colls {
416 db.put(coll, id, doc.clone(), vec![], None, None).unwrap();
417 }
418 (dir, db)
419 }
420
421 fn names(rows: &[Value], field: &str) -> Vec<String> {
422 let mut v: Vec<String> = rows
423 .iter()
424 .filter_map(|r| r.get(field)?.as_str().map(str::to_string))
425 .collect();
426 v.sort();
427 v
428 }
429
430 #[test]
431 fn a_collection_appears_as_a_table_in_every_place_a_client_looks() {
432 // JDBC reads information_schema; psql reads pg_class. A collection
433 // visible in one and not the other is a database that looks half
434 // empty depending on the tool.
435 let (_t, db) = db_with(&[
436 ("orders", "1", json!({"total": 1})),
437 ("drivers", "d1", json!({"name": "Bob"})),
438 ]);
439 let d = Some(&db);
440 assert_eq!(names(&rows("pg_class", d).unwrap(), "relname"),
441 vec!["drivers", "orders"]);
442 assert_eq!(names(&rows("information_schema.tables", d).unwrap(), "table_name"),
443 vec!["drivers", "orders"]);
444 }
445
446 #[test]
447 fn internal_bookkeeping_is_not_presented_as_a_user_table() {
448 // `__links__` holds relation edges. Listing it in `\dt` would invite
449 // someone to query or trust it as their own data.
450 let (_t, db) = db_with(&[("orders", "1", json!({"a": 1}))]);
451 let db2 = Arc::clone(&db);
452 db2.link("orders:1", "rel", "orders:1").ok();
453 let got = names(&rows("pg_class", Some(&db)).unwrap(), "relname");
454 assert!(!got.iter().any(|n| n.starts_with("__")), "{:?}", got);
455 }
456
457 #[test]
458 fn a_columns_reported_type_is_the_type_the_WIRE_sends() {
459 // The catalogue and the protocol go through one typing function, so
460 // they cannot disagree. A column reported `bigint` here that arrived
461 // as text on the wire would be a self-contradiction a client is
462 // entitled to trust.
463 let (_t, db) = db_with(&[
464 ("t", "1", json!({"n": 7, "s": "x", "b": true, "f": 1.5})),
465 ]);
466 let cols = rows("information_schema.columns", Some(&db)).unwrap();
467 let by = |name: &str| -> String {
468 cols.iter()
469 .find(|r| r["column_name"] == json!(name))
470 .and_then(|r| r["data_type"].as_str())
471 .unwrap_or("<missing>").to_string()
472 };
473 assert_eq!(by("n"), "bigint");
474 assert_eq!(by("s"), "text");
475 assert_eq!(by("b"), "boolean");
476 assert_eq!(by("f"), "double precision");
477 }
478
479 #[test]
480 fn everything_is_nullable_because_a_schemaless_document_may_omit_anything() {
481 let (_t, db) = db_with(&[("t", "1", json!({"a": 1}))]);
482 let cols = rows("information_schema.columns", Some(&db)).unwrap();
483 assert!(cols.iter().all(|r| r["is_nullable"] == json!("YES")), "{:?}", cols);
484 let attrs = rows("pg_attribute", Some(&db)).unwrap();
485 assert!(attrs.iter().all(|r| r["attnotnull"] == json!(false)));
486 }
487
488 #[test]
489 fn pg_attribute_correlates_with_pg_class_by_oid() {
490 // A client JOINs these two on oid. If the synthetic oids did not
491 // match, every `\d`-style query would silently return no columns.
492 let (_t, db) = db_with(&[("orders", "1", json!({"total": 1}))]);
493 let d = Some(&db);
494 let rel = rows("pg_class", d).unwrap();
495 let oid = rel.iter().find(|r| r["relname"] == json!("orders")).unwrap()["oid"].clone();
496 let attrs = rows("pg_attribute", d).unwrap();
497 assert!(attrs.iter().any(|r| r["attrelid"] == oid),
498 "no pg_attribute row points at pg_class.oid {:?}", oid);
499 // …and attnum is 1-based, as Postgres numbers columns.
500 assert!(attrs.iter().all(|r| r["attnum"].as_i64().unwrap_or(0) >= 1));
501 }
502
503 #[test]
504 fn an_oid_is_stable_across_calls_so_a_join_holds() {
505 assert_eq!(oid_for("orders"), oid_for("orders"));
506 assert_ne!(oid_for("orders"), oid_for("drivers"));
507 // Above Postgres's reserved floor, so a synthetic oid cannot collide
508 // with a real one a client has hard-coded.
509 assert!(oid_for("orders") >= 16_384);
510 // And inside i32, because a Postgres OID is 32-bit.
511 assert!(oid_for("orders") < i32::MAX as i64);
512 }
513
514 #[test]
515 fn pg_namespace_answers_without_any_database_open() {
516 // psql sends catalogue queries on startup, sometimes before a database
517 // is selected. Refusing there is how "psql cannot connect" begins.
518 let ns = rows("pg_namespace", None).unwrap();
519 assert_eq!(names(&ns, "nspname"),
520 vec!["information_schema", "pg_catalog", "public"]);
521 }
522
523 #[test]
524 fn the_tables_nedb_genuinely_has_nothing_for_are_EMPTY_not_absent() {
525 // Empty is the truthful answer: no SQL-visible indexes, no
526 // constraints, no comments, no tablespaces. Returning rows would
527 // fabricate structure; returning None would break generated SQL that
528 // only wants to find none.
529 for t in ["pg_index", "pg_constraint", "pg_description", "pg_tablespace",
530 "information_schema.key_column_usage",
531 "information_schema.table_constraints"] {
532 let r = rows(t, None).unwrap_or_else(|| panic!("{} must be served", t));
533 assert!(r.is_empty(), "{} should be empty, got {:?}", t, r);
534 }
535 }
536
537 #[test]
538 fn a_name_that_is_not_a_catalogue_relation_is_not_claimed() {
539 assert!(!is_catalog("orders"));
540 assert!(!is_catalog("tables"), "a bare `tables` is a user collection");
541 assert!(rows("orders", None).is_none());
542 // information_schema keeps its qualifier precisely so a user
543 // collection called `tables` cannot be shadowed by the catalogue.
544 assert!(is_catalog("information_schema.tables"));
545 }
546
547 #[test]
548 fn pg_type_lists_only_types_this_endpoint_can_actually_send() {
549 let t = names(&rows("pg_type", None).unwrap(), "typname");
550 assert!(t.contains(&"int8".to_string()));
551 assert!(t.contains(&"text".to_string()));
552 // Listing Postgres's whole type table would advertise encoders the
553 // wire layer does not have.
554 assert!(!t.contains(&"tsvector".to_string()));
555 assert!(!t.contains(&"jsonb".to_string()));
556 }
557}