Skip to content

Commit 084e01d

Browse files
committed
Fix review issues: explain plain SQL, strict node_type validation, config-embedded node checks
- explain.rs: Auto-wrap plain SQL input in df.explain() to prevent SELECT SELECT ... errors; detect DSL operators precisely to avoid misclassifying SQL with bitwise | or & - types.rs: Add Durofut::ensure_strict() to reject unknown node_types at entrypoints; add validate_recursive() for full graph validation including config-embedded nodes (condition_node, extra_nodes) - dsl.rs: df.start() now uses strict parsing + recursive validation; insert_nodes errors on invalid condition_node or extra_nodes - lib.rs: SQL df.ensure_durofut() now rejects unknown node_type values - Added E2E tests 27 (explain plain SQL) and 28 (invalid node_type)
1 parent d26f321 commit 084e01d

6 files changed

Lines changed: 237 additions & 16 deletions

File tree

src/dsl.rs

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,15 @@ pub fn signal(instance_id: &str, signal_name: &str, signal_data: default!(&str,
477477
/// Variables from df.vars are captured and passed to the orchestration.
478478
#[pg_extern(schema = "df")]
479479
pub fn start(fut: &str, label: default!(Option<&str>, "NULL")) -> String {
480-
let durofut = Durofut::ensure(fut);
480+
let durofut = match Durofut::ensure_strict(fut) {
481+
Ok(d) => d,
482+
Err(e) => pgrx::error!("Invalid durable function: {}", e),
483+
};
484+
485+
// Validate the entire graph recursively before inserting
486+
if let Err(e) = durofut.validate_recursive() {
487+
pgrx::error!("Invalid durable function graph: {}", e);
488+
}
481489
let instance_id = short_id();
482490

483491
let label_sql = label
@@ -504,24 +512,40 @@ pub fn start(fut: &str, label: default!(Option<&str>, "NULL")) -> String {
504512
// For IF/LOOP nodes: replace condition_node Durofut with ID
505513
if node.node_type == "IF" || node.node_type == "LOOP" {
506514
if let Some(cond_json) = config.get("condition_node") {
507-
if let Ok(cond_node) = serde_json::from_value::<Durofut>(cond_json.clone())
508-
{
509-
let cond_id = insert_nodes(&cond_node, instance_id);
510-
config["condition_node"] = serde_json::json!(cond_id);
515+
match serde_json::from_value::<Durofut>(cond_json.clone()) {
516+
Ok(cond_node) => {
517+
let cond_id = insert_nodes(&cond_node, instance_id);
518+
config["condition_node"] = serde_json::json!(cond_id);
519+
}
520+
Err(_) => {
521+
if cond_json.is_string() {
522+
pgrx::error!(
523+
"condition_node in {} must be a Durofut object, not a string ID",
524+
node.node_type
525+
);
526+
}
527+
// For other types, this will be caught by validate_recursive
528+
}
511529
}
512530
}
513531
}
514532
// For JOIN3 nodes: replace extra_nodes Durofuts with IDs
515533
if node.node_type == "JOIN" {
516534
if let Some(extras) = config.get("extra_nodes").and_then(|e| e.as_array()) {
517-
let extra_ids: Vec<String> = extras
518-
.iter()
519-
.filter_map(|extra_json| {
520-
serde_json::from_value::<Durofut>(extra_json.clone())
521-
.ok()
522-
.map(|n| insert_nodes(&n, instance_id))
523-
})
524-
.collect();
535+
let mut extra_ids: Vec<String> = Vec::new();
536+
for (i, extra_json) in extras.iter().enumerate() {
537+
match serde_json::from_value::<Durofut>(extra_json.clone()) {
538+
Ok(n) => {
539+
extra_ids.push(insert_nodes(&n, instance_id));
540+
}
541+
Err(_) => {
542+
pgrx::error!(
543+
"extra_nodes[{}] in JOIN must be a Durofut object",
544+
i
545+
);
546+
}
547+
}
548+
}
525549
if !extra_ids.is_empty() {
526550
config["extra_nodes"] = serde_json::json!(extra_ids);
527551
}

src/explain.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,45 @@ fn get_duroxide_instance_info(instance_id: &str) -> (String, Option<String>) {
155155
})
156156
}
157157

158+
/// Check if input looks like a plain SQL statement (as opposed to a DSL expression).
159+
/// Returns true for inputs starting with SQL keywords like SELECT, INSERT, etc.
160+
fn looks_like_plain_sql(input: &str) -> bool {
161+
let upper = input.trim_start().to_uppercase();
162+
let sql_keywords = [
163+
"SELECT ",
164+
"INSERT ",
165+
"UPDATE ",
166+
"DELETE ",
167+
"WITH ",
168+
"CREATE ",
169+
"ALTER ",
170+
"DROP ",
171+
"CALL ",
172+
"DO ",
173+
"TRUNCATE ",
174+
"GRANT ",
175+
"REVOKE ",
176+
"EXPLAIN ",
177+
"SELECT\n",
178+
"INSERT\n",
179+
"UPDATE\n",
180+
"DELETE\n",
181+
"WITH\n",
182+
];
183+
sql_keywords.iter().any(|kw| upper.starts_with(kw))
184+
}
185+
186+
/// Check if input looks like a DSL expression containing pg_durable operators or functions.
187+
/// DSL expressions contain operators like ~>, |=>, ?>, !>, @> or df.* function calls.
188+
fn looks_like_dsl_expression(input: &str) -> bool {
189+
input.contains("~>")
190+
|| input.contains("|=>")
191+
|| input.contains("?>")
192+
|| input.contains("!>")
193+
|| input.contains("@>")
194+
|| input.contains("df.")
195+
}
196+
158197
/// Explain a DSL expression without executing it
159198
fn explain_expression(expr: &str) -> String {
160199
use crate::types::Durofut;
@@ -168,7 +207,21 @@ fn explain_expression(expr: &str) -> String {
168207
return build_tree_visualization(&root_id, &nodes, false);
169208
}
170209

171-
// Not JSON - execute the expression to build the graph
210+
// If it looks like plain SQL (not a DSL expression), wrap it directly as a SQL node
211+
// This prevents the "SELECT SELECT ..." problem when users pass plain SQL to explain
212+
if looks_like_plain_sql(expr) && !looks_like_dsl_expression(expr) {
213+
let sql_node = Durofut {
214+
node_type: "SQL".to_string(),
215+
query: Some(expr.to_string()),
216+
..Default::default()
217+
};
218+
let mut nodes = HashMap::new();
219+
let mut id_counter = 0;
220+
let root_id = collect_nodes(&sql_node, &mut nodes, &mut id_counter);
221+
return build_tree_visualization(&root_id, &nodes, false);
222+
}
223+
224+
// DSL expression - evaluate it to build the graph
172225
let durofut_json: Result<Option<String>, _> = Spi::get_one(&format!("SELECT {expr}"));
173226

174227
let root_json = match durofut_json {

src/lib.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,28 @@ END;
174174
$$ LANGUAGE plpgsql IMMUTABLE;
175175
176176
-- Helper to ensure a value is a durofut (returns JSON string)
177+
-- Rejects JSON with unknown node_type values
177178
CREATE OR REPLACE FUNCTION df.ensure_durofut(val text) RETURNS text AS $$
179+
DECLARE
180+
node_type_val text;
178181
BEGIN
179182
-- Try to parse as JSON to check if it's already a durofut
180183
BEGIN
181-
IF (val::jsonb)->>'node_type' IS NOT NULL THEN
184+
node_type_val := (val::jsonb)->>'node_type';
185+
IF node_type_val IS NOT NULL THEN
186+
-- Has a node_type - validate it
187+
IF node_type_val NOT IN ('SQL', 'THEN', 'IF', 'JOIN', 'LOOP', 'BREAK', 'RACE', 'SLEEP', 'WAIT_SCHEDULE', 'HTTP', 'SIGNAL') THEN
188+
RAISE EXCEPTION 'Unknown node_type ''%''. Valid types: SQL, THEN, IF, JOIN, LOOP, BREAK, RACE, SLEEP, WAIT_SCHEDULE, HTTP, SIGNAL', node_type_val;
189+
END IF;
182190
RETURN val;
183191
END IF;
184-
EXCEPTION WHEN OTHERS THEN
192+
EXCEPTION WHEN invalid_text_representation THEN
193+
-- Not valid JSON, treat as SQL
194+
NULL;
195+
WHEN raise_exception THEN
196+
-- Re-raise our validation error
197+
RAISE;
198+
WHEN OTHERS THEN
185199
-- Not valid JSON, treat as SQL
186200
NULL;
187201
END;

src/types.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,88 @@ impl Durofut {
345345
},
346346
}
347347
}
348+
349+
/// Strict version of ensure - rejects JSON with unknown node_type instead of wrapping as SQL.
350+
/// Used by df.start() and other entrypoints where invalid node types should be caught early.
351+
pub fn ensure_strict(s: &str) -> Result<Self, String> {
352+
match serde_json::from_str::<Durofut>(s) {
353+
Ok(d) => {
354+
if VALID_NODE_TYPES.contains(&d.node_type.as_str()) {
355+
Ok(d)
356+
} else {
357+
Err(format!(
358+
"Unknown node_type '{}'. Valid types: {}",
359+
d.node_type,
360+
VALID_NODE_TYPES.join(", ")
361+
))
362+
}
363+
}
364+
Err(_) => {
365+
// Not valid Durofut JSON - try to parse as generic JSON to check for node_type
366+
if let Ok(val) = serde_json::from_str::<serde_json::Value>(s) {
367+
if let Some(nt) = val.get("node_type").and_then(|v| v.as_str()) {
368+
return Err(format!(
369+
"Unknown node_type '{}'. Valid types: {}",
370+
nt,
371+
VALID_NODE_TYPES.join(", ")
372+
));
373+
}
374+
}
375+
// Not JSON at all or no node_type field - treat as SQL
376+
Ok(Durofut {
377+
node_type: "SQL".to_string(),
378+
query: Some(s.to_string()),
379+
..Default::default()
380+
})
381+
}
382+
}
383+
}
384+
385+
/// Validate a Durofut node and all its children have valid node_types.
386+
/// Used during insertion in df.start() to catch invalid nested nodes.
387+
pub fn validate_recursive(&self) -> Result<(), String> {
388+
if !VALID_NODE_TYPES.contains(&self.node_type.as_str()) {
389+
return Err(format!(
390+
"Unknown node_type '{}'. Valid types: {}",
391+
self.node_type,
392+
VALID_NODE_TYPES.join(", ")
393+
));
394+
}
395+
if let Some(ref left) = self.left_node {
396+
left.validate_recursive()?;
397+
}
398+
if let Some(ref right) = self.right_node {
399+
right.validate_recursive()?;
400+
}
401+
// Validate config-embedded nodes (condition_node, extra_nodes)
402+
if let Some(ref query_str) = self.query {
403+
if let Ok(config) = serde_json::from_str::<serde_json::Value>(query_str) {
404+
if let Some(cond) = config.get("condition_node") {
405+
if let Ok(cond_node) = serde_json::from_value::<Durofut>(cond.clone()) {
406+
cond_node.validate_recursive()?;
407+
} else if cond.is_string()
408+
&& (self.node_type == "IF" || self.node_type == "LOOP")
409+
{
410+
return Err(format!(
411+
"condition_node in {} must be a Durofut object, not a string ID",
412+
self.node_type
413+
));
414+
}
415+
}
416+
if let Some(extras) = config.get("extra_nodes").and_then(|e| e.as_array()) {
417+
for (i, extra) in extras.iter().enumerate() {
418+
if let Ok(extra_node) = serde_json::from_value::<Durofut>(extra.clone()) {
419+
extra_node.validate_recursive()?;
420+
} else {
421+
return Err(format!(
422+
"extra_nodes[{}] in {} must be a Durofut object",
423+
i, self.node_type
424+
));
425+
}
426+
}
427+
}
428+
}
429+
}
430+
Ok(())
431+
}
348432
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Test: Explain on plain SQL input (auto-wrap)
2+
-- Expected: df.explain('SELECT 1') produces a SQL node visualization
3+
4+
DO $body$
5+
DECLARE
6+
explain_output TEXT;
7+
BEGIN
8+
SELECT df.explain('SELECT 1') INTO explain_output;
9+
10+
IF explain_output IS NULL OR explain_output = '' THEN
11+
RAISE EXCEPTION 'TEST FAILED: explain returned empty output';
12+
END IF;
13+
14+
IF explain_output NOT LIKE '%SQL:%' OR explain_output NOT LIKE '%SELECT 1%' THEN
15+
RAISE EXCEPTION 'TEST FAILED: explain should show SQL: SELECT 1, got: %', explain_output;
16+
END IF;
17+
18+
RAISE NOTICE 'TEST PASSED: explain plain SQL';
19+
END $body$;
20+
21+
SELECT 'TEST PASSED' AS result;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Test: Reject invalid Durofut JSON node_type
2+
-- Expected: df.start('{"node_type":"NOT_A_NODE"}') raises an error
3+
4+
DO $body$
5+
BEGIN
6+
BEGIN
7+
PERFORM df.start('{"node_type":"NOT_A_NODE"}');
8+
RAISE EXCEPTION 'TEST FAILED: df.start should have rejected invalid node_type';
9+
EXCEPTION WHEN OTHERS THEN
10+
-- ok
11+
RAISE NOTICE 'Caught expected error: %', SQLERRM;
12+
END;
13+
14+
BEGIN
15+
PERFORM df.explain('{"node_type":"NOT_A_NODE"}');
16+
-- explain returns a string; it should contain our error text, not crash.
17+
-- If it returned empty, that would be suspicious.
18+
EXCEPTION WHEN OTHERS THEN
19+
RAISE EXCEPTION 'TEST FAILED: df.explain should not raise on invalid node_type';
20+
END;
21+
22+
RAISE NOTICE 'TEST PASSED: invalid node_type handling';
23+
END $body$;
24+
25+
SELECT 'TEST PASSED' AS result;

0 commit comments

Comments
 (0)