Skip to content

Commit 9af5664

Browse files
committed
feat(pgwire): implement pg_catalog virtual table handler
Adds `pgwire/pg_catalog/` with a dispatcher and virtual table definitions that intercept queries against `pg_catalog` relations. This allows PostgreSQL-compatible clients and drivers that inspect catalog tables during connection setup to receive well-formed responses without requiring a full system catalog implementation.
1 parent a01f73c commit 9af5664

3 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! pg_catalog query interception and dispatch.
2+
3+
use pgwire::api::results::Response;
4+
use pgwire::error::PgWireResult;
5+
6+
use crate::control::security::identity::AuthenticatedIdentity;
7+
use crate::control::state::SharedState;
8+
9+
use super::tables;
10+
11+
/// Try to handle a SQL query as a pg_catalog virtual-table lookup.
12+
///
13+
/// Returns `Some(Ok(response))` if the query targets a known
14+
/// pg_catalog table, `None` if the query should fall through to the
15+
/// normal planner. The `upper` argument is the uppercased SQL.
16+
pub fn try_pg_catalog(
17+
state: &SharedState,
18+
identity: &AuthenticatedIdentity,
19+
upper: &str,
20+
) -> Option<PgWireResult<Vec<Response>>> {
21+
let table = extract_pg_catalog_table(upper)?;
22+
let result = match table {
23+
"pg_database" => tables::pg_database(),
24+
"pg_namespace" => tables::pg_namespace(),
25+
"pg_type" => tables::pg_type(),
26+
"pg_class" => tables::pg_class(state, identity),
27+
"pg_attribute" => tables::pg_attribute(state, identity),
28+
"pg_index" => tables::pg_index(),
29+
"pg_authid" => tables::pg_authid(state, identity),
30+
_ => return None,
31+
};
32+
Some(result)
33+
}
34+
35+
/// Extract the first `pg_catalog.<table>` or bare `pg_<table>`
36+
/// reference from a FROM clause. Returns the lowercase table name
37+
/// if found.
38+
fn extract_pg_catalog_table(upper: &str) -> Option<&'static str> {
39+
let known = [
40+
"pg_database",
41+
"pg_namespace",
42+
"pg_type",
43+
"pg_class",
44+
"pg_attribute",
45+
"pg_index",
46+
"pg_authid",
47+
];
48+
for table in &known {
49+
let qualified = format!("PG_CATALOG.{}", table.to_uppercase());
50+
let bare = table.to_uppercase();
51+
if upper.contains(&qualified) || upper.contains(&bare) {
52+
return Some(table);
53+
}
54+
}
55+
None
56+
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::*;
61+
62+
#[test]
63+
fn extracts_qualified_table() {
64+
let sql = "SELECT * FROM pg_catalog.pg_class WHERE relkind = 'r'";
65+
assert_eq!(
66+
extract_pg_catalog_table(&sql.to_uppercase()),
67+
Some("pg_class")
68+
);
69+
}
70+
71+
#[test]
72+
fn extracts_bare_table() {
73+
let sql = "SELECT oid, typname FROM pg_type";
74+
assert_eq!(
75+
extract_pg_catalog_table(&sql.to_uppercase()),
76+
Some("pg_type")
77+
);
78+
}
79+
80+
#[test]
81+
fn no_match_for_regular_query() {
82+
let sql = "SELECT * FROM users WHERE id = 1";
83+
assert_eq!(extract_pg_catalog_table(&sql.to_uppercase()), None);
84+
}
85+
86+
#[test]
87+
fn handles_join_with_pg_catalog() {
88+
let sql =
89+
"SELECT c.oid FROM pg_class c JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid";
90+
assert_eq!(
91+
extract_pg_catalog_table(&sql.to_uppercase()),
92+
Some("pg_namespace")
93+
);
94+
}
95+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//! Minimal `pg_catalog` virtual-table emulation.
2+
//!
3+
//! Generic Postgres clients (DBeaver, pgAdmin, SQLAlchemy, psql's
4+
//! `\dt`) issue `SELECT` queries against `pg_catalog.*` tables to
5+
//! discover schemas, types, and tables. Without a response they
6+
//! either error out or show an empty catalog. This module intercepts
7+
//! those queries and returns rows synthesised from NodeDB's own
8+
//! `SystemCatalog` and credential store.
9+
//!
10+
//! The interception is pattern-based: we extract the first
11+
//! `pg_catalog.<table>` (or bare `pg_<table>`) reference from the
12+
//! `FROM` clause and delegate to the matching virtual table handler.
13+
//! The result always returns ALL rows with a fixed column schema —
14+
//! clients that send `WHERE` clauses filter client-side.
15+
16+
pub mod dispatch;
17+
pub mod tables;
18+
19+
pub use dispatch::try_pg_catalog;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
//! Virtual table row generators for each pg_catalog table.
2+
3+
use std::sync::Arc;
4+
5+
use futures::stream;
6+
use pgwire::api::results::{DataRowEncoder, QueryResponse, Response};
7+
use pgwire::error::PgWireResult;
8+
9+
use crate::control::security::identity::AuthenticatedIdentity;
10+
use crate::control::server::pgwire::types::{bool_field, int4_field, int8_field, text_field};
11+
use crate::control::state::SharedState;
12+
13+
/// `pg_database` — one row: the current database.
14+
pub fn pg_database() -> PgWireResult<Vec<Response>> {
15+
let schema = Arc::new(vec![
16+
int8_field("oid"),
17+
text_field("datname"),
18+
text_field("datdba"),
19+
text_field("encoding"),
20+
]);
21+
let mut encoder = DataRowEncoder::new(schema.clone());
22+
encoder.encode_field(&1i64)?;
23+
encoder.encode_field(&"nodedb")?;
24+
encoder.encode_field(&"nodedb")?;
25+
encoder.encode_field(&"UTF8")?;
26+
let rows = vec![Ok(encoder.take_row())];
27+
Ok(vec![Response::Query(QueryResponse::new(
28+
schema,
29+
stream::iter(rows),
30+
))])
31+
}
32+
33+
/// `pg_namespace` — schemas: `public` + `pg_catalog`.
34+
pub fn pg_namespace() -> PgWireResult<Vec<Response>> {
35+
let schema = Arc::new(vec![
36+
int8_field("oid"),
37+
text_field("nspname"),
38+
int8_field("nspowner"),
39+
]);
40+
let mut encoder = DataRowEncoder::new(schema.clone());
41+
let mut rows = Vec::new();
42+
43+
encoder.encode_field(&11i64)?;
44+
encoder.encode_field(&"pg_catalog")?;
45+
encoder.encode_field(&10i64)?;
46+
rows.push(Ok(encoder.take_row()));
47+
48+
encoder.encode_field(&2200i64)?;
49+
encoder.encode_field(&"public")?;
50+
encoder.encode_field(&10i64)?;
51+
rows.push(Ok(encoder.take_row()));
52+
53+
Ok(vec![Response::Query(QueryResponse::new(
54+
schema,
55+
stream::iter(rows),
56+
))])
57+
}
58+
59+
/// `pg_type` — common Postgres type OIDs that client drivers need.
60+
pub fn pg_type() -> PgWireResult<Vec<Response>> {
61+
let schema = Arc::new(vec![
62+
int8_field("oid"),
63+
text_field("typname"),
64+
int8_field("typnamespace"),
65+
int4_field("typlen"),
66+
text_field("typtype"),
67+
]);
68+
69+
let types: &[(i64, &str, i32, &str)] = &[
70+
(16, "bool", 1, "b"),
71+
(20, "int8", 8, "b"),
72+
(21, "int2", 2, "b"),
73+
(23, "int4", 4, "b"),
74+
(25, "text", -1, "b"),
75+
(114, "json", -1, "b"),
76+
(700, "float4", 4, "b"),
77+
(701, "float8", 8, "b"),
78+
(1043, "varchar", -1, "b"),
79+
(1082, "date", 4, "b"),
80+
(1114, "timestamp", 8, "b"),
81+
(1184, "timestamptz", 8, "b"),
82+
(2950, "uuid", 16, "b"),
83+
(3802, "jsonb", -1, "b"),
84+
];
85+
86+
let mut rows = Vec::with_capacity(types.len());
87+
let mut encoder = DataRowEncoder::new(schema.clone());
88+
89+
for &(oid, name, len, typtype) in types {
90+
encoder.encode_field(&oid)?;
91+
encoder.encode_field(&name)?;
92+
encoder.encode_field(&11i64)?;
93+
encoder.encode_field(&len)?;
94+
encoder.encode_field(&typtype)?;
95+
rows.push(Ok(encoder.take_row()));
96+
}
97+
98+
Ok(vec![Response::Query(QueryResponse::new(
99+
schema,
100+
stream::iter(rows),
101+
))])
102+
}
103+
104+
/// `pg_class` — one row per active collection (mapped as relation).
105+
pub fn pg_class(
106+
state: &SharedState,
107+
identity: &AuthenticatedIdentity,
108+
) -> PgWireResult<Vec<Response>> {
109+
let schema = Arc::new(vec![
110+
int8_field("oid"),
111+
text_field("relname"),
112+
int8_field("relnamespace"),
113+
text_field("relkind"),
114+
int8_field("relowner"),
115+
]);
116+
117+
let collections = load_collections(state, identity);
118+
119+
let mut rows = Vec::with_capacity(collections.len());
120+
let mut encoder = DataRowEncoder::new(schema.clone());
121+
122+
for (i, coll) in collections.iter().enumerate() {
123+
let oid = 16384i64 + i as i64;
124+
encoder.encode_field(&oid)?;
125+
encoder.encode_field(&coll.name.as_str())?;
126+
encoder.encode_field(&2200i64)?;
127+
encoder.encode_field(&"r")?;
128+
encoder.encode_field(&10i64)?;
129+
rows.push(Ok(encoder.take_row()));
130+
}
131+
132+
Ok(vec![Response::Query(QueryResponse::new(
133+
schema,
134+
stream::iter(rows),
135+
))])
136+
}
137+
138+
/// `pg_attribute` — one row per field in strict-schema collections.
139+
pub fn pg_attribute(
140+
state: &SharedState,
141+
identity: &AuthenticatedIdentity,
142+
) -> PgWireResult<Vec<Response>> {
143+
let schema = Arc::new(vec![
144+
int8_field("attrelid"),
145+
text_field("attname"),
146+
int8_field("atttypid"),
147+
int4_field("attnum"),
148+
int4_field("attlen"),
149+
bool_field("attnotnull"),
150+
]);
151+
152+
let collections = load_collections(state, identity);
153+
154+
let mut rows = Vec::new();
155+
let mut encoder = DataRowEncoder::new(schema.clone());
156+
157+
for (i, coll) in collections.iter().enumerate() {
158+
let rel_oid = 16384i64 + i as i64;
159+
for (col_num, (field_name, field_type)) in coll.fields.iter().enumerate() {
160+
let type_oid = field_type_to_oid(field_type);
161+
encoder.encode_field(&rel_oid)?;
162+
encoder.encode_field(&field_name.as_str())?;
163+
encoder.encode_field(&type_oid)?;
164+
encoder.encode_field(&((col_num + 1) as i32))?;
165+
encoder.encode_field(&(-1i32))?;
166+
encoder.encode_field(&false)?;
167+
rows.push(Ok(encoder.take_row()));
168+
}
169+
}
170+
171+
Ok(vec![Response::Query(QueryResponse::new(
172+
schema,
173+
stream::iter(rows),
174+
))])
175+
}
176+
177+
/// `pg_index` — secondary indexes.
178+
///
179+
/// Returns an empty result set with the correct schema. Structured
180+
/// index metadata is not yet surfaced through `StoredCollection`;
181+
/// once it is, this function will take `(state, identity)` and
182+
/// populate rows from the catalog.
183+
pub fn pg_index() -> PgWireResult<Vec<Response>> {
184+
let schema = Arc::new(vec![
185+
int8_field("indexrelid"),
186+
int8_field("indrelid"),
187+
bool_field("indisunique"),
188+
bool_field("indisprimary"),
189+
]);
190+
191+
let rows: Vec<Result<_, pgwire::error::PgWireError>> = Vec::new();
192+
193+
Ok(vec![Response::Query(QueryResponse::new(
194+
schema,
195+
stream::iter(rows),
196+
))])
197+
}
198+
199+
/// `pg_authid` — users / roles.
200+
pub fn pg_authid(
201+
state: &SharedState,
202+
identity: &AuthenticatedIdentity,
203+
) -> PgWireResult<Vec<Response>> {
204+
let schema = Arc::new(vec![
205+
int8_field("oid"),
206+
text_field("rolname"),
207+
bool_field("rolsuper"),
208+
bool_field("rolcanlogin"),
209+
]);
210+
211+
let mut rows = Vec::new();
212+
let mut encoder = DataRowEncoder::new(schema.clone());
213+
214+
let users = state.credentials.list_users();
215+
for (i, user) in users.iter().enumerate() {
216+
let oid = 10i64 + i as i64;
217+
let is_super = identity.is_superuser && user == &identity.username;
218+
encoder.encode_field(&oid)?;
219+
encoder.encode_field(&user.as_str())?;
220+
encoder.encode_field(&is_super)?;
221+
encoder.encode_field(&true)?;
222+
rows.push(Ok(encoder.take_row()));
223+
}
224+
225+
Ok(vec![Response::Query(QueryResponse::new(
226+
schema,
227+
stream::iter(rows),
228+
))])
229+
}
230+
231+
fn load_collections(
232+
state: &SharedState,
233+
identity: &AuthenticatedIdentity,
234+
) -> Vec<crate::control::security::catalog::types::StoredCollection> {
235+
let Some(catalog) = state.credentials.catalog() else {
236+
return Vec::new();
237+
};
238+
if identity.is_superuser {
239+
catalog
240+
.load_all_collections()
241+
.unwrap_or_default()
242+
.into_iter()
243+
.filter(|c| c.is_active)
244+
.collect()
245+
} else {
246+
catalog
247+
.load_collections_for_tenant(identity.tenant_id.as_u32())
248+
.unwrap_or_default()
249+
}
250+
}
251+
252+
fn field_type_to_oid(field_type: &str) -> i64 {
253+
match field_type.to_lowercase().as_str() {
254+
"bool" | "boolean" => 16,
255+
"int" | "integer" | "int4" => 23,
256+
"bigint" | "int8" => 20,
257+
"smallint" | "int2" => 21,
258+
"float" | "float4" | "real" => 700,
259+
"double" | "float8" => 701,
260+
"text" | "string" => 25,
261+
"varchar" => 1043,
262+
"json" => 114,
263+
"jsonb" => 3802,
264+
"uuid" => 2950,
265+
"date" => 1082,
266+
"timestamp" => 1114,
267+
"timestamptz" => 1184,
268+
_ => 25,
269+
}
270+
}

0 commit comments

Comments
 (0)