Skip to main content

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}