Skip to content

Commit 22c104d

Browse files
committed
fix(pgwire): support ActiveRecord catalog reflection
1 parent 78e66c0 commit 22c104d

39 files changed

Lines changed: 1035 additions & 79 deletions

File tree

docs/protocols.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ psql -h localhost -p 6432
2828

2929
**SQL coverage:** Everything in the [query language reference](query-language.md).
3030

31-
**Driver compatibility:** NodeDB advertises a libpq-parseable `server_version` (`15.0 (NodeDB <version>)`) and the matching PostgreSQL-compatible `server_version_num` in the startup parameter burst. It also supports the probes drivers issue on connect: `version()` returns a PostgreSQL-compatible string, `current_setting(name [, missing_ok])` resolves the same settings as `SHOW`, and `::regclass`/`::regtype` casts, `ANY(current_schemas(...))`, and cross-catalog-table JOINs evaluate PostgreSQL-identically. Binary result formats requested at Bind are honored per column; types the encoder cannot yet emit in binary (timestamp, numeric, json/jsonb, arrays) downgrade to text, with the Describe-phase RowDescription kept in sync.
31+
**Driver compatibility:** NodeDB advertises a libpq-parseable `server_version` (`15.0 (NodeDB <version>)`) and the matching PostgreSQL-compatible `server_version_num` in the startup parameter burst. It also supports the probes drivers issue on connect: `version()` returns a PostgreSQL-compatible string, `current_setting(name [, missing_ok])` resolves the same settings as `SHOW`, `current_schemas(...)` returns PostgreSQL `TEXT[]` syntax, and `::regclass`/`::regtype` casts, `ANY(...)`, and ActiveRecord-style cross-catalog-table JOINs and projections evaluate PostgreSQL-identically. Binary result formats requested at Bind are honored per column; types the encoder cannot yet emit in binary (timestamp, numeric, json/jsonb, arrays) downgrade to text, with the Describe-phase RowDescription kept in sync.
3232

33-
**Introspection:** NodeDB exposes PostgreSQL-compatible `pg_catalog` virtual tables (e.g., `pg_class`, `pg_namespace`, `pg_attribute`, `pg_type`) so that standard Postgres clients, ORMs, and business intelligence tools can introspect the database schema without modification (`psql \d`, driver type caches, ORM bootstraps). Queries against `pg_catalog.*` tables are transparently rewritten to pull from NodeDB's internal catalog.
33+
**Introspection:** NodeDB exposes PostgreSQL-compatible `pg_catalog` virtual tables (including `pg_class`, `pg_namespace`, `pg_attribute`, `pg_attrdef`, `pg_type`, `pg_range`, and `pg_collation`) so that standard Postgres clients, ORMs, and business intelligence tools can introspect the database schema without modification (`psql \d`, driver type caches, ORM bootstraps). Column defaults and nullability are reflected, and the metadata functions used by PostgreSQL clients (`format_type`, `pg_get_expr`, and `col_description`) are supported. Queries against `pg_catalog.*` tables are transparently rewritten to pull from NodeDB's internal catalog.
3434

3535
**Streaming:** Unordered multi-row SELECTs stream lazily to the client — rows are not buffered and merged on the coordinator first. Ordered, aggregate, point-get, and search queries use the materialized path.
3636

