mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052ee60218 |
@@ -17,13 +17,11 @@ pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||
let d = s.doc()?;
|
||||
let mut names = Resolver::new(&d.wb);
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas: Vec<RangeRef> = Vec::new();
|
||||
let mut named = false;
|
||||
for part in reference_parts(reference) {
|
||||
let mut found = Vec::new();
|
||||
let Some((sheet, _, by_name)) = names.target(d.wb.active_sheet(), part, 0, Some(&mut found)) else {
|
||||
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
};
|
||||
// One selection holds the areas of one sheet.
|
||||
@@ -79,114 +77,57 @@ pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves Go To references against one workbook. Defined names are found through an index
|
||||
/// built once, and whether a name refers to a reference is remembered: listing the names of a
|
||||
/// workbook full of names that alias other names would otherwise scan every name for every alias
|
||||
/// of every name.
|
||||
struct Resolver<'a> {
|
||||
wb: &'a gridcraft_model::Workbook,
|
||||
/// Indexes into `wb.names` by scope and folded name, in workbook order.
|
||||
by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>>,
|
||||
/// [`Resolver::name_areas`] without areas, by name index, sheet and depth: the target sheet
|
||||
/// and the number of areas.
|
||||
memo: std::collections::HashMap<(usize, usize, usize), Option<(usize, usize)>>,
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
|
||||
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
|
||||
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
|
||||
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
};
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
return Some((on, vec![r], false));
|
||||
}
|
||||
let name = match prefix {
|
||||
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
|
||||
None => wb.name(body, sheet),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl<'a> Resolver<'a> {
|
||||
fn new(wb: &'a gridcraft_model::Workbook) -> Self {
|
||||
let mut by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, n) in wb.names.iter().enumerate() {
|
||||
by_name.entry((n.scope, gridcraft_formula::fold_name(&n.name))).or_default().push(i);
|
||||
}
|
||||
Self { wb, by_name, memo: std::collections::HashMap::new() }
|
||||
}
|
||||
|
||||
/// The first name of scope `scope` that `same` accepts, as a scan of `wb.names` finds it.
|
||||
/// Names `same` accepts fold alike, so only that bucket is checked.
|
||||
fn find(&self, scope: Option<usize>, body: &str, same: impl Fn(&str, &str) -> bool) -> Option<usize> {
|
||||
self.by_name
|
||||
.get(&(scope, gridcraft_formula::fold_name(body)))?
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.wb.names.get(i).is_some_and(|n| same(&n.name, body)))
|
||||
}
|
||||
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, how many
|
||||
/// areas (appended to `areas` when given), and whether a defined name or a table gave them.
|
||||
/// `Sheet2!Name` is the name scoped to Sheet2. A name counts when what it refers to is a
|
||||
/// reference; a constant or a formula is not one.
|
||||
fn target(&mut self, sheet: usize, part: &str, depth: usize, areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize, bool)> {
|
||||
let wb = self.wb;
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
|
||||
/// reference.
|
||||
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas = Vec::new();
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
};
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
if let Some(a) = areas {
|
||||
a.push(r);
|
||||
}
|
||||
return Some((on, 1, false));
|
||||
target = Some(s);
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
}
|
||||
// As `Workbook::name`: the sheet's own name, else the workbook's.
|
||||
let name = match prefix {
|
||||
Some(_) => self.find(Some(on), body, gridcraft_formula::same_name),
|
||||
None => self.find(Some(sheet), body, str::eq_ignore_ascii_case).or_else(|| self.find(None, body, str::eq_ignore_ascii_case)),
|
||||
};
|
||||
if let Some(i) = name {
|
||||
return self.name_areas(i, sheet, depth, areas).map(|(t, n)| (t, n, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
if let Some(a) = areas {
|
||||
a.push(t.data_range().unwrap_or(t.range));
|
||||
}
|
||||
return Some((si, 1, true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sheet and number of areas defined name `wb.names[i]` refers to, seen from sheet
|
||||
/// `sheet`, when that is a reference; the areas are appended to `areas` when given.
|
||||
fn name_areas(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
|
||||
let key = (i, sheet, depth);
|
||||
if areas.is_none()
|
||||
&& let Some(known) = self.memo.get(&key)
|
||||
{
|
||||
return *known;
|
||||
}
|
||||
let out = self.name_areas_uncached(i, sheet, depth, areas.as_deref_mut());
|
||||
if areas.is_none() {
|
||||
self.memo.insert(key, out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn name_areas_uncached(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
|
||||
let n = self.wb.names.get(i)?;
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut count = 0usize;
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = self.target(from, p, depth + 1, areas.as_deref_mut())?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
target = Some(s);
|
||||
count += found;
|
||||
if count > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
target.map(|t| (t, count))
|
||||
}
|
||||
target.map(|t| (t, areas))
|
||||
}
|
||||
|
||||
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
|
||||
@@ -196,13 +137,11 @@ impl<'a> Resolver<'a> {
|
||||
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
|
||||
let own: std::collections::HashSet<String> =
|
||||
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut names = Resolver::new(wb);
|
||||
let mut out: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, n)| !n.hidden && names.name_areas(*i, sheet, 0, None).is_some())
|
||||
.filter_map(|(_, n)| match n.scope {
|
||||
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
|
||||
.filter_map(|n| match n.scope {
|
||||
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
|
||||
None => Some(n.name.clone()),
|
||||
Some(s) if s == sheet => Some(n.name.clone()),
|
||||
|
||||
@@ -42,28 +42,6 @@ fn go_to_lists_the_named_ranges_of_every_sheet() {
|
||||
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
/// Thousands of names that alias other names are listed with one lookup per alias, not a scan of
|
||||
/// every name for each; aliases resolve through the chain like a single name does.
|
||||
#[test]
|
||||
fn go_to_lists_many_aliases_and_follows_them() {
|
||||
let mut s = go_to_workbook();
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let alias = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
wb.names.push(alias("Chain0".into(), "Areas".into()));
|
||||
for i in 1..20_000 {
|
||||
wb.names.push(alias(format!("Chain{i}"), format!("Chain{},price", i - 1)));
|
||||
}
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
let names: Vec<&str> = list["names"].as_array().unwrap().iter().filter_map(|n| n.as_str()).collect();
|
||||
// Chain0..Chain6 are within the name depth Go To follows; longer chains are not ranges.
|
||||
for i in 0..7 {
|
||||
assert!(names.contains(&format!("Chain{i}").as_str()), "Chain{i} in {names:?}");
|
||||
}
|
||||
assert!(!names.contains(&"Chain7"), "{names:?}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "chain1"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,C3:C4,B2:B5", "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
|
||||
@@ -42,6 +42,15 @@ pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most cells (rows × columns, blanks included) Get Data imports. The rows are built densely
|
||||
/// before they are written, so a few sparse records or one far-away CSV cell must not expand
|
||||
/// into a rectangle of billions of slots.
|
||||
const MAX_IMPORT_CELLS: u64 = 10_000_000;
|
||||
|
||||
fn too_many_cells() -> EngineError {
|
||||
EngineError::Other(format!("The data is too large to import ({MAX_IMPORT_CELLS} cells at most)."))
|
||||
}
|
||||
|
||||
pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bytes = if let Some(t) = str_param(p, "text") {
|
||||
t.as_bytes().to_vec()
|
||||
@@ -57,15 +66,22 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if text.trim_start().starts_with('[') {
|
||||
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for o in &arr {
|
||||
if let Some(m) = o.as_object() {
|
||||
for k in m.keys() {
|
||||
if !keys.contains(k) {
|
||||
if seen.insert(k.as_str()) {
|
||||
keys.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.len() > gridcraft_core::MAX_COLS as usize {
|
||||
return Err(EngineError::Other("The data has more fields than a sheet has columns.".into()));
|
||||
}
|
||||
if (arr.len() as u64 + 1).saturating_mul(keys.len().max(1) as u64) > MAX_IMPORT_CELLS {
|
||||
return Err(too_many_cells());
|
||||
}
|
||||
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
|
||||
for o in &arr {
|
||||
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
|
||||
@@ -79,6 +95,9 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
|
||||
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
|
||||
if u.count() > MAX_IMPORT_CELLS {
|
||||
return Err(too_many_cells());
|
||||
}
|
||||
(u.start.row..=u.end.row)
|
||||
.map(|r| {
|
||||
(u.start.col..=u.end.col)
|
||||
@@ -129,3 +148,35 @@ pub(super) fn queries(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_imports_are_refused_before_their_rows_are_built() {
|
||||
let mut s = s();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
// 5,000 records with one field each: 5,001 × 5,000 slots once every record gets every field.
|
||||
let records: Vec<_> = (0..5_000).map(|i| json!({ format!("k{i}"): i })).collect();
|
||||
let text = serde_json::to_string(&records).unwrap();
|
||||
let err = s.execute("data.getData", json!({"text": text})).expect_err("too many cells");
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
// A CSV whose last line reaches 16,001 columns, 1,000 lines down.
|
||||
let text = format!("1{}{}x", "\n".repeat(999), ",".repeat(16_000));
|
||||
let err = s.execute("data.fromTextCsv", json!({"text": text})).expect_err("too many cells");
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
// Small imports still work.
|
||||
let out = s.execute("data.getData", json!({"text": r#"[{"a": 1}, {"b": 2}]"#})).unwrap();
|
||||
assert_eq!((out["rows"].as_u64(), out["cols"].as_u64()), (Some(3), Some(2)), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user