Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

// Re-export the main Workbook and Worksheet types from controller/api
pub use logisheets_controller::api::{
BlockSortOrder, CellInfo, CellRefRange, CfRuleInfo, DependentCell, FillRange, ReproducibleCell,
BlockSortOrder, CellInfo, CellRefRange, CfRuleInfo, DependentCell, FieldValidationVerdict,
FillRange, ReproducibleCell,
SaveFileResult, SheetCoordinate, SheetDimension, Workbook, Worksheet,
};

Expand Down
21 changes: 21 additions & 0 deletions crates/api/src/rpc/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,27 @@ pub fn may_modify_block(
.map_err(ErrorMessage::from)
}

/// Whether writing `proposed` into this cell would break its field's
/// validation rule — see [`CheckFieldValidationParams`].
///
/// The companion to [`may_modify_block`] for `BlockOp::OverrideValidation`:
/// that answers "may this actor write a violating value", this answers
/// "would this value be a violating one". Neither writes anything.
///
/// [`CheckFieldValidationParams`]: crate::rpc::message::CheckFieldValidationParams
pub fn check_field_validation(
mgr: &mut Manager,
id: usize,
sheet_idx: usize,
row: usize,
col: usize,
proposed: String,
) -> Result<logisheets_controller::FieldValidationVerdict, ErrorMessage> {
let wb = mgr.get_mut_workbook(&id).unwrap();
wb.check_field_validation(sheet_idx, row, col, proposed)
.map_err(ErrorMessage::from)
}

/// A block's governance metadata, without its cells. See
/// [`GetBlockModifyInfoParams`].
///
Expand Down
27 changes: 27 additions & 0 deletions crates/api/src/rpc/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::BlockId;
use crate::{
ActionEffect, AppData, AppendixWithCell, BlockActor, BlockDataRow, BlockField, BlockInfo,
BlockModifyInfo, BlockOp, BlockSortOrder, CellCoordinateWithSheet, CellImageInfo, CellInfo,
FieldValidationVerdict,
CellInput, CellPosition, CellRefRange, CfRuleInfo, ChartInfo, ColId, Comment, DependentCell,
DisplayWindow, DisplayWindowWithStartPoint, EditPayload, ErrorMessage, FormulaDisplayInfo,
LinkInfo, MergeCell, ReproducibleCell, RowId, RowInfo, SaveFileResult, ShadowCellInfo,
Expand Down Expand Up @@ -62,6 +63,7 @@ pub enum Message {
GetBlockValues(GetBlockValuesParams),
GetBlockSortOrder(GetBlockSortOrderParams),
MayModifyBlock(MayModifyBlockParams),
CheckFieldValidation(CheckFieldValidationParams),
GetBlockModifyInfo(GetBlockModifyInfoParams),
GetShadowCellId(GetShadowCellIdParams),
GetShadowCellIds(GetShadowCellIdsParams),
Expand Down Expand Up @@ -522,6 +524,27 @@ pub struct MayModifyBlockParams {
pub actor: BlockActor,
}

/// Ask whether a value would break a block field's validation rule, before
/// writing it.
///
/// The pairing with [`MayModifyBlockParams`] is the whole point: a host that
/// wants to enforce `BlockOp::OverrideValidation` needs both halves — is this
/// write a violation, and is this actor allowed to make one.
#[derive(Debug, Clone, TS)]
#[ts(
file_name = "check_field_validation_params.ts",
rename_all = "camelCase"
)]
pub struct CheckFieldValidationParams {
pub sheet_idx: usize,
/// Sheet-absolute coordinates, not block-relative — this is asked from the
/// grid's write path, which speaks in sheet coordinates.
pub row: usize,
pub col: usize,
/// The value the caller is about to write, exactly as the user typed it.
pub proposed: String,
}