nodedb-physical/src/physical_plan/query.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ pub enum QueryOp {
186186
post_aggregates: Vec<(String, String)>,
187187
/// Post-join projection: column names to keep (empty = all).
188188
projection: Vec<JoinProjection>,
189+
/// MessagePack-encoded computed projection expressions. When present,
190+
/// these represent the complete SELECT list in output order.
191+
computed_projection: Vec<u8>,
192+
/// Residual `ON` predicates evaluated for each equi-key candidate
193+
/// before outer-join match accounting (MessagePack).
194+
join_filters: Vec<u8>,
189195
/// Post-join WHERE filter predicates (MessagePack).
190196
post_filters: Vec<u8>,
191197
/// Resolved child plan for the left side. An Exchange child during

nodedb-physical/src/physical_plan/wire.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,39 @@ pub fn encode_batch(plans: &Vec<PhysicalPlan>) -> Result<Vec<u8>, WireError> {
9999
pub fn decode_batch(bytes: &[u8]) -> Result<Vec<PhysicalPlan>, WireError> {
100100
zerompk::from_msgpack(bytes).map_err(|e| WireError::Codec(format!("batch decode: {e}")))
101101
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use super::*;
106+
use crate::physical_plan::JoinProjection;
107+
108+
#[test]
109+
fn hash_join_tail_semantics_roundtrip() {
110+
let plan = PhysicalPlan::Query(QueryOp::HashJoin {
111+
left_collection: "left".into(),
112+
right_collection: "right".into(),
113+
left_alias: Some("l".into()),
114+
right_alias: Some("r".into()),
115+
on: vec![("id".into(), "id".into())],
116+
join_type: "left".into(),
117+
limit: 10,
118+
post_group_by: Vec::new(),
119+
post_aggregates: Vec::new(),
120+
projection: vec![JoinProjection {
121+
source: "l.id".into(),
122+
output: "id".into(),
123+
}],
124+
computed_projection: vec![1, 2],
125+
join_filters: vec![3, 4],
126+
post_filters: vec![5, 6],
127+
left_input: None,
128+
right_input: None,
129+
left_bitmap: None,
130+
right_bitmap: None,
131+
});
132+
133+
let encoded = encode(&plan).expect("hash join encodes");
134+
let decoded = decode(&encoded).expect("hash join decodes");
135+
assert_eq!(decoded, plan);
136+
}
137+
}

nodedb-query/src/functions/system.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,67 @@
55
use nodedb_types::Value;
66

77
/// Evaluate a system scalar function; returns `None` if `name` is not handled.
8-
pub(crate) fn try_eval(name: &str, _args: &[Value]) -> Option<Value> {
8+
pub(crate) fn try_eval(name: &str, args: &[Value]) -> Option<Value> {
99
match name {
1010
"version" => Some(Value::String(nodedb_types::pg_compat::version_string())),
11+
"format_type" => Some(format_type(args)),
12+
"pg_get_expr" => Some(args.first().cloned().unwrap_or(Value::Null)),
13+
// NodeDB does not persist per-column comments yet.
14+
"col_description" => Some(Value::Null),
1115
_ => None,
1216
}
1317
}
18+
19+
fn format_type(args: &[Value]) -> Value {
20+
let Some(Value::Integer(oid)) = args.first() else {
21+
return Value::Null;
22+
};
23+
let name = match *oid {
24+
16 => "boolean",
25+
17 => "bytea",
26+
20 => "bigint",
27+
21 => "smallint",
28+
23 => "integer",
29+
25 => "text",
30+
26 => "oid",
31+
114 => "json",
32+
700 => "real",
33+
701 => "double precision",
34+
1042 => "character",
35+
1043 => "character varying",
36+
1082 => "date",
37+
1083 => "time without time zone",
38+
1114 => "timestamp without time zone",
39+
1184 => "timestamp with time zone",
40+
1186 => "interval",
41+
1700 => "numeric",
42+
2950 => "uuid",
43+
3802 => "jsonb",
44+
_ => return Value::String("text".into()),
45+
};
46+
Value::String(name.into())
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
53+
#[test]
54+
fn postgres_catalog_metadata_functions() {
55+
assert_eq!(
56+
try_eval("format_type", &[Value::Integer(23), Value::Integer(-1)]),
57+
Some(Value::String("integer".into()))
58+
);
59+
assert_eq!(
60+
try_eval(
61+
"pg_get_expr",
62+
&[Value::String("'untitled'".into()), Value::Integer(42)]
63+
),
64+
Some(Value::String("'untitled'".into()))
65+
);
66+
assert_eq!(
67+
try_eval("col_description", &[Value::Integer(42), Value::Integer(1)]),
68+
Some(Value::Null)
69+
);
70+
}
71+
}

nodedb-sql/src/ddl_ast/collection_type.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,11 @@ pub fn parse_column_type_str_full(type_str: &str) -> (String, bool, bool, Option
306306
// type_str may look like: "TEXT DEFAULT upper('x')" or "INT NOT NULL DEFAULT 1 + 2".
307307
let default_expr = if let Some(def_pos) = find_ascii_case_insensitive(type_str, "DEFAULT") {
308308
let after = type_str[def_pos + "DEFAULT".len()..].trim();
309-
if after.is_empty() {
309+
let expression = &after[..default_expression_end(after)];
310+
if expression.trim().is_empty() {
310311
None
311312
} else {
312-
Some(after.to_string())
313+
Some(expression.trim().to_string())
313314
}
314315
} else {
315316
None
@@ -325,6 +326,62 @@ pub fn parse_column_type_str_full(type_str: &str) -> (String, bool, bool, Option
325326
(bare.to_string(), is_pk, is_not_null, default_expr)
326327
}
327328

329+
/// Return the byte offset where a trailing column constraint begins. Constraint
330+
/// keywords inside quoted strings or parenthesized expressions belong to the
331+
/// default expression and are intentionally ignored.
332+
fn default_expression_end(input: &str) -> usize {
333+
const CONSTRAINTS: &[&str] = &[
334+
"NOT NULL",
335+
"PRIMARY KEY",
336+
"UNIQUE",
337+
"CHECK",
338+
"REFERENCES",
339+
"COLLATE",
340+
"GENERATED",
341+
"CONSTRAINT",
342+
];
343+
344+
let bytes = input.as_bytes();
345+
let mut depth = 0usize;
346+
let mut quote = None;
347+
let mut index = 0usize;
348+
while index < bytes.len() {
349+
let byte = bytes[index];
350+
if let Some(quoted_by) = quote {
351+
if byte == quoted_by {
352+
if index + 1 < bytes.len() && bytes[index + 1] == quoted_by {
353+
index += 2;
354+
continue;
355+
}
356+
quote = None;
357+
}
358+
index += 1;
359+
continue;
360+
}
361+
match byte {
362+
b'\'' | b'"' => quote = Some(byte),
363+
b'(' => depth += 1,
364+
b')' => depth = depth.saturating_sub(1),
365+
_ if depth == 0 && (index == 0 || bytes[index - 1].is_ascii_whitespace()) => {
366+
let tail = &input[index..];
367+
if CONSTRAINTS.iter().any(|keyword| {
368+
tail.get(..keyword.len())
369+
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
370+
&& tail
371+
.as_bytes()
372+
.get(keyword.len())
373+
.is_none_or(|next| next.is_ascii_whitespace() || *next == b'(')
374+
}) {
375+
return index;
376+
}
377+
}
378+
_ => {}
379+
}
380+
index += 1;
381+
}
382+
input.len()
383+
}
384+
328385
/// Parse TTL from the options list.
329386
///
330387
/// Looks for a key `ttl` whose value is either:
@@ -444,6 +501,18 @@ mod tests {
444501
assert_eq!(default_expr.as_deref(), Some("42"));
445502
}
446503

504+
#[test]
505+
fn default_expression_excludes_trailing_constraints() {
506+
let (_, _, is_not_null, default_expr) = parse_column_type_str_full(
507+
"TEXT DEFAULT concat('NOT NULL', upper('(CHECK)')) NOT NULL UNIQUE",
508+
);
509+
assert!(is_not_null);
510+
assert_eq!(
511+
default_expr.as_deref(),
512+
Some("concat('NOT NULL', upper('(CHECK)'))")
513+
);
514+
}
515+
447516
// ── engine name → CollectionType variant ─────────────────────────────
448517

449518
#[test]

nodedb-sql/src/functions/arg_types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ pub static REPLACE_ARGS: &[ArgTypeSpec] =
267267

268268
pub static MAKE_ARRAY_ARGS: &[ArgTypeSpec] = &[any_variadic("expr")];
269269

270+
pub static PG_CATALOG_2_ARGS: &[ArgTypeSpec] = &[any("value"), any("context")];
271+
270272
// ── PostgreSQL JSON operators ─────────────────────────────────────────────────
271273

272274
pub static PG_JSON_2_ARGS: &[ArgTypeSpec] = &[any("json_col"), any("key")];

nodedb-sql/src/functions/builtins/scalars/misc.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,32 @@ pub(super) fn misc_functions() -> Vec<FunctionMeta> {
5656
Some(ColumnType::String),
5757
arg_types::NO_ARGS,
5858
),
59+
m(
60+
"format_type",
61+
Scalar,
62+
2,
63+
2,
64+
no_trigger(),
65+
Some(ColumnType::String),
66+
arg_types::PG_CATALOG_2_ARGS,
67+
),
68+
m(
69+
"pg_get_expr",
70+
Scalar,
71+
2,
72+
2,
73+
no_trigger(),
74+
Some(ColumnType::String),
75+
arg_types::PG_CATALOG_2_ARGS,
76+
),
77+
m(
78+
"col_description",
79+
Scalar,
80+
2,
81+
2,
82+
no_trigger(),
83+
Some(ColumnType::String),
84+
arg_types::PG_CATALOG_2_ARGS,
85+
),
5986
]
6087
}

nodedb-sql/src/planner/const_fold.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ pub fn fold_constant_default(expr: &SqlExpr) -> Option<SqlValue> {
4747
pub fn fold_constant(expr: &SqlExpr, registry: &FunctionRegistry) -> Option<SqlValue> {
4848
match expr {
4949
SqlExpr::Literal(v) => Some(v.clone()),
50+
SqlExpr::ArrayLiteral(items) => items
51+
.iter()
52+
.map(|item| fold_constant(item, registry))
53+
.collect::<Option<Vec<_>>>()
54+
.map(SqlValue::Array),
5055
SqlExpr::UnaryOp {
5156
op: UnaryOp::Neg,
5257
expr,
@@ -335,6 +340,22 @@ mod tests {
335340
assert!(fold_constant(&expr, &registry).is_none());
336341
}
337342

343+
#[test]
344+
fn fold_array_literal_recursively() {
345+
let registry = FunctionRegistry::new();
346+
let expr = SqlExpr::ArrayLiteral(vec![
347+
SqlExpr::Literal(SqlValue::String("public".into())),
348+
SqlExpr::Literal(SqlValue::Int(42)),
349+
]);
350+
assert_eq!(
351+
fold_constant(&expr, &registry),
352+
Some(SqlValue::Array(vec![
353+
SqlValue::String("public".into()),
354+
SqlValue::Int(42),
355+
]))
356+
);
357+
}
358+
338359
#[test]
339360
fn fold_literal_arithmetic_still_works() {
340361
let registry = FunctionRegistry::new();

nodedb/src/control/planner/sql_plan_convert/aggregate/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub(in crate::control::planner::sql_plan_convert) use plan::{
1818
};
1919
pub(in crate::control::planner::sql_plan_convert) use projection::{
2020
extract_computed_columns, extract_join_projection_specs, extract_projection_names,
21-
serialize_window_functions,
21+
serialize_join_computed_projection, serialize_window_functions,
2222
};
2323
pub(in crate::control::planner::sql_plan_convert) use spec::{
2424
agg_expr_to_pair, extract_collection_name, extract_scan_alias, inline_join_side,

nodedb/src/control/planner/sql_plan_convert/aggregate/plan.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ pub(in crate::control::planner::sql_plan_convert) fn convert_aggregate(
123123
post_group_by: group_strs,
124124
post_aggregates: agg_pairs,
125125
projection: Vec::new(),
126+
computed_projection: Vec::new(),
127+
join_filters: Vec::new(),
126128
post_filters: Vec::new(),
127129
left_input,
128130
right_input,

0 commit comments

Comments
 (0)