diff --git a/crates/plasm-cml/src/lib.rs b/crates/plasm-cml/src/lib.rs index 2611a59c..2189e403 100644 --- a/crates/plasm-cml/src/lib.rs +++ b/crates/plasm-cml/src/lib.rs @@ -4,6 +4,7 @@ pub mod cml; pub mod error; pub(crate) mod gmail_send_body; pub mod transport; +pub(crate) mod wire_normalize; #[cfg(feature = "evm")] pub mod evm_transport; diff --git a/crates/plasm-cml/src/transport.rs b/crates/plasm-cml/src/transport.rs index f918323c..0118d82b 100644 --- a/crates/plasm-cml/src/transport.rs +++ b/crates/plasm-cml/src/transport.rs @@ -71,6 +71,7 @@ pub enum CompiledOperation { pub fn parse_capability_template( template: &serde_json::Value, ) -> Result { + let template = &crate::wire_normalize::normalize_wire_cml_template(template.clone()); let transport = template .get("transport") .and_then(|v| v.as_str()) @@ -424,3 +425,4 @@ mod tests { ); } } + diff --git a/crates/plasm-cml/src/wire_normalize.rs b/crates/plasm-cml/src/wire_normalize.rs new file mode 100644 index 00000000..b4e6047b --- /dev/null +++ b/crates/plasm-cml/src/wire_normalize.rs @@ -0,0 +1,123 @@ +//! Normalize authoring-wire mapping JSON into tagged CML before deserialization. +//! +//! Catalog `mappings.yaml` may attach optional body/query fields as: +//! ```yaml +//! - - status +//! - name: status +//! type: var +//! if: +//! exists: status +//! ``` +//! That shorthand is not valid tagged [`crate::cml::CmlCond`] JSON (`type: exists`). +//! [`super::transport::parse_capability_template`] normalizes these nodes into +//! explicit `type: if` expressions before serde builds [`crate::cml::CmlExpr`]. + +use serde_json::{json, Value}; + +/// Walk a capability mapping template and expand optional `if: {exists: …}` field specs. +pub fn normalize_wire_cml_template(template: Value) -> Value { + normalize_wire_cml_value(template) +} + +fn normalize_wire_cml_value(value: Value) -> Value { + match value { + Value::Object(mut map) => { + if map.contains_key("type") && map.contains_key("if") { + let cond_raw = map + .remove("if") + .expect("if key present when contains_key true"); + let inner = normalize_wire_cml_value(Value::Object(map)); + return wrap_cml_if(normalize_cml_cond_value(cond_raw), inner); + } + + if map.get("type").and_then(Value::as_str) == Some("object") { + if let Some(Value::Array(fields)) = map.get_mut("fields") { + for entry in fields.iter_mut() { + if let Value::Array(pair) = entry { + if pair.len() == 2 { + pair[1] = normalize_wire_cml_value(pair[1].take()); + } + } + } + } + } + + let mut out = serde_json::Map::new(); + for (k, v) in map { + out.insert(k, normalize_wire_cml_value(v)); + } + Value::Object(out) + } + Value::Array(items) => Value::Array( + items + .into_iter() + .map(normalize_wire_cml_value) + .collect(), + ), + other => other, + } +} + +fn wrap_cml_if(condition: Value, then_expr: Value) -> Value { + json!({ + "type": "if", + "condition": condition, + "then_expr": then_expr, + "else_expr": { "type": "const", "value": null } + }) +} + +fn normalize_cml_cond_value(value: Value) -> Value { + if let Value::Object(map) = &value { + if map.len() == 1 { + if let Some(Value::String(var)) = map.get("exists") { + return json!({ "type": "exists", "var": var }); + } + } + } + normalize_wire_cml_value(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{compile_operation, parse_capability_template, CmlEnv}; + use plasm_core::Value as PlasmValue; + + #[test] + fn optional_var_field_if_exists_shorthand_compiles() { + let v = json!({ + "method": "POST", + "path": [{"type": "literal", "value": "v1"}], + "body": { + "type": "object", + "fields": [ + ["credit_card_account_id", {"type": "var", "name": "credit_card_account_id"}], + ["status", {"type": "var", "name": "status", "if": {"exists": "status"}}] + ] + } + }); + let t = parse_capability_template(&v).unwrap(); + let mut env = CmlEnv::new(); + env.insert( + "credit_card_account_id".into(), + PlasmValue::String("cc".into()), + ); + let compiled = compile_operation(&t, &env).expect("omit optional status"); + let crate::CompiledOperation::Http(req) = compiled else { + panic!("expected http"); + }; + let obj = req.body.as_ref().unwrap().as_object().unwrap(); + assert!(!obj.contains_key("status")); + env.insert("status".into(), PlasmValue::String("PENDING".into())); + let compiled = compile_operation(&t, &env).expect("include status when bound"); + let crate::CompiledOperation::Http(req) = compiled else { + panic!("expected http"); + }; + let obj = req.body.as_ref().unwrap().as_object().unwrap(); + assert_eq!( + obj.get("status"), + Some(&PlasmValue::String("PENDING".into())) + ); + } +} diff --git a/crates/plasm-runtime/src/view_matrix_fixture.rs b/crates/plasm-runtime/src/view_matrix_fixture.rs index 55dee4f5..06a728aa 100644 --- a/crates/plasm-runtime/src/view_matrix_fixture.rs +++ b/crates/plasm-runtime/src/view_matrix_fixture.rs @@ -15,6 +15,7 @@ pub const MATRIX_VIEW_PREFLIGHT_CASES: &[(&str, &str)] = &[ ("lang_triage_context", "LangTriageContext"), ("lang_item_link", "LangItemLink"), ("lang_owner_filter_demo", "LangOwnerFilterDemo"), + ("lang_tag_filter_demo", "LangTagFilterDemo"), ("lang_work_snapshot", "LangWorkSnapshot"), ("lang_work_snapshot_empty", "LangWorkSnapshotEmpty"), ]; diff --git a/crates/plasm-runtime/src/view_preflight.rs b/crates/plasm-runtime/src/view_preflight.rs index 69f93b0b..d8f908e5 100644 --- a/crates/plasm-runtime/src/view_preflight.rs +++ b/crates/plasm-runtime/src/view_preflight.rs @@ -73,7 +73,14 @@ impl ViewNodeRunner for PreflightViewNodeRunner<'_> { node_fields: &ViewNodeFieldMap, ) -> Result { let q = QueryExpr::filtered(cap.domain.as_str(), pred.clone()); - preflight_compile_expr(&plasm_core::Expr::Query(q), self.cgs, self.ambient)?; + preflight_compile_expr(&plasm_core::Expr::Query(q), self.cgs, self.ambient).map_err( + |e| RuntimeError::ConfigurationError { + message: format!( + "view `{}` node `{}` (capability `{}`): {e}", + ctx.view_name, node.id, node.capability + ), + }, + )?; let mut bound_values = IndexMap::with_capacity(node.bind.len()); for (param, bspec) in &node.bind { bound_values.insert( @@ -86,20 +93,27 @@ impl ViewNodeRunner for PreflightViewNodeRunner<'_> { fn run_get_node( &self, - _ctx: &ViewRunContext<'_>, - _node: &plasm_core::schema::ViewNodeSpec, + ctx: &ViewRunContext<'_>, + node: &plasm_core::schema::ViewNodeSpec, cap: &plasm_core::CapabilitySchema, get: &GetExpr, bound: &BTreeMap, ) -> Result { - preflight_compile_expr(&plasm_core::Expr::Get(get.clone()), self.cgs, self.ambient)?; + preflight_compile_expr(&plasm_core::Expr::Get(get.clone()), self.cgs, self.ambient).map_err( + |e| RuntimeError::ConfigurationError { + message: format!( + "view `{}` node `{}` (capability `{}`): {e}", + ctx.view_name, node.id, node.capability + ), + }, + )?; stub_get_result(cap, self.cgs, bound) } fn run_create_node( &self, - _ctx: &ViewRunContext<'_>, - _node: &plasm_core::schema::ViewNodeSpec, + ctx: &ViewRunContext<'_>, + node: &plasm_core::schema::ViewNodeSpec, cap: &plasm_core::CapabilitySchema, create: &plasm_core::CreateExpr, ) -> Result { @@ -107,7 +121,13 @@ impl ViewNodeRunner for PreflightViewNodeRunner<'_> { &plasm_core::Expr::Create(create.clone()), self.cgs, self.ambient, - )?; + ) + .map_err(|e| RuntimeError::ConfigurationError { + message: format!( + "view `{}` node `{}` (capability `{}`): {e}", + ctx.view_name, node.id, node.capability + ), + })?; stub_query_result(cap, self.cgs, &IndexMap::new()) } } diff --git a/crates/plasm-runtime/tests/view_literal_optional_cml_preflight.rs b/crates/plasm-runtime/tests/view_literal_optional_cml_preflight.rs new file mode 100644 index 00000000..0c0fe334 --- /dev/null +++ b/crates/plasm-runtime/tests/view_literal_optional_cml_preflight.rs @@ -0,0 +1,21 @@ +//! Regression: view DAG nodes with literal param binds must compile optional CML +//! `if: {exists: …}` body/query fields (see `plasm-cml::wire_normalize`). + +use plasm_runtime::{ + preflight_view_query, + view_test_support::{matrix_view_query, matrix_views_cgs}, + ViewAmbientContext, +}; + +#[test] +fn matrix_lang_tag_filter_demo_literal_label_bind_preflight() { + let cgs = matrix_views_cgs(); + let query = matrix_view_query("LangTagFilterDemo"); + preflight_view_query( + "lang_tag_filter_demo", + &query, + &cgs, + &ViewAmbientContext::default(), + ) + .unwrap_or_else(|e| panic!("lang_tag_filter_demo preflight: {e}")); +} diff --git a/fixtures/schemas/plasm_language_matrix_views/domain.yaml b/fixtures/schemas/plasm_language_matrix_views/domain.yaml index a57a0901..434fd126 100644 --- a/fixtures/schemas/plasm_language_matrix_views/domain.yaml +++ b/fixtures/schemas/plasm_language_matrix_views/domain.yaml @@ -117,6 +117,18 @@ entities: tags: target: LangTag cardinality: many + LangTagFilterDemo: + id_field: item_id + primary_read: lang_tag_filter_demo_get + description: View-backed tag list with literal label filter (optional CML if-exists conformance). + fields: + item_id: + required: true + value_ref: nv_lang_digest_item_id + tag_count: + required: false + value_ref: nv_wire_int + relations: {} LangItemLink: id_field: item_id primary_read: lang_item_link_get @@ -313,6 +325,10 @@ capabilities: value_ref: nv_langtag_query_item_id required: true role: scope + - name: label + value_ref: nv_lang_tag_label + required: false + role: filter provides: - id - item_id @@ -444,6 +460,30 @@ capabilities: - item_id - owner - matching_item_count + lang_tag_filter_demo_query: + kind: query + entity: LangTagFilterDemo + description: Query tag filter demo by item id (literal label bind in view DAG). + parameters: + - name: item_id + value_ref: nv_lang_digest_scope_item_id + required: true + role: filter + provides: + - item_id + - tag_count + lang_tag_filter_demo_get: + kind: get + entity: LangTagFilterDemo + description: Get tag filter demo by item id. + parameters: + - name: item_id + value_ref: nv_lang_digest_get_item_id + required: true + role: scope + provides: + - item_id + - tag_count langviewer_get: kind: get entity: LangViewer @@ -724,6 +764,30 @@ views: binding: kind: node_single_row node: item_node + lang_tag_filter_demo: + description: Count LangTags for one item with a fixed label filter (literal view bind). + capability: lang_tag_filter_demo_query + entity: LangTagFilterDemo + scope: + - name: item_id + value_ref: nv_lang_digest_scope_item_id + nodes: + - id: tagged_rows + capability: langtag_query + bind: + item_id: + kind: scope + param: item_id + label: + kind: literal + value: urgent + output: + item_id: + kind: scope + param: item_id + tag_count: + kind: node_row_count + node: tagged_rows lang_work_snapshot: description: Parameterless dashboard snapshot with assigned LangItems (MyWorkSnapshot pattern). capability: lang_work_snapshot_query diff --git a/fixtures/schemas/plasm_language_matrix_views/mappings.yaml b/fixtures/schemas/plasm_language_matrix_views/mappings.yaml index 29828b4e..d255fb4b 100644 --- a/fixtures/schemas/plasm_language_matrix_views/mappings.yaml +++ b/fixtures/schemas/plasm_language_matrix_views/mappings.yaml @@ -123,6 +123,11 @@ langtag_query: type: object fields: - ["item_id", { type: var, name: item_id }] + - - label + - name: label + type: var + if: + exists: label langline_query: method: GET @@ -182,6 +187,14 @@ lang_owner_filter_demo_get: transport: view view: lang_owner_filter_demo +lang_tag_filter_demo_query: + transport: view + view: lang_tag_filter_demo + +lang_tag_filter_demo_get: + transport: view + view: lang_tag_filter_demo + langviewer_get: method: GET path: