feat(nakui-ui): validación cross-field del EntityRef (existence en store)

parse_field_value(EntityRef) sólo validaba forma (UUID parseable).
Un UUID válido pero inexistente pasaba al log/store, dejando
dangling references. Ahora validamos también existencia contra la
entity declarada en FieldSpec.ref_entity.

- Nuevo helper validate_entity_refs<S: Store>(store, refs):
  fail-fast loop sobre (label, target_entity, uuid) tuples; primer
  record ausente → error con label legible + UUID corto.
- commit_seed: durante el parse loop encolamos cada EntityRef +
  ref_entity + UUID parseado. Después del loop, una sola toma del
  store lock valida todos. Falla early: ningún log entry.
- Cobertura: SEED + EDIT. Morphism inputs ya cubierto por
  Executor::compute (load + EntityMissing) — documentado en el doc
  del helper.

5 tests nuevos del helper: happy path, fail-fast con detalles,
label en msg, lista vacía, distingue target entity.

45 tests verdes en nakui-ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 22:23:20 +00:00
parent f0c0a71860
commit ffdfa6f8d7
2 changed files with 181 additions and 0 deletions
+65
View File
@@ -6,6 +6,71 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 2026-05-09
### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
sólo validaba **forma** (UUID parseable + trim de whitespace) — un
UUID válido pero inexistente en el store pasaba silenciosamente al
log/store, dejando dangling references. Ahora validamos también
**existencia** contra la entity declarada en `FieldSpec.ref_entity`.
Cambios:
- **Nuevo helper `validate_entity_refs<S: Store>(store, refs)`**:
- `refs: &[(label, target_entity, uuid)]`.
- Loop fail-fast: primer record ausente → error con label
legible + UUID corto + target entity en el msg
`"campo 'Stock': record abc12345 de 'Stock' no existe en el store"`.
- Pure (toma `&S: Store`), totalmente testable sin GPUI.
- **Wireup en `commit_seed`**:
- Durante el parse loop, cuando un field es EntityRef + tiene
`ref_entity` declarado + value parseado a UUID, lo encolamos
en `entity_refs: Vec<(String, String, Uuid)>`.
- Después del parse loop (antes del seed/edit branch), si
`entity_refs` no está vacío, una sola toma del store lock
para validar todos via el helper.
- Falla early: ningún log entry, ningún apply.
- **Cobertura**:
- SEED path: alta nueva con EntityRef → validamos antes de
`Seed { data }`.
- EDIT path: edit con EntityRef → validamos antes de calcular
delta. Una optional empty (que iría a clear) no cuenta como
EntityRef (raw vacío skipea el push).
- Morphism inputs: NO se duplica acá. `Executor::compute` ya
valida cada input via `store.load(...).ok_or(EntityMissing)`
antes de correr el script Rhai. Documentado en el doc del
helper.
5 tests nuevos:
- `validate_entity_refs_passes_when_all_records_exist` — happy path.
- `validate_entity_refs_fails_on_first_missing` — fail-fast con
msg que incluye entity + UUID corto.
- `validate_entity_refs_uses_label_not_entity_in_msg` — el label
legible (ej: "Stock origen") aparece en el error, no la entity
desnuda.
- `validate_entity_refs_empty_list_is_ok` — lista vacía es Ok.
- `validate_entity_refs_distinguishes_target_from_other_entities`
un UUID que existe bajo Customer pero NO Stock falla la
validación contra Stock.
45 tests verdes en nakui-ui (+5). Workspace build verde.
Comportamiento esperado:
- **Selector clickable es happy path**: el dropdown sólo lista
records existentes, así que clickearlo nunca debería disparar
el error. Sólo dispara con paste manual de UUID que no existe
o records borrados después de la selección (timing race).
- **Optional empty no se valida**: si el field es EntityRef
optional y el form lo deja vacío, lo manejamos como "no value"
(skipea el push a `entity_refs`); la lógica de Clear se
encarga del resto.
- **Lock contention**: una sola toma del store lock por
`commit_seed`, no una por field. La validación es O(refs) reads.
Pendientes restantes:
- **Validación KCL del record post-edit** antes de emitir Set/Clear
(hoy `ui.edit_record` no pasa por `Executor::compute`).
- **EntityRef cross-module** (referenciar records de OTRO módulo
por nombre, no sólo por entity local).
### feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío ### feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
Cierra el último pendiente de UX del round. El edit no podía Cierra el último pendiente de UX del round. El edit no podía
"borrar" un value vaciando el input — empty optional fields "borrar" un value vaciando el input — empty optional fields
+116
View File
@@ -705,6 +705,10 @@ impl MetaUi {
// current store value tiene algo en ese key). En el SEED path // current store value tiene algo en ese key). En el SEED path
// simplemente no se incluyen, igual que antes. // simplemente no se incluyen, igual que antes.
let mut to_clear: Vec<String> = Vec::new(); let mut to_clear: Vec<String> = Vec::new();
// EntityRef references a chequear existencia DESPUÉS del parse
// loop (necesitan lock del store; lo tomamos una sola vez).
// Tuple: (label legible, target entity, parsed UUID).
let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
for f in &spec_fields { for f in &spec_fields {
let raw = self let raw = self
.form_inputs .form_inputs
@@ -720,8 +724,28 @@ impl MetaUi {
} }
let value = parse_field_value(f.kind, &raw) let value = parse_field_value(f.kind, &raw)
.map_err(|e| format!("campo '{}': {e}", f.label))?; .map_err(|e| format!("campo '{}': {e}", f.label))?;
// Si el field es EntityRef y declara ref_entity, encolamos
// el (label, target, uuid) para validar existence en lote.
// El UUID ya está bien-formado (parse_field_value lo
// validó); ahora chequeamos que el record exista.
if f.kind == FieldKind::EntityRef {
if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
let id = Uuid::parse_str(uuid_str)
.expect("parse_field_value validated UUID");
entity_refs.push((f.label.clone(), target.clone(), id));
}
}
obj.insert(f.name.clone(), value); obj.insert(f.name.clone(), value);
} }
// Validar EntityRefs contra el store actual. Una sola toma del
// lock para todas las refs.
if !entity_refs.is_empty() {
let store = self
.store
.lock()
.map_err(|_| "store mutex envenenado".to_string())?;
validate_entity_refs(&*store, &entity_refs)?;
}
// Ramificación: si `editing` está set para esta entity, es un // Ramificación: si `editing` está set para esta entity, es un
// edit de un record existente — emitimos Morphism con un // edit de un record existente — emitimos Morphism con un
// FieldOp::Set por cada campo del form (sobreescribe). Si no, // FieldOp::Set por cada campo del form (sobreescribe). Si no,
@@ -941,6 +965,33 @@ fn maybe_compact_log(
))) )))
} }
/// Valida que cada UUID en `refs` apunte a un record que realmente
/// existe en el store bajo la entity esperada. Devuelve el primer
/// error encontrado (fail-fast).
///
/// `refs` es una lista de `(label, target_entity, uuid)`. El label
/// va al error message, así que conviene que sea legible (ej:
/// `FieldSpec.label` en lugar de `FieldSpec.name`).
///
/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism
/// no necesitan este check porque `Executor::compute` ya valida cada
/// input via `store.load(...).ok_or(EntityMissing)` antes de correr
/// el script Rhai.
fn validate_entity_refs<S: Store>(
store: &S,
refs: &[(String, String, Uuid)],
) -> Result<(), String> {
for (label, target, id) in refs {
if store.load(target, *id).is_none() {
return Err(format!(
"campo '{label}': record {} de '{target}' no existe en el store",
short_uuid(id)
));
}
}
Ok(())
}
/// Decide cuáles fields del `to_clear` candidate list ameritan /// Decide cuáles fields del `to_clear` candidate list ameritan
/// realmente un `FieldOp::Clear`: sólo los que existen en el current /// realmente un `FieldOp::Clear`: sólo los que existen en el current
/// con un valor non-null. Para fields ausentes o ya null, Clear es /// con un valor non-null. Para fields ausentes o ya null, Clear es
@@ -2059,6 +2110,71 @@ mod tests {
let _ = std::fs::remove_file(&snap_path); let _ = std::fs::remove_file(&snap_path);
} }
#[test]
fn validate_entity_refs_passes_when_all_records_exist() {
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
let caja_id = Uuid::new_v4();
store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
store.seed("Caja", caja_id, json!({"name": "Principal"}));
let refs = vec![
("Stock".into(), "Stock".into(), stock_id),
("Caja".into(), "Caja".into(), caja_id),
];
assert!(validate_entity_refs(&store, &refs).is_ok());
}
#[test]
fn validate_entity_refs_fails_on_first_missing() {
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
store.seed("Stock", stock_id, json!({"sku_id": "abc"}));
let missing_caja = Uuid::new_v4();
let refs = vec![
("Stock".into(), "Stock".into(), stock_id),
("Caja".into(), "Caja".into(), missing_caja),
];
let err = validate_entity_refs(&store, &refs).unwrap_err();
assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}");
assert!(
err.contains(&short_uuid(&missing_caja)),
"msg debe incluir el UUID corto: {err}"
);
}
#[test]
fn validate_entity_refs_uses_label_not_entity_in_msg() {
// Si el FieldSpec.label es distinto de la entity (ej:
// "Stock origen" en lugar de "Stock"), el error debería usar
// el label legible.
let store = MemoryStore::new();
let id = Uuid::new_v4();
let refs = vec![("Stock origen".into(), "Stock".into(), id)];
let err = validate_entity_refs(&store, &refs).unwrap_err();
assert!(
err.contains("Stock origen"),
"msg debe incluir el label: {err}"
);
}
#[test]
fn validate_entity_refs_empty_list_is_ok() {
let store = MemoryStore::new();
assert!(validate_entity_refs(&store, &[]).is_ok());
}
#[test]
fn validate_entity_refs_distinguishes_target_from_other_entities() {
// Sanity: un UUID que existe bajo entity X pero NO bajo Y
// debería fallar la validación contra Y.
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Customer", id, json!({"name": "Acme"}));
// Mismo UUID, target distinto.
let refs = vec![("Stock".into(), "Stock".into(), id)];
assert!(validate_entity_refs(&store, &refs).is_err());
}
#[test] #[test]
fn clear_fields_skips_absent_and_null() { fn clear_fields_skips_absent_and_null() {
let current = json!({ let current = json!({