diff --git a/CHANGELOG.md b/CHANGELOG.md index 973afc3..5f8e8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc - **`df.ensure_durofut(text)`:** removed this undocumented internal helper. The `0.2.5 -> 0.2.6` upgrade replaces its operator callers before dropping it with `RESTRICT`; customer-owned dependent objects must be changed or removed before upgrading. +### Changed + +- **Workflow graph envelope:** IF/LOOP conditions and additional JOIN branches are now first-class opaque children instead of JSON embedded inside the `query` string. This removes the remaining parser-depth limit and repeated escaping for config-nested graphs. The transient `Durofut` JSON representation changes; persisted `df.nodes` rows and in-flight instances are unaffected. Applications must not store this internal envelope for replay across an upgrade or downgrade. + ## [0.2.5] - 2026-07-30 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 020225c..ff62aa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -339,87 +339,20 @@ CREATE OPERATOR @> (FUNCTION = df.loop_prefix_op, RIGHTARG = text); ### Node Insertion -When `df.start()` is called, it recursively inserts all nodes from the nested graph into the database: +When `df.start()` is called, it validates the complete graph and recursively inserts all nodes. +Opaque children are parsed one level at a time, and config children are materialized through the +same helper used by `df.explain()`: ```rust -// src/dsl.rs - df.start() -pub fn start(fut: &str, label: Option<&str>) -> String { - let durofut = Durofut::ensure(fut); - let instance_id = short_id(); - - // Recursively insert all nodes from the nested graph - // Note: No HashSet needed - nested graphs are trees, not DAGs - fn insert_nodes(node: &Durofut, instance_id: &str) -> String { - let node_id = short_id(); // Generate ID at insertion time - - // Recursively insert children FIRST to get their IDs - let left_id = node.left_node.as_ref().map(|n| insert_nodes(n, instance_id)); - let right_id = node.right_node.as_ref().map(|n| insert_nodes(n, instance_id)); - - // Process config JSON to replace embedded Durofuts with IDs - // (for IF condition_node, LOOP condition_node, JOIN3 extra_nodes) - let query_escaped = if let Some(ref query_str) = node.query { - if let Ok(mut config) = serde_json::from_str::(query_str) { - // For IF/LOOP nodes: replace condition_node Durofut with ID - if node.node_type == "IF" || node.node_type == "LOOP" { - if let Some(cond_json) = config.get("condition_node") { - if let Ok(cond_node) = serde_json::from_value::(cond_json.clone()) { - let cond_id = insert_nodes(&cond_node, instance_id); - config["condition_node"] = serde_json::json!(cond_id); - } - } - } - // For JOIN3 nodes: replace extra_nodes Durofuts with IDs - if node.node_type == "JOIN" { - if let Some(extras) = config.get("extra_nodes").and_then(|e| e.as_array()) { - let extra_ids: Vec = extras.iter() - .filter_map(|e| serde_json::from_value::(e.clone()).ok()) - .map(|n| insert_nodes(&n, instance_id)) - .collect(); - if !extra_ids.is_empty() { - config["extra_nodes"] = serde_json::json!(extra_ids); - } - } - } - format!("'{}'", serde_json::to_string(&config).unwrap().replace('\'', "''")) - } else { - format!("'{}'", query_str.replace('\'', "''")) - } - } else { - "NULL".to_string() - }; +fn insert_nodes(node: &Durofut, instance_id: &str) -> Result { + let left_id = insert_optional_child(node.left_node.as_deref(), instance_id)?; + let right_id = insert_optional_child(node.right_node.as_deref(), instance_id)?; - // Insert this node with all fields - Spi::run(&format!( - "INSERT INTO df.nodes - (id, instance_id, node_type, query, result_name, left_node, right_node) - VALUES ('{}', '{}', '{}', {}, {}, {}, {})", - node_id, instance_id, node.node_type, - query_escaped, - escape_option(&node.result_name), - escape_option(&left_id), - escape_option(&right_id) - )); - - node_id // Return the generated ID - } - - let root_node_id = insert_nodes(&durofut, &instance_id); - - // Create instance record with the root node ID - Spi::run(&format!( - "INSERT INTO df.instances (id, label, root_node, status) - VALUES ('{}', {}, '{}', 'pending')", - instance_id, label_sql, root_node_id - )); - - // Capture variables and enqueue to duroxide - let vars = capture_vars(); // SELECT * FROM df.vars - let input = FunctionInput { instance_id, label, vars }; - - start_durable_function(ORCHESTRATION_NAME, &instance_id, &input.to_json()); - - instance_id // Return to user immediately + // condition_node and extra_nodes are first-class Durofut children. The + // persisted query keeps the worker-facing child-ID representation. + let query = node.transform_config_children(|child| insert_nodes(child, instance_id))?; + + insert_node_row(node, query, left_id, right_id, instance_id) } ``` diff --git a/docs/nested-graph-design.md b/docs/nested-graph-design.md index 9fc6505..47193c6 100644 --- a/docs/nested-graph-design.md +++ b/docs/nested-graph-design.md @@ -2,27 +2,19 @@ ## Motivation -The current DSL implementation inserts nodes into `df.nodes` during graph construction (e.g., inside `df.sql()`, `df.join()`, etc.). This creates several problems: +The original DSL implementation inserted nodes into `df.nodes` during graph construction (e.g., inside `df.sql()`, `df.join()`, etc.). This created several problems: 1. **Premature database writes**: Graph construction performs I/O before `df.start()` is called -2. **Orphaned nodes**: Errors during graph construction leave partial state in the database with no `instance_id` -3. **No transaction management**: Nodes inserted before `df.start()` don't participate in transaction rollback -4. **Complex explain mode**: Requires temporary tables and session variables to avoid polluting the database -5. **No optimization opportunities**: Graph cannot be analyzed or transformed before execution -6. **Accidental pollution**: Users experimenting with DSL expressions create database state +2. **Abandoned nodes**: Successfully constructed graphs that are never passed to `df.start()` leave rows with no instance +3. **Complex explain mode**: Requires temporary tables and session variables to avoid polluting the database +4. **No optimization opportunities**: Graph cannot be analyzed or transformed before execution +5. **Accidental pollution**: Users experimenting with DSL expressions create database state -### Example of Current Issues +### Example of the Previous Issue ```sql --- Error creates orphaned node -SELECT df.sql('SELECT 1') ~> df.sql('SYNTAX ERROR'); --- First node inserted to df.nodes with no instance_id, never cleaned up - --- Transaction rollback doesn't help -BEGIN; +-- Constructing a graph without starting it left unowned rows behind. SELECT df.sql('SELECT 1'); -ROLLBACK; --- Node still in df.nodes ``` ## Design Approach: Nested JSON @@ -37,15 +29,37 @@ DSL functions return **self-contained JSON** that embeds the complete subtree, n #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Durofut { pub node_type: String, + pub left_node: Option>, + pub right_node: Option>, + pub condition_node: Option>, + pub extra_nodes: Vec>, pub query: Option, pub result_name: Option, - // Children are embedded, not referenced by ID - // Node IDs are generated during df.start(), not during construction - pub left_node: Option>, - pub right_node: Option>, } ``` +### Serialization Boundary + +`Durofut` is the transient graph-construction envelope returned by DSL functions. Its child fields +hold opaque JSON objects as `RawValue`, so deserializing one node does not recursively deserialize +its whole subtree. Walkers parse each child with a fresh deserializer as they descend. This avoids +serde_json's recursion ceiling while preserving the configured graph depth and node-count limits. + +Conditions for IF/LOOP and additional JOIN branches are first-class `condition_node` and +`extra_nodes` fields. They do not live inside the escaped `query` string, so nested config graphs +grow linearly and use the same traversal path as left and right children. + +The envelope is distinct from durable execution state. During `df.start()`, config children are +materialized as node rows and `df.nodes.query` retains the worker-facing format with child IDs: + +```json +{"condition_node":"a1b2c3d4"} +``` + +Changing the envelope therefore does not change queued or in-flight instances, `FunctionNode`, or +duroxide replay state. Serialized `Durofut` text is an unversioned DSL representation and is not a +cross-version persistence contract. + ### Example Flow ```sql @@ -131,239 +145,11 @@ CREATE TEMP TABLE _dsl_nodes (LIKE df.nodes) ON COMMIT DROP; - PostgreSQL temp table overhead for every DSL session - Nested JSON is simpler and faster -## Implementation Plan - -### Phase 1: Update Type Definitions - -**File**: `src/types.rs` - -1. **Change `Durofut` structure**: - ```rust - pub struct Durofut { - pub node_type: String, - pub query: Option, - pub result_name: Option, - // Embed children directly, not by ID reference - // node_id is removed - IDs generated during df.start() - pub left_node: Option>, - pub right_node: Option>, - } - ``` - -2. **Update serialization**: - - `to_json()` - already works with serde - - `from_json()` - already works with serde - - `is_durofut()` - simplified to just check deserialization - - `ensure()` - no node_id needed - -3. **Remove `insert_node()` method** - no longer needed in DSL functions - -4. **Remove `is_explain_mode()` function** - no longer needed - -5. **Update `ensure()` to not set node_id**: - -### Phase 2: Update DSL Functions - -**File**: [src/dsl.rs](../src/dsl.rs) - -All DSL functions updated to embed children as `Box` instead of storing ID references. No database writes during construction — just JSON composition. - -**Before** (old pattern — `join` as example): -```rust -let durofut = Durofut { - node_id: short_id(), - left_node: Some(a_fut.node_id), // ID reference - right_node: Some(b_fut.node_id), // ID reference - ... -}; -durofut.insert_node(); // Database write -``` - -**After** (new pattern): -```rust -let durofut = Durofut { - node_type: "JOIN".to_string(), - left_node: Some(Box::new(a_fut)), // Embed child - right_node: Some(Box::new(b_fut)), // Embed child - ..Default::default() -}; -durofut.to_json() // No database write, no node_id -``` - -See [src/dsl.rs](../src/dsl.rs) for the full implementation. - -### Phase 3: Update `df.start()` - -**File**: [src/dsl.rs](../src/dsl.rs) - -`link_nodes()` replaced with `insert_nodes()` — a recursive function that: -1. Generates node IDs via `short_id()` during insertion (post-order traversal) -2. Inserts children before parents so their IDs are available -3. Handles config-embedded children (IF condition, JOIN3 extras, LOOP condition) via `transform_config_children()` -4. No `HashSet` needed — single tree traversal - -See `start()` → `insert_nodes()` in [src/dsl.rs](../src/dsl.rs) for the full implementation. - -### Phase 4: Update `df.explain()` - -**File**: [src/explain.rs](../src/explain.rs) - -Drastically simplified — no temp tables or database access needed for DSL expressions: +## Compatibility -1. `explain()` dispatches between instance IDs (8-char hex) and DSL expressions -2. `explain_expression()` parses the nested JSON via `Durofut::ensure()`, then builds an in-memory node map with generated IDs (N1, N2, ...) -3. `collect_nodes()` walks the tree recursively, handling config-embedded children via `transform_config_children()` -4. Visualization uses the existing `build_tree_visualization()` - -**Removed**: `is_explain_mode()` checks, `df._explain_mode` session variable, `_durable_explain_nodes` temp table. - -See [src/explain.rs](../src/explain.rs) for the full implementation. - -### Phase 5: Test Updates - -#### Unit Tests - -**File**: `src/lib.rs` or test modules - -- Update any tests that inspect `df.nodes` before `df.start()` -- Tests should now only verify JSON structure, not database state -- Add tests for nested graph construction - -Example: -```rust -#[pg_test] -fn test_nested_graph_construction() { - let result = Spi::get_one::( - "SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2')" - ).expect("query failed"); - - let graph: Durofut = serde_json::from_str(&result).expect("parse failed"); - assert_eq!(graph.node_type, "THEN"); - assert!(graph.left_node.is_some()); - assert!(graph.right_node.is_some()); -} -``` - -#### E2E Tests - -**Directory**: `tests/e2e/sql/` - -Most E2E tests should work **without changes** because they: -1. Build DSL expression -2. Call `df.start()` -3. Wait for completion -4. Assert results - -**Tests that will need updates**: -- `10_explain.sql` - Must change from `$$...$$` syntax to passing Durofut JSON or plain SQL directly to `df.explain()` -- Any test that queries `df.nodes` before calling `df.start()` -- Tests that verify node count or structure before execution - -**What to verify after changes**: -- All existing E2E tests pass -- Node insertion happens correctly in `df.start()` -- `instance_id` is always set on all nodes -- No orphaned nodes in `df.nodes` after errors - -### Phase 6: Documentation Updates - -#### USER_GUIDE.md - -Update sections on: -- **Graph Construction**: Clarify that DSL functions don't write to database -- **df.explain()**: Update to show it works on DSL expressions directly (no temp tables) -- **Debugging**: Update guidance on inspecting graph JSON - -Example addition: -```markdown -### Understanding Graph Construction - -DSL functions build graph structures in memory without touching the database: - -```sql --- This creates JSON, not database records -SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2'); - --- Only df.start() writes to the database -SELECT df.start(df.sql('SELECT 1') ~> df.sql('SELECT 2')); -``` - -You can inspect the graph structure by examining the JSON: -```sql -SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2'); --- Returns: {"node_type":"THEN",...} -``` -``` +The SQL API and durable execution format are unchanged. `df.start()` still materializes graphs as +flat `df.nodes` rows, and queued or in-flight instances continue to use the same `FunctionNode` +representation. -#### docs/ARCHITECTURE.md - -Update: -- Data flow section to reflect new construction model -- Remove mention of temp tables for explain mode -- Add section on stateless DSL design - -#### README.md - -Update quick start examples if they reference graph construction details. - -## Implementation Checklist - -All items completed in commit `fdfbd44` and subsequent fixes: - -- [x] Update `Durofut` struct in `types.rs` to use `Box` for children -- [x] Remove `insert_node()` method from `Durofut` -- [x] Update `Durofut::ensure()` to not call `insert_node()` -- [x] Remove `is_explain_mode()` function from `types.rs` -- [x] Update all DSL functions in `dsl.rs` to embed children instead of storing IDs -- [x] Remove `insert_node()` calls from all DSL functions -- [x] Update `df.as_named()` to remove UPDATE query -- [x] Replace `link_nodes()` with `insert_nodes()` in `df.start()` -- [x] Add helper function for config node extraction (`for_each_config_child`, `transform_config_children`) -- [x] Simplify `df.explain()` in `explain.rs` to parse nested JSON -- [x] Remove temp table logic from `explain_expression()` -- [x] Remove `df._explain_mode` session variable usage -- [x] Update unit tests to verify JSON structure -- [x] Run all E2E tests and fix any failures -- [x] Update `USER_GUIDE.md` with new graph construction model -- [x] Update `docs/ARCHITECTURE.md` with stateless design -- [x] Update `README.md` if needed (checked — no stale references) -- [x] Run `cargo fmt` -- [x] Run `cargo clippy --features pg17` and fix warnings -- [x] Run `./scripts/test-unit.sh` -- [x] Run `./scripts/test-e2e-local.sh` - -## Migration Notes - -### Backward Compatibility - -**Breaking changes**: -- Durofut JSON structure changes (children embedded vs. referenced) -- Code relying on inspecting `df.nodes` before `df.start()` will break - -**Non-breaking**: -- All SQL APIs remain the same (`df.sql()`, `df.start()`, etc.) -- Existing workflows continue to work -- Database schema unchanged - -### Rollout Strategy - -Completed — shipped in commit `fdfbd44` on branch `pinodeca/nested-graph` (PR #5). - -## Success Criteria - -✅ All unit tests pass -✅ All E2E tests pass -✅ No database writes during DSL construction -✅ No orphaned nodes in `df.nodes` -✅ `df.explain()` works on DSL expressions -✅ No clippy warnings or format issues -✅ Documentation updated and clear - -## Future Enhancements - -Once this is in place, we can: -- **Graph optimization**: Dead code elimination, common subexpression elimination -- **Validation**: Check for errors before execution (invalid SQL syntax, missing variables) -- **Visualization**: Web UI for graph structure -- **Compilation**: Pre-compile graphs to optimized execution plans -- **Debugging**: Step through graph execution with breakpoints +The serialized `Durofut` envelope is an internal, unversioned DSL representation. Its shape may +change between releases, so applications should not persist it as a cross-version workflow format. diff --git a/src/dsl.rs b/src/dsl.rs index 81f0680..aaf1afb 100644 --- a/src/dsl.rs +++ b/src/dsl.rs @@ -324,21 +324,12 @@ pub fn wait_for_schedule(cron_expr: &str) -> String { #[pg_extern(name = "loop", schema = "df")] pub fn loop_fn(body: &str, condition: default!(Option<&str>, "NULL")) -> String { let body_fut = Durofut::ensure(body); - - let query = if let Some(cond) = condition { - let cond_fut = Durofut::ensure(cond); - let config = serde_json::json!({ - "condition_node": cond_fut - }); - Some(config.to_string()) - } else { - None - }; + let condition_node = condition.map(|cond| Durofut::ensure(cond).into_raw()); Durofut { node_type: "LOOP".to_string(), left_node: Some(body_fut.into_raw()), - query, + condition_node, ..Default::default() } .to_json() @@ -384,15 +375,11 @@ pub fn if_fn(condition: &str, then_branch: &str, else_branch: &str) -> String { let then_fut = Durofut::ensure(then_branch); let else_fut = Durofut::ensure(else_branch); - let config = serde_json::json!({ - "condition_node": condition_fut - }); - Durofut { node_type: "IF".to_string(), left_node: Some(then_fut.into_raw()), right_node: Some(else_fut.into_raw()), - query: Some(config.to_string()), + condition_node: Some(condition_fut.into_raw()), ..Default::default() } .to_json() @@ -445,15 +432,11 @@ pub fn join3(a: &str, b: &str, c: &str) -> String { let b_fut = Durofut::ensure(b); let c_fut = Durofut::ensure(c); - let config = serde_json::json!({ - "extra_nodes": [c_fut] - }); - Durofut { node_type: "JOIN".to_string(), left_node: Some(a_fut.into_raw()), right_node: Some(b_fut.into_raw()), - query: Some(config.to_string()), + extra_nodes: vec![c_fut.into_raw()], ..Default::default() } .to_json() diff --git a/src/lib.rs b/src/lib.rs index 01dd9e1..e095eef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1102,18 +1102,14 @@ mod tests { assert!( fut.right_node.is_none(), "right_node should be None for LOOP" - ); // condition in config now - assert!( - fut.query.is_some(), - "should have config with condition_node" - ); // has config with condition_node - - // Verify condition is embedded in config - let config: serde_json::Value = serde_json::from_str(fut.query.as_ref().unwrap()).unwrap(); - assert!( - config.get("condition_node").is_some(), - "config should have condition_node" ); + let condition = Durofut::child_from_raw(fut.condition_node.as_ref().unwrap()).unwrap(); + assert_eq!(condition.node_type, "SQL"); + assert_eq!( + condition.query.as_deref(), + Some("SELECT count(*) > 0 FROM queue") + ); + assert!(fut.query.is_none()); } #[pg_test] @@ -1147,9 +1143,7 @@ mod tests { } #[pg_test] - fn test_if_condition_embedded_in_config() { - // Verify that if_fn embeds condition as a nested Durofut in the config JSON, - // not as a string ID reference. + fn test_if_condition_is_first_class_child() { let condition = crate::dsl::sql("SELECT count(*) > 0 FROM tasks"); let then_branch = crate::dsl::sql("SELECT 'yes'"); let else_branch = crate::dsl::sql("SELECT 'no'"); @@ -1160,19 +1154,13 @@ mod tests { assert!(fut.left_node.is_some(), "then branch should be left_node"); assert!(fut.right_node.is_some(), "else branch should be right_node"); - // Parse the config to verify condition_node structure - let config: serde_json::Value = serde_json::from_str(fut.query.as_ref().unwrap()).unwrap(); - let cond_node = config - .get("condition_node") - .expect("config should have condition_node"); - - // condition_node must be a nested Durofut object, not a string ID - assert!( - cond_node.is_object(), - "condition_node should be an object, not a string" + let cond_node = Durofut::child_from_raw(fut.condition_node.as_ref().unwrap()).unwrap(); + assert_eq!(cond_node.node_type, "SQL"); + assert_eq!( + cond_node.query.as_deref(), + Some("SELECT count(*) > 0 FROM tasks") ); - assert_eq!(cond_node["node_type"], "SQL"); - assert_eq!(cond_node["query"], "SELECT count(*) > 0 FROM tasks"); + assert!(fut.query.is_none()); // Verify it round-trips through validation assert!( @@ -1182,9 +1170,29 @@ mod tests { } #[pg_test] - fn test_join3_extra_nodes_embedded_in_config() { - // Verify that join3 embeds the third branch as a nested Durofut in extra_nodes, - // not as a string ID reference. + fn test_nested_if_condition_envelope_grows_linearly() { + let branch = crate::dsl::sql("SELECT 1"); + let mut graph = crate::dsl::sql("SELECT true"); + let mut sizes = Vec::new(); + + for _ in 0..20 { + graph = crate::dsl::if_fn(&graph, &branch, &branch); + sizes.push(graph.len()); + } + + let increments: Vec = sizes.windows(2).map(|pair| pair[1] - pair[0]).collect(); + assert!( + increments.windows(2).all(|pair| pair[0] == pair[1]), + "first-class condition children should add constant envelope overhead: {increments:?}" + ); + + let fut = Durofut::try_from_json(&graph).expect("final graph should deserialize"); + fut.validate_recursive() + .expect("final graph should preserve every nested condition"); + } + + #[pg_test] + fn test_join3_extra_nodes_are_first_class_children() { let a = crate::dsl::sql("SELECT 1"); let b = crate::dsl::sql("SELECT 2"); let c = crate::dsl::sql("SELECT 3"); @@ -1198,23 +1206,11 @@ mod tests { "second branch should be right_node" ); - // Parse the config to verify extra_nodes structure - let config: serde_json::Value = serde_json::from_str(fut.query.as_ref().unwrap()).unwrap(); - let extras = config - .get("extra_nodes") - .and_then(|e| e.as_array()) - .expect("config should have extra_nodes array"); - - assert_eq!(extras.len(), 1, "join3 should have exactly 1 extra node"); - - // extra_nodes[0] must be a nested Durofut object, not a string ID - let extra = &extras[0]; - assert!( - extra.is_object(), - "extra_nodes entry should be an object, not a string" - ); - assert_eq!(extra["node_type"], "SQL"); - assert_eq!(extra["query"], "SELECT 3"); + assert_eq!(fut.extra_nodes.len(), 1); + let extra = Durofut::child_from_raw(&fut.extra_nodes[0]).unwrap(); + assert_eq!(extra.node_type, "SQL"); + assert_eq!(extra.query.as_deref(), Some("SELECT 3")); + assert!(fut.query.is_none()); // Verify it round-trips through validation assert!( @@ -2741,7 +2737,10 @@ mod tests { } .into_raw(), ), - query: Some(r#"{"condition_node": {"foo": "bar"}}"#.to_string()), + condition_node: Some( + serde_json::from_str::>(r#"{"foo":"bar"}"#) + .unwrap(), + ), ..Default::default() }; let result = durofut.validate_recursive(); @@ -2757,49 +2756,13 @@ mod tests { #[pg_test] fn test_validate_rejects_condition_node_number() { - // condition_node as a number should be rejected - let durofut = Durofut { - node_type: "LOOP".to_string(), - left_node: Some( - Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - } - .into_raw(), - ), - query: Some(r#"{"condition_node": 42}"#.to_string()), - ..Default::default() - }; - let result = durofut.validate_recursive(); + let result = Durofut::try_from_json(r#"{"node_type":"LOOP","condition_node":42}"#); assert!(result.is_err(), "Should reject numeric condition_node"); } #[pg_test] fn test_validate_rejects_condition_node_string_id() { - // condition_node as a string ID (old format) should be rejected - let durofut = Durofut { - node_type: "IF".to_string(), - left_node: Some( - Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'then'".to_string()), - ..Default::default() - } - .into_raw(), - ), - right_node: Some( - Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 'else'".to_string()), - ..Default::default() - } - .into_raw(), - ), - query: Some(r#"{"condition_node": "a1b2c3d4"}"#.to_string()), - ..Default::default() - }; - let result = durofut.validate_recursive(); + let result = Durofut::try_from_json(r#"{"node_type":"IF","condition_node":"a1b2c3d4"}"#); assert!(result.is_err(), "Should reject string ID condition_node"); } @@ -2869,7 +2832,10 @@ mod tests { } .into_raw(), ), - query: Some(r#"{"extra_nodes": [{"not": "a durofut"}]}"#.to_string()), + extra_nodes: vec![serde_json::from_str::>( + r#"{"not":"a durofut"}"#, + ) + .unwrap()], ..Default::default() }; let result = durofut.validate_recursive(); @@ -2927,7 +2893,6 @@ mod tests { query: Some("SELECT true".to_string()), ..Default::default() }; - let config = serde_json::json!({ "condition_node": condition }); let durofut = Durofut { node_type: "IF".to_string(), left_node: Some( @@ -2946,7 +2911,7 @@ mod tests { } .into_raw(), ), - query: Some(config.to_string()), + condition_node: Some(condition.into_raw()), ..Default::default() }; assert!( @@ -3192,15 +3157,13 @@ mod tests { ..Default::default() }; - let sql_value = serde_json::to_value(&sql_node).unwrap(); - let extra_nodes: Vec = vec![sql_value; MAX_GRAPH_NODES]; - let config = serde_json::json!({ "extra_nodes": extra_nodes }); + let extra_node = sql_node.clone().into_raw(); let join_node = Durofut { node_type: "JOIN".to_string(), left_node: Some(sql_node.clone().into_raw()), right_node: Some(sql_node.into_raw()), - query: Some(config.to_string()), + extra_nodes: vec![extra_node; MAX_GRAPH_NODES], ..Default::default() }; @@ -3370,9 +3333,7 @@ mod tests { #[pg_test] fn test_malformed_loop_condition_detected_at_validate() { - // A LOOP node whose condition_node is a plain string (not a Durofut object) - // should be rejected by validate_recursive because for_each_config_child - // requires condition_node to deserialize as a valid Durofut. + // A LOOP condition object without a node_type is rejected by validation. let node = Durofut { node_type: "LOOP".to_string(), left_node: Some( @@ -3383,36 +3344,17 @@ mod tests { } .into_raw(), ), - // Malformed config: valid JSON but condition_node is a string, not a Durofut object. - query: Some(r#"{"condition_node": "nonexist"}"#.to_string()), + condition_node: Some( + serde_json::from_str::>(r#"{"id":"nonexist"}"#) + .unwrap(), + ), ..Default::default() }; - // Validate should fail because condition_node is not a valid Durofut object let err = node.validate_recursive().unwrap_err(); assert!( err.contains("condition_node"), "Error should mention condition_node, got: {err}" ); - - // But if the config is totally not JSON, for_each_config_child skips it - // (it's treated as a plain query string, not a config object). - let non_json_node = Durofut { - node_type: "LOOP".to_string(), - left_node: Some( - Durofut { - node_type: "SQL".to_string(), - query: Some("SELECT 1".to_string()), - ..Default::default() - } - .into_raw(), - ), - query: Some("this is not json at all!!!".to_string()), - ..Default::default() - }; - assert!( - non_json_node.validate_recursive().is_ok(), - "LOOP with non-JSON config passes DSL validation (caught at execution time)" - ); } } diff --git a/src/types.rs b/src/types.rs index 7b2b7cb..88028c4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1186,6 +1186,42 @@ where Ok(value) } +fn deserialize_raw_objects<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let values = Vec::>::deserialize(deserializer)?; + if values + .iter() + .any(|raw| !raw.get().trim_start().starts_with('{')) + { + return Err(serde::de::Error::custom( + "extra_nodes entries must be Durofut JSON objects", + )); + } + Ok(values) +} + +fn deserialize_condition_node<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::>::deserialize(deserializer)?; + if value + .as_ref() + .is_some_and(|raw| !raw.get().trim_start().starts_with('{')) + { + return Err(serde::de::Error::custom( + "condition_node must be a Durofut JSON object", + )); + } + Ok(value) +} + /// The Durofut type represents a "durable future" - a reference to a node in the function graph. /// Children are embedded as opaque JSON objects, not stored as ID references. Keeping them as /// `RawValue` lets each graph level deserialize independently without serde_json's recursion limit. @@ -1205,6 +1241,18 @@ pub struct Durofut { default )] pub right_node: Option>, + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_condition_node", + default + )] + pub condition_node: Option>, + #[serde( + skip_serializing_if = "Vec::is_empty", + deserialize_with = "deserialize_raw_objects", + default + )] + pub extra_nodes: Vec>, #[serde(skip_serializing_if = "Option::is_none")] pub query: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1370,6 +1418,67 @@ impl Durofut { self.validate_recursive_inner(0, &mut node_count) } + fn reject_embedded_config_children(&self) -> Result<(), String> { + let Some(query) = self.query.as_ref() else { + return Ok(()); + }; + let Ok(config) = serde_json::from_str::(query) else { + return Ok(()); + }; + + let legacy_field = match self.node_type.as_str() { + "IF" | "LOOP" if config.get("condition_node").is_some() => Some("condition_node"), + "JOIN" if config.get("extra_nodes").is_some() => Some("extra_nodes"), + _ => None, + }; + if let Some(field) = legacy_field { + return Err(format!( + "{} in {} must be a first-class Durofut field, not embedded in query", + field, self.node_type + )); + } + + Ok(()) + } + + fn validate_config_children(&self) -> Result<(), String> { + let supports_condition = self.node_type == "IF" || self.node_type == "LOOP"; + if self.condition_node.is_some() && !supports_condition { + return Err(format!( + "condition_node is not valid for {} nodes", + self.node_type + )); + } + if !self.extra_nodes.is_empty() && self.node_type != "JOIN" { + return Err(format!( + "extra_nodes is not valid for {} nodes", + self.node_type + )); + } + + self.reject_embedded_config_children()?; + + let has_config_children = (supports_condition && self.condition_node.is_some()) + || (self.node_type == "JOIN" && !self.extra_nodes.is_empty()); + let Some(query) = self.query.as_ref().filter(|_| has_config_children) else { + return Ok(()); + }; + let config = serde_json::from_str::(query).map_err(|e| { + format!( + "query in {} must be valid JSON config: {}", + self.node_type, e + ) + })?; + if !config.is_object() { + return Err(format!( + "query in {} must be a JSON config object", + self.node_type + )); + } + + Ok(()) + } + fn validate_recursive_inner(&self, depth: usize, node_count: &mut usize) -> Result<(), String> { *node_count += 1; if *node_count > MAX_GRAPH_NODES { @@ -1393,21 +1502,20 @@ impl Durofut { VALID_NODE_TYPES.join(", ") )); } + self.validate_config_children()?; if let Some(ref left) = self.left_node { Self::child_from_raw(left)?.validate_recursive_inner(depth + 1, node_count)?; } if let Some(ref right) = self.right_node { Self::child_from_raw(right)?.validate_recursive_inner(depth + 1, node_count)?; } - // Validate config-embedded nodes (condition_node, extra_nodes) + // Validate config children (condition_node, extra_nodes) let d = depth + 1; self.for_each_config_child(|child| child.validate_recursive_inner(d, node_count))?; Ok(()) } - /// Extract config-embedded Durofut children from the `query` JSON field and apply - /// a callback to each. This is the single source of truth for walking `condition_node` - /// (in IF/LOOP nodes) and `extra_nodes` (in JOIN nodes). + /// Parse each opaque config child and apply a callback to it. /// /// The callback receives each embedded child and returns `Result<(), String>`. /// Parsing failures are always treated as errors — a `condition_node` or `extra_nodes` @@ -1416,24 +1524,13 @@ impl Durofut { where F: FnMut(&Durofut) -> Result<(), String>, { - let query_str = match self.query.as_ref() { - Some(s) => s, - None => return Ok(()), - }; - let config = match serde_json::from_str::(query_str) { - Ok(c) => c, - Err(_) => return Ok(()), // not JSON config, nothing to walk - }; - // IF/LOOP nodes: condition_node if self.node_type == "IF" || self.node_type == "LOOP" { - if let Some(cond) = config.get("condition_node") { - let cond_node = serde_json::from_value::(cond.clone()).map_err(|e| { + if let Some(cond) = self.condition_node.as_ref() { + let cond_node = Self::child_from_raw(cond).map_err(|e| { format!( - "condition_node in {} must be a valid Durofut object, got {}: {}", - self.node_type, - summarize_json_type(cond), - e + "condition_node in {} must be a valid Durofut object: {}", + self.node_type, e ) })?; f(&cond_node)?; @@ -1442,26 +1539,22 @@ impl Durofut { // JOIN nodes: extra_nodes array if self.node_type == "JOIN" { - if let Some(extras) = config.get("extra_nodes").and_then(|e| e.as_array()) { - for (i, extra) in extras.iter().enumerate() { - let extra_node = - serde_json::from_value::(extra.clone()).map_err(|e| { - format!( - "extra_nodes[{}] in {} must be a valid Durofut object: {}", - i, self.node_type, e - ) - })?; - f(&extra_node)?; - } + for (i, extra) in self.extra_nodes.iter().enumerate() { + let extra_node = Self::child_from_raw(extra).map_err(|e| { + format!( + "extra_nodes[{}] in {} must be a valid Durofut object: {}", + i, self.node_type, e + ) + })?; + f(&extra_node)?; } } Ok(()) } - /// Transform config-embedded Durofut children into string IDs via a callback, - /// returning the updated query JSON string. Used by `insert_nodes` and `collect_nodes` - /// to replace nested Durofut objects with generated node IDs. + /// Transform config children into string IDs via a callback, returning the + /// `df.nodes.query` JSON string used by `insert_nodes` and `collect_nodes`. /// /// The callback receives each embedded child and returns the generated ID string. /// Parsing failures are always treated as errors. @@ -1469,24 +1562,37 @@ impl Durofut { where F: FnMut(&Durofut) -> Result, { - let query_str = match self.query.as_ref() { - Some(s) => s, - None => return Ok(None), - }; - let mut config = match serde_json::from_str::(query_str) { - Ok(c) => c, - Err(_) => return Ok(Some(query_str.clone())), // not JSON, pass through as-is + self.validate_config_children()?; + let has_condition = + (self.node_type == "IF" || self.node_type == "LOOP") && self.condition_node.is_some(); + let has_extras = self.node_type == "JOIN" && !self.extra_nodes.is_empty(); + if !has_condition && !has_extras { + return Ok(self.query.clone()); + } + + let mut config = match self.query.as_ref() { + Some(query) => serde_json::from_str::(query).map_err(|e| { + format!( + "query in {} must be valid JSON config: {}", + self.node_type, e + ) + })?, + None => serde_json::json!({}), }; + if !config.is_object() { + return Err(format!( + "query in {} must be a JSON config object", + self.node_type + )); + } // IF/LOOP nodes: condition_node - if self.node_type == "IF" || self.node_type == "LOOP" { - if let Some(cond) = config.get("condition_node") { - let cond_node = serde_json::from_value::(cond.clone()).map_err(|e| { + if has_condition { + if let Some(cond) = self.condition_node.as_ref() { + let cond_node = Self::child_from_raw(cond).map_err(|e| { format!( - "condition_node in {} must be a valid Durofut object, got {}: {}", - self.node_type, - summarize_json_type(cond), - e + "condition_node in {} must be a valid Durofut object: {}", + self.node_type, e ) })?; let cond_id = f(&cond_node)?; @@ -1495,41 +1601,24 @@ impl Durofut { } // JOIN nodes: extra_nodes array - if self.node_type == "JOIN" { - if let Some(extras) = config.get("extra_nodes").and_then(|e| e.as_array()) { - let mut extra_ids: Vec = Vec::new(); - for (i, extra) in extras.iter().enumerate() { - let extra_node = - serde_json::from_value::(extra.clone()).map_err(|e| { - format!( - "extra_nodes[{}] in {} must be a valid Durofut object: {}", - i, self.node_type, e - ) - })?; - extra_ids.push(f(&extra_node)?); - } - if !extra_ids.is_empty() { - config["extra_nodes"] = serde_json::json!(extra_ids); - } + if has_extras { + let mut extra_ids: Vec = Vec::with_capacity(self.extra_nodes.len()); + for (i, extra) in self.extra_nodes.iter().enumerate() { + let extra_node = Self::child_from_raw(extra).map_err(|e| { + format!( + "extra_nodes[{}] in {} must be a valid Durofut object: {}", + i, self.node_type, e + ) + })?; + extra_ids.push(f(&extra_node)?); } + config["extra_nodes"] = serde_json::json!(extra_ids); } Ok(Some(serde_json::to_string(&config).unwrap())) } } -/// Helper to describe a JSON value type for error messages -fn summarize_json_type(v: &serde_json::Value) -> &'static str { - match v { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "a boolean", - serde_json::Value::Number(_) => "a number", - serde_json::Value::String(_) => "a string", - serde_json::Value::Array(_) => "an array", - serde_json::Value::Object(_) => "an object", - } -} - #[cfg(test)] mod tests { use super::*; @@ -2312,17 +2401,189 @@ mod tests { query: Some("SELECT 1".to_string()), ..Default::default() }; - let sql_value = serde_json::to_value(&sql_node).unwrap(); - let extra_nodes: Vec = vec![sql_value; n]; - let config = serde_json::json!({ "extra_nodes": extra_nodes }); + let extra_node = sql_node.clone().into_raw(); Durofut { node_type: "JOIN".to_string(), left_node: Some(sql_node.clone().into_raw()), right_node: Some(sql_node.into_raw()), - query: Some(config.to_string()), + extra_nodes: vec![extra_node; n], + ..Default::default() + } + } + + #[test] + fn test_transform_config_children_preserves_nodes_query_format() { + let condition = Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT true".to_string()), + ..Default::default() + }; + let node = Durofut { + node_type: "IF".to_string(), + condition_node: Some(condition.into_raw()), + ..Default::default() + }; + + let query = node + .transform_config_children(|_| Ok("condition-id".to_string())) + .unwrap() + .unwrap(); + assert_eq!(query, r#"{"condition_node":"condition-id"}"#); + } + + #[test] + fn test_transform_config_children_preserves_existing_query() { + let condition = Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT true".to_string()), + ..Default::default() + }; + let node = Durofut { + node_type: "IF".to_string(), + condition_node: Some(condition.into_raw()), + query: Some(r#"{"a":1}"#.to_string()), + ..Default::default() + }; + + let query = node + .transform_config_children(|_| Ok("condition-id".to_string())) + .unwrap() + .unwrap(); + assert_eq!(query, r#"{"a":1,"condition_node":"condition-id"}"#); + } + + #[test] + fn test_transform_extra_nodes_preserves_nodes_query_format() { + let extra = Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 3".to_string()), + ..Default::default() + }; + let node = Durofut { + node_type: "JOIN".to_string(), + extra_nodes: vec![extra.into_raw()], + ..Default::default() + }; + + let query = node + .transform_config_children(|_| Ok("extra-id".to_string())) + .unwrap() + .unwrap(); + assert_eq!(query, r#"{"extra_nodes":["extra-id"]}"#); + } + + #[test] + fn test_rejects_config_children_embedded_in_query() { + let legacy_if = Durofut { + node_type: "IF".to_string(), + query: Some( + r#"{"condition_node":{"node_type":"SQL","query":"SELECT true"}}"#.to_string(), + ), ..Default::default() + }; + let legacy_join = Durofut { + node_type: "JOIN".to_string(), + query: Some(r#"{"extra_nodes":[{"node_type":"SQL","query":"SELECT 3"}]}"#.to_string()), + ..Default::default() + }; + + let legacy_loop = Durofut { + node_type: "LOOP".to_string(), + query: Some( + r#"{"condition_node":{"node_type":"SQL","query":"SELECT true"}}"#.to_string(), + ), + ..Default::default() + }; + + assert!(legacy_if + .validate_recursive() + .unwrap_err() + .contains("condition_node in IF must be a first-class Durofut field")); + assert!(legacy_join + .validate_recursive() + .unwrap_err() + .contains("extra_nodes in JOIN must be a first-class Durofut field")); + assert!(legacy_loop + .validate_recursive() + .unwrap_err() + .contains("condition_node in LOOP must be a first-class Durofut field")); + assert!(legacy_join + .transform_config_children(|_| Ok("unused".to_string())) + .unwrap_err() + .contains("extra_nodes in JOIN must be a first-class Durofut field")); + } + + #[test] + fn test_config_children_require_json_object_query() { + let condition = Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT true".to_string()), + ..Default::default() + } + .into_raw(); + for query in ["SELECT 1", "42"] { + let node = Durofut { + node_type: "IF".to_string(), + condition_node: Some(condition.clone()), + query: Some(query.to_string()), + ..Default::default() + }; + + assert!(node + .validate_recursive() + .unwrap_err() + .contains("query in IF must be")); + } + } + + #[test] + fn test_config_children_reject_fields_on_wrong_node_types() { + let child = Durofut { + node_type: "SQL".to_string(), + query: Some("SELECT 1".to_string()), + ..Default::default() + } + .into_raw(); + let sql_with_condition = Durofut { + node_type: "SQL".to_string(), + condition_node: Some(child.clone()), + ..Default::default() + }; + let race_with_extras = Durofut { + node_type: "RACE".to_string(), + extra_nodes: vec![child], + ..Default::default() + }; + + assert!(sql_with_condition + .validate_recursive() + .unwrap_err() + .contains("condition_node is not valid for SQL nodes")); + assert!(race_with_extras + .validate_recursive() + .unwrap_err() + .contains("extra_nodes is not valid for RACE nodes")); + } + + #[test] + fn test_extra_nodes_deserialization_rejects_invalid_shapes() { + for json in [ + r#"{"node_type":"JOIN","extra_nodes":["a1b2c3d4"]}"#, + r#"{"node_type":"JOIN","extra_nodes":[42]}"#, + ] { + let error = Durofut::try_from_json(json).unwrap_err(); + assert!( + error.contains("extra_nodes entries must be Durofut JSON objects"), + "should identify the invalid extra_nodes entry: {error}" + ); } + + let non_array = r#"{"node_type":"JOIN","extra_nodes":{"node_type":"SQL"}}"#; + assert!( + Durofut::try_from_json(non_array).is_err(), + "should reject non-array extra_nodes" + ); } #[test] diff --git a/tests/e2e/sql/02_conditionals.sql b/tests/e2e/sql/02_conditionals.sql index 3d536d4..e155ded 100644 --- a/tests/e2e/sql/02_conditionals.sql +++ b/tests/e2e/sql/02_conditionals.sql @@ -128,7 +128,7 @@ BEGIN "node_type": "IF", "left_node": {"node_type": "SQL", "query": "SELECT 1"}, "right_node": {"node_type": "SQL", "query": "SELECT 2"}, - "query": "{\"condition_node\": {\"foo\": \"bar\"}}" + "condition_node": {"foo": "bar"} }'); RAISE EXCEPTION 'TEST FAILED: df.start should have rejected malformed condition_node'; EXCEPTION WHEN OTHERS THEN @@ -147,7 +147,7 @@ BEGIN PERFORM df.start('{ "node_type": "LOOP", "left_node": {"node_type": "SQL", "query": "SELECT 1"}, - "query": "{\"condition_node\": \"a1b2c3d4\"}" + "condition_node": "a1b2c3d4" }'); RAISE EXCEPTION 'TEST FAILED: df.start should have rejected string condition_node'; EXCEPTION WHEN OTHERS THEN @@ -167,7 +167,7 @@ BEGIN "node_type": "IF", "left_node": {"node_type": "SQL", "query": "SELECT 1"}, "right_node": {"node_type": "SQL", "query": "SELECT 2"}, - "query": "{\"condition_node\": 42}" + "condition_node": 42 }'); RAISE EXCEPTION 'TEST FAILED: df.start should have rejected numeric condition_node'; EXCEPTION WHEN OTHERS THEN @@ -191,6 +191,83 @@ BEGIN RAISE NOTICE 'Test 4 PASSED: Valid IF graph produced: %', left(graph, 80); END $body$; +-- Test 5: condition_node embedded in the legacy query format should be rejected before persistence +DO $body$ +DECLARE + nodes_before BIGINT; +BEGIN + SELECT count(*) INTO nodes_before FROM df.nodes; + + BEGIN + PERFORM df.start('{ + "node_type": "IF", + "left_node": {"node_type": "SQL", "query": "SELECT 1"}, + "right_node": {"node_type": "SQL", "query": "SELECT 2"}, + "query": "{\"condition_node\":{\"node_type\":\"SQL\",\"query\":\"SELECT true\"}}" + }'); + RAISE EXCEPTION 'TEST FAILED: df.start should have rejected legacy condition_node format'; + EXCEPTION WHEN OTHERS THEN + IF SQLERRM LIKE '%condition_node in IF must be a first-class Durofut field%' THEN + RAISE NOTICE 'Test 5 PASSED: Caught legacy condition_node format: %', SQLERRM; + ELSE + RAISE EXCEPTION 'TEST FAILED: Wrong error for legacy condition_node format: %', SQLERRM; + END IF; + END; + + IF (SELECT count(*) FROM df.nodes) != nodes_before THEN + RAISE EXCEPTION 'TEST FAILED: legacy condition_node format persisted node rows'; + END IF; +END $body$; + +-- Test 6: extra_nodes string IDs from the old envelope format should be rejected +DO $body$ +BEGIN + BEGIN + PERFORM df.start('{ + "node_type": "JOIN", + "left_node": {"node_type": "SQL", "query": "SELECT 1"}, + "right_node": {"node_type": "SQL", "query": "SELECT 2"}, + "extra_nodes": ["a1b2c3d4"] + }'); + RAISE EXCEPTION 'TEST FAILED: df.start should have rejected string extra_nodes'; + EXCEPTION WHEN OTHERS THEN + IF SQLERRM LIKE '%extra_nodes entries must be Durofut JSON objects%' THEN + RAISE NOTICE 'Test 6 PASSED: Caught string extra_nodes: %', SQLERRM; + ELSE + RAISE EXCEPTION 'TEST FAILED: Wrong error for string extra_nodes: %', SQLERRM; + END IF; + END; +END $body$; + +-- Test 7: invalid config query shape should be rejected before persistence +DO $body$ +DECLARE + nodes_before BIGINT; +BEGIN + SELECT count(*) INTO nodes_before FROM df.nodes; + + BEGIN + PERFORM df.start('{ + "node_type": "IF", + "left_node": {"node_type": "SQL", "query": "SELECT 1"}, + "right_node": {"node_type": "SQL", "query": "SELECT 2"}, + "condition_node": {"node_type": "SQL", "query": "SELECT true"}, + "query": "not-json" + }'); + RAISE EXCEPTION 'TEST FAILED: df.start should have rejected invalid config query'; + EXCEPTION WHEN OTHERS THEN + IF SQLERRM LIKE '%query in IF must be valid JSON config%' THEN + RAISE NOTICE 'Test 7 PASSED: Caught invalid config query: %', SQLERRM; + ELSE + RAISE EXCEPTION 'TEST FAILED: Wrong error for invalid config query: %', SQLERRM; + END IF; + END; + + IF (SELECT count(*) FROM df.nodes) != nodes_before THEN + RAISE EXCEPTION 'TEST FAILED: invalid config query persisted node rows'; + END IF; +END $body$; + -- === Test: 40_if_rows === -- Test 1: if_rows with rows present → then branch executes diff --git a/tests/e2e/sql/09_graph_and_validation.sql b/tests/e2e/sql/09_graph_and_validation.sql index 0ef9c2d..78619e8 100644 --- a/tests/e2e/sql/09_graph_and_validation.sql +++ b/tests/e2e/sql/09_graph_and_validation.sql @@ -216,6 +216,27 @@ BEGIN RAISE NOTICE 'TEST PASSED: ?>/!> composes beyond parser recursion limit'; END $$; +DO $$ +DECLARE + condition_graph TEXT := df.sql('SELECT true'); + graph TEXT; + explanation TEXT; +BEGIN + -- 129 levels intentionally exceed serde_json's default 128-level recursion limit. + FOR i IN 1..129 LOOP + condition_graph := df.seq(condition_graph, 'SELECT true'); + END LOOP; + + graph := df.if(condition_graph, 'SELECT 1', 'SELECT 0'); + explanation := df.explain(graph); + IF explanation LIKE 'Invalid durable function graph:%' + OR pg_catalog.regexp_count(explanation, '→') != 129 THEN + RAISE EXCEPTION 'TEST FAILED: deep condition graph was corrupted: %', explanation; + END IF; + + RAISE NOTICE 'TEST PASSED: config children compose beyond serde recursion limit'; +END $$; + DO $$ DECLARE graph TEXT := df.sql('SELECT 1');