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:
@@ -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
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
Reference in New Issue
Block a user