/// A block's governance metadata on its own — owner, default policy,
/// per-operation overrides, description — without its cells.
///
Expand Down Expand Up @@ -871,6 +894,10 @@ pub struct WorkbookMethods {
) -> Result<BlockSortOrder, ErrorMessage>,
pub may_modify_block:
fn(params: MayModifyBlockParams, book_id: Option<usize>) -> Result<bool, ErrorMessage>,
pub check_field_validation: fn(
params: CheckFieldValidationParams,
book_id: Option<usize>,
) -> Result<FieldValidationVerdict, ErrorMessage>,
pub get_block_modify_info: fn(
params: GetBlockModifyInfoParams,
book_id: Option<usize>,
Expand Down
236 changes: 236 additions & 0 deletions crates/controller/src/api/field_validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//! Ask, before writing, whether a value would violate a block field's
//! validation rule.
//!
//! The rule itself is already live: a field with a `validation_formula` gets a
//! `ShadowKind::Validation` shadow per record, wired into the dependency graph
//! with `#PLACEHOLDER` bound to the cell it guards, and readers use its value
//! to draw the warning marker. That is a verdict on the value the cell *has*.
//!
//! [`BlockOp::OverrideValidation`](crate::edit_action::BlockOp) needs the
//! verdict on a value the cell does not have yet — the host has to know whether
//! the write it is about to allow is a violating one *before* it happens, or
//! the only way to refuse would be to write and then undo.
//!
//! So this evaluates the shadow's existing AST against a proposed value: swap
//! the value into the container, run the calculator on that one node, put the
//! old value back. Nothing is registered, nothing is dirtied, and no history
//! entry is made — the workbook is unchanged when this returns. Re-using the
//! shadow's AST (rather than re-parsing the template) also means the answer
//! cannot drift from what the marker will say once the write lands.

use std::collections::{HashMap, HashSet};

use gents_derives::TS;
use logisheets_base::{Addr, CellId, CellValue, SheetId, TextId, errors::BasicError};

// NB: do not `use crate::errors::Result` here — the local alias would shadow
// `std::result::Result` and break the serde impls the `TS` derive generates for
// `FieldValidationVerdict` (the same trap `sort_block` documents). Reference it
// fully-qualified in the signature instead.
use crate::{
calc_engine::calculator::{calc_vertex::CalcValue, calc_vertex::Value, calculator::calc},
cell::Cell,
connectors::CalcConnector,
errors::Error,
sid_assigner::ShadowKind,
};

use super::Workbook;

/// What a proposed write would do to a cell's validation rule.
#[derive(Debug, Clone, Default, TS)]
#[ts(file_name = "field_validation_verdict.ts", rename_all = "camelCase")]
pub struct FieldValidationVerdict {
/// `false` when the cell has no validation rule at all — the other two
/// fields are then meaningless and the caller has nothing to gate on.
pub has_rule: bool,
/// `true` when the proposed value fails the rule. Only meaningful when
/// `has_rule`.
pub violates: bool,
/// The rule's raw template, so a refusal can say what was expected.
/// Empty when the field carries no rule.
pub rule: String,
}

/// How a computed validation result is read as a verdict.
///
/// Mirrors `interpretValidation` in `logisheets-core` deliberately: a rule is
/// satisfied when it evaluates truthy, and an error (`#VALUE!`, `#NAME?`, a
/// rule that cannot resolve) counts as a violation rather than as a pass — a
/// rule nobody can evaluate is not a rule anybody has met. A blank result is
/// treated as passing, so an optional field is not flagged for being empty.
fn scalar_violates(v: &Value) -> bool {
match v {
Value::Boolean(b) => !*b,
Value::Number(n) => *n == 0.0,
Value::Error(_) => true,
Value::Blank => false,
Value::Text(_) => false,
}
}

fn violates(value: &CalcValue) -> bool {
match value {
CalcValue::Scalar(v) => scalar_violates(v),
// A rule that yields a range or a union is malformed for this purpose;
// judge it on its first element rather than silently passing, which
// would let a broken rule read as "everything is fine".
CalcValue::Range(m) => match m.visit(0, 0) {
Ok(v) => scalar_violates(v),
Err(v) => scalar_violates(&v),
},
CalcValue::Cube(_) => true,
CalcValue::Union(parts) => parts.first().map(|p| violates(p)).unwrap_or(false),
}
}

impl Workbook {
/// Whether writing `proposed` into the cell at (`row`, `col`) would break
/// its field's validation rule.
///
/// Returns `has_rule: false` — never an error — for every cell that is not
/// a validated block cell: cells outside a block, fields with no rule, and
/// rows whose shadow has not been installed yet. A caller gating a write
/// wants one question answered, and "there is nothing to check here" is an
/// answer, not a failure.
pub fn check_field_validation(
&mut self,
sheet_idx: usize,
row: usize,
col: usize,
proposed: String,
) -> std::result::Result<FieldValidationVerdict, Error> {
let sheet_id = self
.controller
.status
.sheet_info_manager
.get_sheet_id(sheet_idx)
.ok_or(BasicError::SheetIdxExceed(sheet_idx))?;

let cell_id = self
.controller
.status
.navigator
.fetch_cell_id(&sheet_id, row, col)?;
let CellId::BlockCell(bcid) = cell_id else {
return Ok(FieldValidationVerdict::default());
};

let Some(rule) = self
.controller
.status
.block_schema_manager
.validation_for_block_cell(sheet_id, &bcid)
else {
return Ok(FieldValidationVerdict::default());
};

// The shadow carries the rule already substituted for this record —
// its `#FIELD` siblings and `#KEY` resolved against this row. Without
// one there is nothing to evaluate; that happens only in the window
// between a bind and the calc pass, so report the rule but no verdict
// rather than guessing at one.
let unknown = FieldValidationVerdict {
has_rule: true,
violates: false,
rule: rule.clone(),
};
let Some(eid) =
self.controller
.sid_assigner
.find_shadow_id(sheet_id, cell_id, ShadowKind::Validation)
else {
return Ok(unknown);
};
let Some(ast) = self
.controller
.status
.formula_manager
.formulas
.get(&(sheet_id, CellId::EphemeralCell(eid)))
.cloned()
else {
return Ok(unknown);
};

let violated = self.eval_against(sheet_id, cell_id, proposed, &ast);
Ok(FieldValidationVerdict {
has_rule: true,
violates: violated,
rule,
})
}

/// Evaluate `ast` with `cell_id` temporarily holding `proposed`, then put
/// the cell back exactly as it was.
///
/// The restore is unconditional and covers the "cell had no entry" case
/// too: a validated-but-empty cell has nothing in the container, and
/// leaving a blank one behind would turn a question into an edit.
fn eval_against(
&mut self,
sheet_id: SheetId,
cell_id: CellId,
proposed: String,
ast: &logisheets_parser::ast::Node,
) -> bool {
let status = &mut self.controller.status;
let proposed_value = {
let text_ids = &mut status.text_id_manager;
CellValue::from_string(proposed, &mut |t| -> TextId {
text_ids.get_or_register_id(t)
})
};

let saved: Option<Cell> = status.container.get_cell(sheet_id, &cell_id).cloned();
status
.container
.update_value(sheet_id, cell_id, proposed_value);

let (row, col) = status
.navigator
.fetch_cell_idx(&sheet_id, &cell_id)
.unwrap_or((0, 0));

let mut dirty_next: imbl::HashSet<(SheetId, CellId)> = imbl::HashSet::new();
let mut calc_cells: HashSet<(SheetId, CellId)> = HashSet::new();
let mut async_func_manager = crate::async_func_manager::AsyncFuncManager::default();
let async_funcs: HashSet<String> = HashSet::new();

let value = {
let mut connector = CalcConnector {
range_manager: &status.range_manager,
cube_manager: &status.cube_manager,
navigator: &mut status.navigator,
container: &mut status.container,
ext_links: &mut status.external_links_manager,
text_id_manager: &mut status.text_id_manager,
func_id_manager: &status.func_id_manager,
sheet_id_manager: &status.sheet_id_manager,
names_storage: HashMap::new(),
cells_storage: HashMap::new(),
sheet_pos_manager: &status.sheet_info_manager,
async_func_manager: &mut async_func_manager,
async_funcs: &async_funcs,
active_sheet: sheet_id,
curr_addr: Addr { row, col },
dirty_cells_in_next_run: &mut dirty_next,
calc_cells: &mut calc_cells,
block_schema_manager: &status.block_schema_manager,
formula_manager: &status.formula_manager,
name_id_manager: &status.name_id_manager,
ext_ref_manager: &status.ext_ref_manager,
};
calc(ast, &mut connector)
};

match saved {
Some(cell) => status.container.add_cell(sheet_id, cell_id, cell),
None => {
status.container.remove_cell(sheet_id, &cell_id);
}
}

violates(&value)
}
}
2 changes: 2 additions & 0 deletions crates/controller/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub use crate::{
errors::{Error, ErrorMessage, Result},
};
mod cell_positioner;
mod field_validation;
mod fill;
mod sort_block;
mod types;
Expand All @@ -13,6 +14,7 @@ mod worksheet;

#[cfg(test)]
mod test;
pub use field_validation::FieldValidationVerdict;
pub use fill::FillRange;
pub use logisheets_base::BlockId;
pub use sort_block::BlockSortOrder;
Expand Down
Loading
Loading