feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
Nueva variante semántica del kernel: Clear { path } remueve la key
del record, distinta de Set { value: Null } (que deja la key con
valor literal null). Habilita "limpiar" un field optional vaciando
el input en la UI.
nakui-core:
- delta::FieldOp::Clear + simulate_on + capability_token (mismo
shape que Set: "entity.field").
- MemoryStore::apply_dry_run y apply: Set/Clear comparten
pre-condition (record existe + es objeto). Clear de field
ausente = no-op silencioso.
- SurrealStore: equivalente con `UPDATE ... UNSET <field>`.
- Executor capability check: Set/Clear comparten match.
- Conservation rules NO consideran Clear (sólo Set) — documentado
como morphism-author responsibility.
nakui-ui:
- commit_seed acumula `to_clear: Vec<String>` con optional empties
en lugar de `continue` silencioso.
- EDIT branch: nuevo helper compute_clear_fields filtra a sólo los
fields con current value non-null. Combina Set + Clear ops.
NoChange ahora requiere ambos vacíos. Log entry incluye
`cleared: [...]` sólo si non-empty. Updated.changed cuenta
sets+clears.
Tests: +7 en nakui-core (4 store + 3 delta), +3 en nakui-ui.
Suites: 34/34 nakui-core, 40/40 nakui-ui. Workspace build verde.
E2E del morphism real intacto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,17 @@ pub enum FieldOp {
|
||||
path: FieldPath,
|
||||
value: Value,
|
||||
},
|
||||
/// Remove a single field key from a record. Distinto de `Set { value: Null }`:
|
||||
/// `Clear` borra la clave del map; un load posterior no encuentra el
|
||||
/// campo (`None`/`Value::Null` semantically). `Set Null` por contraste
|
||||
/// deja la clave con valor literal `null`. La distinción importa para
|
||||
/// downstream code que diferencia "ausente" de "presente como null"
|
||||
/// (ej: serialize que `skip_serializing_if = "Option::is_none"`).
|
||||
///
|
||||
/// Capability token: `entity.field` (mismo shape que Set).
|
||||
Clear {
|
||||
path: FieldPath,
|
||||
},
|
||||
Create {
|
||||
entity: String,
|
||||
id: Uuid,
|
||||
@@ -33,12 +44,87 @@ impl FieldOp {
|
||||
pub fn capability_token(&self) -> String {
|
||||
match self {
|
||||
FieldOp::Set { path, .. } => format!("{}.{}", path.entity, path.field),
|
||||
FieldOp::Clear { path } => format!("{}.{}", path.entity, path.field),
|
||||
FieldOp::Create { entity, .. } => entity.clone(),
|
||||
FieldOp::Delete { entity, .. } => entity.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn simulate_clear_removes_field() {
|
||||
let id = Uuid::new_v4();
|
||||
let state = json!({"name": "Acme", "notes": "lorem"});
|
||||
let op = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
let after = simulate_on(&state, "Customer", id, &[op]).unwrap();
|
||||
let map = after.as_object().unwrap();
|
||||
assert!(!map.contains_key("notes"));
|
||||
assert_eq!(map.get("name"), Some(&json!("Acme")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulate_clear_then_set_same_field_keeps_set() {
|
||||
let id = Uuid::new_v4();
|
||||
let state = json!({"name": "Acme", "notes": "lorem"});
|
||||
let ops = vec![
|
||||
FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
},
|
||||
FieldOp::Set {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
value: json!("nuevo"),
|
||||
},
|
||||
];
|
||||
let after = simulate_on(&state, "Customer", id, &ops).unwrap();
|
||||
assert_eq!(after.get("notes"), Some(&json!("nuevo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_capability_token_matches_set_shape() {
|
||||
let id = Uuid::new_v4();
|
||||
let set = FieldOp::Set {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
value: json!("x"),
|
||||
};
|
||||
let clear = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
assert_eq!(set.capability_token(), "Customer.notes");
|
||||
assert_eq!(
|
||||
clear.capability_token(),
|
||||
set.capability_token(),
|
||||
"Clear y Set comparten token shape para el capability check"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply only the ops that target `(entity, id)` to `state` and return the
|
||||
/// new value. Returns `None` if a Delete op removes the target — callers
|
||||
/// should skip post-checks against a deleted entity rather than running
|
||||
@@ -52,6 +138,11 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op
|
||||
map.insert(path.field.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
FieldOp::Clear { path } if path.entity == entity && path.id == id => {
|
||||
if let Some(Value::Object(map)) = s.as_mut() {
|
||||
map.remove(&path.field);
|
||||
}
|
||||
}
|
||||
FieldOp::Create {
|
||||
entity: e,
|
||||
id: i,
|
||||
|
||||
@@ -275,28 +275,35 @@ impl Executor {
|
||||
let declared: HashSet<&str> = spec.writes.iter().map(String::as_str).collect();
|
||||
for op in &ops {
|
||||
let token = match op {
|
||||
FieldOp::Set { path, .. } => match id_to_input.get(&path.id) {
|
||||
Some(binding) if binding.entity == path.entity => {
|
||||
format!("{}.{}", binding.role, path.field)
|
||||
// Set y Clear comparten el mismo token shape: ambos
|
||||
// mutan un field específico de un record tracked.
|
||||
FieldOp::Set { path, .. } | FieldOp::Clear { path } => {
|
||||
match id_to_input.get(&path.id) {
|
||||
Some(binding) if binding.entity == path.entity => {
|
||||
format!("{}.{}", binding.role, path.field)
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ExecError::CapabilityViolation {
|
||||
morphism: morphism_name.to_string(),
|
||||
token: format!(
|
||||
"<entity-mismatch>.{}.{}",
|
||||
path.entity, path.field
|
||||
),
|
||||
declared: spec.writes.clone(),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
return Err(ExecError::CapabilityViolation {
|
||||
morphism: morphism_name.to_string(),
|
||||
token: format!(
|
||||
"<untracked id>.{}.{}",
|
||||
path.entity, path.field
|
||||
),
|
||||
declared: spec.writes.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ExecError::CapabilityViolation {
|
||||
morphism: morphism_name.to_string(),
|
||||
token: format!(
|
||||
"<entity-mismatch>.{}.{}",
|
||||
path.entity, path.field
|
||||
),
|
||||
declared: spec.writes.clone(),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
return Err(ExecError::CapabilityViolation {
|
||||
morphism: morphism_name.to_string(),
|
||||
token: format!("<untracked id>.{}.{}", path.entity, path.field),
|
||||
declared: spec.writes.clone(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
FieldOp::Create { entity, .. } => entity.clone(),
|
||||
FieldOp::Delete { entity, .. } => entity.clone(),
|
||||
};
|
||||
|
||||
@@ -231,7 +231,11 @@ impl Store for MemoryStore {
|
||||
fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> {
|
||||
for op in ops {
|
||||
match op {
|
||||
FieldOp::Set { path, .. } => {
|
||||
FieldOp::Set { path, .. } | FieldOp::Clear { path } => {
|
||||
// Set y Clear comparten la misma pre-condición: el
|
||||
// record padre tiene que existir y ser un objeto.
|
||||
// Clear de un field que no existe en el map es no-op
|
||||
// benigno en apply (no error).
|
||||
match self.records.get(&path.entity).and_then(|m| m.get(&path.id)) {
|
||||
None => {
|
||||
return Err(StoreError::NotFound(path.entity.clone(), path.id));
|
||||
@@ -283,6 +287,21 @@ impl Store for MemoryStore {
|
||||
};
|
||||
map.insert(path.field.clone(), value.clone());
|
||||
}
|
||||
FieldOp::Clear { path } => {
|
||||
let rec = self
|
||||
.records
|
||||
.get_mut(&path.entity)
|
||||
.and_then(|m| m.get_mut(&path.id))
|
||||
.expect("validated by dry_run");
|
||||
let map = match rec {
|
||||
Value::Object(m) => m,
|
||||
_ => unreachable!("dry_run guards against non-object"),
|
||||
};
|
||||
// Clear de un field ausente: no-op silencioso. El
|
||||
// post-state es el mismo (el field no está) y permite
|
||||
// que el caller emita Clear sin hacer load previo.
|
||||
map.remove(&path.field);
|
||||
}
|
||||
FieldOp::Create { entity, id, data } => {
|
||||
self.records
|
||||
.entry(entity.clone())
|
||||
@@ -394,6 +413,89 @@ mod tests {
|
||||
assert!(store.apply_dry_run(&[op]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_clear_removes_field_key() {
|
||||
let mut store = MemoryStore::new();
|
||||
let id = Uuid::new_v4();
|
||||
store.seed(
|
||||
"Customer",
|
||||
id,
|
||||
json!({"id": id.to_string(), "name": "Acme", "notes": "lorem"}),
|
||||
);
|
||||
let op = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
store.apply(&[op]).unwrap();
|
||||
let after = store.load("Customer", id).unwrap();
|
||||
let map = after.as_object().unwrap();
|
||||
assert!(!map.contains_key("notes"), "notes debería estar borrado");
|
||||
assert_eq!(map.get("name"), Some(&json!("Acme")), "otros fields intactos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_clear_on_absent_field_is_noop() {
|
||||
let mut store = MemoryStore::new();
|
||||
let id = Uuid::new_v4();
|
||||
store.seed(
|
||||
"Customer",
|
||||
id,
|
||||
json!({"id": id.to_string(), "name": "Acme"}),
|
||||
);
|
||||
let op = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
// No debería errar: clear de un field ausente es benigno.
|
||||
store.apply(&[op]).unwrap();
|
||||
let after = store.load("Customer", id).unwrap();
|
||||
assert_eq!(
|
||||
after.as_object().unwrap().get("name"),
|
||||
Some(&json!("Acme"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_rejects_clear_on_missing_record() {
|
||||
let store = MemoryStore::new();
|
||||
let id = Uuid::new_v4();
|
||||
let op = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
assert!(matches!(
|
||||
store.apply_dry_run(&[op]),
|
||||
Err(StoreError::NotFound(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_rejects_clear_on_non_object() {
|
||||
let mut store = MemoryStore::new();
|
||||
let id = Uuid::new_v4();
|
||||
store.seed("Customer", id, json!(42)); // not an object
|
||||
let op = FieldOp::Clear {
|
||||
path: FieldPath {
|
||||
entity: "Customer".into(),
|
||||
id,
|
||||
field: "notes".into(),
|
||||
},
|
||||
};
|
||||
assert!(matches!(
|
||||
store.apply_dry_run(&[op]),
|
||||
Err(StoreError::NotAnObject(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_returns_canonical_order_regardless_of_insertion() {
|
||||
let a = Uuid::new_v4();
|
||||
|
||||
@@ -168,7 +168,11 @@ impl Store for SurrealStore {
|
||||
self.runtime.block_on(async {
|
||||
for op in ops {
|
||||
match op {
|
||||
FieldOp::Set { path, .. } => {
|
||||
FieldOp::Set { path, .. } | FieldOp::Clear { path } => {
|
||||
// Set y Clear comparten la misma pre-condición:
|
||||
// el record padre tiene que existir. Clear de
|
||||
// un field inexistente es no-op benigno (UNSET
|
||||
// sobre un field ausente no falla).
|
||||
let exists = self.exists(&path.entity, path.id).await?;
|
||||
if !exists {
|
||||
return Err(StoreError::NotFound(
|
||||
@@ -360,6 +364,24 @@ impl Store for SurrealStore {
|
||||
.and_then(|r| r.check())
|
||||
.map_err(map_err)?;
|
||||
}
|
||||
FieldOp::Clear { path } => {
|
||||
// SurrealQL `UNSET` borra la key. El field name
|
||||
// viene de un FieldSpec validado upstream y
|
||||
// SurrealQL no soporta binding de identifiers
|
||||
// (sólo valores), así que va inline. Si en
|
||||
// el futuro se permite que el field name venga
|
||||
// de un input no-trusted, validar aquí.
|
||||
self.db
|
||||
.query(format!(
|
||||
"UPDATE type::thing($table, $id) UNSET {}",
|
||||
path.field
|
||||
))
|
||||
.bind(("table", path.entity.clone()))
|
||||
.bind(("id", path.id.to_string()))
|
||||
.await
|
||||
.and_then(|r| r.check())
|
||||
.map_err(map_err)?;
|
||||
}
|
||||
FieldOp::Create { entity, id, data } => {
|
||||
let stripped = strip_app_id(data.clone());
|
||||
let map = json_to_map(stripped)?;
|
||||
|
||||
Reference in New Issue
Block a user