f7
f7 is a spreadsheet formula execution library
git clone https://git.vogt.world/f7.git
Log | Files | README.md | LICENSE.md
← All files
name: src/main/java/io/protobase/f7/spreadsheet/SpreadsheetExecutor.java
-rw-r--r--
16719
  1package io.protobase.f7.spreadsheet;
  2
  3import com.google.common.collect.ImmutableMap;
  4import io.protobase.f7.antlr.F7Lexer;
  5import io.protobase.f7.antlr.F7Parser;
  6import io.protobase.f7.errors.F7Exception;
  7import io.protobase.f7.errors.ParseException;
  8import io.protobase.f7.errors.RefException;
  9import io.protobase.f7.errors.ValueException;
 10import io.protobase.f7.formulas.FormulaCaller;
 11import io.protobase.f7.models.Cell;
 12import io.protobase.f7.models.CellQuery;
 13import io.protobase.f7.models.ColumnRowKey;
 14import io.protobase.f7.models.ErrorNode;
 15import io.protobase.f7.models.Grid;
 16import io.protobase.f7.models.GridColumnRowKey;
 17import io.protobase.f7.models.Node;
 18import io.protobase.f7.transpiler.TranspilationVisitor;
 19import io.protobase.f7.utils.Converters;
 20import org.antlr.v4.runtime.CharStreams;
 21import org.antlr.v4.runtime.CommonTokenStream;
 22import org.antlr.v4.runtime.misc.ParseCancellationException;
 23
 24import java.io.ByteArrayInputStream;
 25import java.io.IOException;
 26import java.nio.charset.StandardCharsets;
 27import java.util.HashMap;
 28import java.util.Iterator;
 29import java.util.Map;
 30import java.util.Optional;
 31import java.util.Stack;
 32
 33/**
 34 * Execute F7 code in the context of a spreadsheet, storing values in a grid.
 35 */
 36public class SpreadsheetExecutor {
 37  /**
 38   * All bound formulas.
 39   */
 40  private FormulaCaller formulaCaller;
 41
 42  /**
 43   * Stores the cells that we've looked up so we can keep track of cyclical dependencies between cells.
 44   */
 45  private Stack<GridColumnRowKey> computationOrderKeyStack = new Stack<>();
 46
 47  /**
 48   * Named grids that will store cells.
 49   */
 50  private Map<String, Worksheet> worksheets;
 51
 52  /**
 53   * Named variables available to the spreadsheet.
 54   */
 55  private Map<String, Node> variables;
 56
 57  /**
 58   * Private constructor that accepts worksheets.
 59   *
 60   * @param worksheets - of raw cell values.
 61   */
 62  private SpreadsheetExecutor(Map<String, Worksheet> worksheets, Map<String, String> namedRanges) {
 63    // Set worksheets. These are what we'll be running.
 64    this.worksheets = worksheets;
 65
 66    // Build variables, overriding with defaults.
 67    ImmutableMap.Builder<String, Node> variablesBuilder = ImmutableMap.builder();
 68    for (Map.Entry<String, String> namedRangeEntry : namedRanges.entrySet()) {
 69      String name = namedRangeEntry.getKey().toUpperCase();
 70      String rangeString = namedRangeEntry.getValue();
 71      variablesBuilder.put(name, this.parseNamedRange(rangeString));
 72    }
 73    variables = variablesBuilder.build();
 74
 75    // Bind formula caller.
 76    this.formulaCaller = new FormulaCaller(this::lookup, this::collateralLookup);
 77  }
 78
 79  /**
 80   * Utility to convert a string input to tokens that the parser can read.
 81   *
 82   * @param input - F7 code.
 83   * @return token stream.
 84   * @throws IOException - if there's a problem reading the stream.
 85   */
 86  private static CommonTokenStream stringToTokens(String input) throws IOException {
 87    return new CommonTokenStream(new F7Lexer(CharStreams.fromStream(
 88        new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)));
 89  }
 90
 91  /**
 92   * Start a builder.
 93   *
 94   * @return builder.
 95   */
 96  public static Builder builder() {
 97    return new Builder();
 98  }
 99
100  /**
101   * Parse a range to node for execution.
102   *
103   * @param range - range string
104   * @return range node.
105   */
106  private Node parseNamedRange(String range) {
107    try {
108      CommonTokenStream tokens = stringToTokens(range);
109      try {
110        F7Parser parser = new F7Parser(tokens);
111        parser.removeErrorListeners();
112        parser.addErrorListener(new ParseErrorListener());
113        return new TranspilationVisitor().visit(parser.range());
114      } catch (ParseCancellationException parseException) {
115        return new ErrorNode(new ParseException("Parse error"));
116      } catch (F7Exception f7Exception) {
117        return new ErrorNode(f7Exception);
118      }
119    } catch (IOException io) {
120      return new ErrorNode(new ParseException("Parse error"));
121    }
122  }
123
124  /**
125   * If the value passed in is a CellQuery, lookup the value. If not, return value unchanged.
126   *
127   * @param queryOrValue - value that is possibly a range, and should be resolved/looked-up.
128   * @return resolved value or given range if not.
129   */
130  private Object lookup(Object queryOrValue) {
131    if (queryOrValue instanceof CellQuery) {
132      return this.performQuery(((CellQuery) queryOrValue));
133    }
134    return queryOrValue;
135  }
136
137  /**
138   * If the value passed in is a CellQuery, perform collateral lookup. If not return value unchanged.
139   *
140   * @param origin       - origin to use in potential collateral lookup.
141   * @param queryOrValue - query or value.
142   * @return
143   */
144  private Object collateralLookup(GridColumnRowKey origin, Object queryOrValue) {
145    if (queryOrValue instanceof CellQuery) {
146      return this.performCollateralLookup(origin, ((CellQuery) queryOrValue));
147    }
148    return queryOrValue;
149  }
150
151  /**
152   * Run the spreadsheet.
153   */
154  public void execute() {
155    // For each worksheet, iterate through the cells in reverse (bottom to top, right to left), computing the cells.
156    for (Map.Entry<String, Worksheet> entry : worksheets.entrySet()) {
157      String gridName = entry.getKey();
158      Grid<Cell> currentGrid = entry.getValue().getCells();
159      currentGrid.reverseIndexIterator().forEachRemaining(index -> processCell(new GridColumnRowKey(gridName, index)));
160    }
161  }
162
163  /**
164   * Process a single cell, setting the raw value if the cell does not contain a formula, or computing the cell's
165   * formula if it has one.
166   *
167   * @param key - where this cell will be stored in the grid.
168   */
169  private void processCell(GridColumnRowKey key) {
170    Optional<Cell> cell = getCell(key.getGridIndex(), key.toColumnRowKey());
171    if (cell.isPresent() && !cell.get().isComputed()) {
172      computationOrderKeyStack.push(key);
173      computeCell(key, cell.get().getRawValue());
174      computationOrderKeyStack.pop();
175    }
176  }
177
178  /**
179   * Compute a single cell, recursively computing other cells if necessary, ultimately storing the final cell in the
180   * grid.
181   *
182   * @param key      - where this cell will be stored in the grid.
183   * @param rawInput - cell raw value.
184   */
185  private void computeCell(GridColumnRowKey key, String rawInput) {
186    Worksheet worksheet = worksheets.get(key.getGridIndex());
187    ColumnRowKey columnRowKey = key.toColumnRowKey();
188    F7CodeExecutor codeExecutor = new F7CodeExecutor(variables, this::lookup, this::collateralLookup, formulaCaller);
189    try {
190      Object result = codeExecutor.execute(key, rawInput);
191      if (result instanceof Grid) {
192        try {
193          Grid<Object> gridToProject = Converters.castAsDataGrid(result);
194          checkProjectionValidity(key, gridToProject.getColumnSize(), gridToProject.getRowSize());
195          performProjection(key, gridToProject);
196        } catch (F7Exception exception) {
197          worksheet.setCellComputedValue(columnRowKey, exception);
198        }
199      } else {
200        worksheet.setCellComputedValue(columnRowKey, result);
201      }
202    } catch (F7Exception exception) {
203      worksheet.setCellComputedValue(columnRowKey, exception);
204    }
205  }
206
207  /**
208   * For a given key, project the values onto the grid. This should only be called after the projection validity has
209   * been checked.
210   *
211   * @param origin       - starting value of computed grid.
212   * @param computedGrid - computed grid to project, starting at key.
213   */
214  private void performProjection(GridColumnRowKey origin, Grid<Object> computedGrid) {
215    Worksheet worksheet = worksheets.get(origin.getGridIndex());
216    Iterator<ColumnRowKey> iterator = computedGrid.indexIterator();
217    while (iterator.hasNext()) {
218      ColumnRowKey localKey = iterator.next();
219      Object projectedValue = computedGrid.get(localKey);
220      ColumnRowKey projectedKey = new ColumnRowKey(origin.getColumnIndex() + localKey.getColumnIndex(),
221          origin.getRowIndex() + localKey.getRowIndex());
222      worksheet.setCell(projectedKey, new Cell(projectedValue, origin.toColumnRowKey()));
223    }
224  }
225
226  /**
227   * At a key, for a number of columns and rows, check to ensure that the values we're potentially projecting won't
228   * overwrite existing cell values.
229   *
230   * @param origin  - to start at
231   * @param columns - number of columns forward to project. If it's 1, then we're just projecting to the current key.
232   * @param rows    - number of rows forward to project. If it's 1, then we're just projecting to the current key.
233   */
234  private void checkProjectionValidity(GridColumnRowKey origin, int columns, int rows) {
235    Iterator<ColumnRowKey> iterator = worksheets.get(origin.getGridIndex()).getCells().reverseIndexIterator(
236        origin.getColumnIndex() + columns - 1, origin.getColumnIndex(), origin.getRowIndex() + rows - 1,
237        origin.getRowIndex());
238    while (iterator.hasNext()) {
239      ColumnRowKey scanKey = iterator.next();
240      if (!scanKey.equals(origin.toColumnRowKey())) {
241        Optional<Cell> found = getCell(origin.getGridIndex(), scanKey);
242        if (found.isPresent()) {
243          throw new RefException(String.format("Grid result was not projected because it would overwrite data in %s",
244              scanKey.toString()));
245        }
246      }
247    }
248  }
249
250  /**
251   * Get a cell from the grid.
252   *
253   * @param gridName - name of the grid that holds the cell.
254   * @param key      - grid index of the cell.
255   * @return cell.
256   */
257  public Optional<Cell> getCell(String gridName, ColumnRowKey key) {
258    return Optional.ofNullable(worksheets.get(gridName).getCell(key));
259  }
260
261  /**
262   * Ensure that a worksheet exists, throwing a #REF! error if it does not.
263   *
264   * @param worksheet to check for.
265   */
266  private void checkWorksheetExistence(String worksheet) {
267    if (!worksheets.containsKey(worksheet)) {
268      throw new RefException(String.format("Unresolved grid name '%s'.", worksheet));
269    }
270  }
271
272  /**
273   * Given a query, traverse the entire range, collecting values into a grid. This is a little complex, so here's the
274   * outline of how it works:
275   * <p>
276   * 1) Get the grid from the query. If we don't have a grid we can't proceed.
277   * 2) Get the grid, and build a query that represents the boundaries of the grid.
278   * 3) If the grid boundaries don't intersect the query boundaries, they are disjoint sets. Return empty grid.
279   * 4) Take the input query and constrict it to the size of the grid we're querying.
280   * 5) We now know we're going to be iterating over something. Start iterator, create grid that we'll be building.
281   * 6) For each key of the iterator, make sure we're not in a cycle.
282   * 7) Process the cell - possibly computing it's value if we've not done that yet.
283   * 8) Add the cell to the return grid.
284   * 9) Return the grid.
285   *
286   * @param query - to resolve.
287   * @return grid of values, or single value if we're using a collateral index.
288   */
289  private Grid performQuery(CellQuery query) {
290    String gridName = query.getGrid().orElseThrow(() -> new IllegalStateException("CellQuery does not have name."));
291    checkWorksheetExistence(gridName);
292
293    Grid<Cell> grid = worksheets.get(gridName).getCells();
294    CellQuery gridBoundaries = CellQuery.builder()
295        .columnsBetween(0, grid.getColumnSize())
296        .rowsBetween(0, grid.getRowSize())
297        .build();
298    if (!gridBoundaries.intersects(query)) {
299      return Grid.builder().build();
300    }
301    CellQuery boundedQuery = query.toBounded(grid.getColumnSize(), grid.getRowSize());
302
303    int endColumn = boundedQuery.getEndColumn();
304    int endRow = boundedQuery.getEndRow();
305    int startColumn = boundedQuery.getStartColumn();
306    int startRow = boundedQuery.getStartRow();
307
308    Iterator<ColumnRowKey> iterator = worksheets.get(gridName).getCells().reverseIndexIterator(endColumn,
309        startColumn, endRow, startRow);
310    Grid<Object> rangeGrid = new Grid<>();
311
312    while (iterator.hasNext()) {
313      ColumnRowKey key = iterator.next();
314      if (computationOrderKeyStack.contains(new GridColumnRowKey(gridName, key))) {
315        throw new RefException("Cycle detected.");
316      }
317      processCell(new GridColumnRowKey(gridName, key));
318      Optional<Cell> cell = getCell(gridName, key);
319      if (cell.isPresent()) {
320        rangeGrid.set(key.getColumnIndex() - startColumn, key.getRowIndex() - startRow, cell.get().getComputedValue());
321      } else {
322        rangeGrid.setEmpty(key.getColumnIndex() - startColumn, key.getRowIndex() - startRow);
323      }
324    }
325
326    return rangeGrid;
327  }
328
329  private Object performCollateralLookup(GridColumnRowKey origin, CellQuery query) {
330    String gridName = query.getGrid().orElseThrow(() -> new IllegalStateException("CellQuery does not have name."));
331    checkWorksheetExistence(gridName);
332
333    Grid<Cell> grid = worksheets.get(gridName).getCells();
334    CellQuery gridBoundaries = CellQuery.builder()
335        .columnsBetween(0, grid.getColumnSize())
336        .rowsBetween(0, grid.getRowSize())
337        .build();
338    if (!gridBoundaries.intersects(query)) {
339      return Grid.builder().build();
340    }
341    CellQuery boundedQuery = query.toBounded(grid.getColumnSize(), grid.getRowSize());
342
343    int endColumn = boundedQuery.getEndColumn();
344    int endRow = boundedQuery.getEndRow();
345    int startColumn = boundedQuery.getStartColumn();
346    int startRow = boundedQuery.getStartRow();
347
348    // Case 0: Single value, just return it.
349    if (startColumn == endColumn && startRow == endRow) {
350      GridColumnRowKey key = new GridColumnRowKey(gridName, new ColumnRowKey(startColumn, startRow));
351      return performSingleCellLookup(key);
352    }
353
354    // Case 1: Row-wise - query is a single row. Use column as collateral index.
355    if (startColumn != endColumn && startRow == endRow &&
356        origin.getColumnIndex() >= startColumn && origin.getColumnIndex() <= endColumn) {
357      GridColumnRowKey key = new GridColumnRowKey(gridName, new ColumnRowKey(origin.getColumnIndex(), startRow));
358      return performSingleCellLookup(key);
359    }
360
361    // Case 2: Column-wise - query is single column. Use row as collateral index.
362    if (startColumn == endColumn &&
363        origin.getRowIndex() >= startRow && origin.getRowIndex() <= endRow) {
364      GridColumnRowKey key = new GridColumnRowKey(gridName, new ColumnRowKey(startColumn, origin.getRowIndex()));
365      return performSingleCellLookup(key);
366    }
367
368    // Case 3: Other grid - query is multi-row, multi-column, and other grid. Use both as collateral index.
369    if (origin.getColumnIndex() >= startColumn && origin.getColumnIndex() <= endColumn &&
370        origin.getRowIndex() >= startRow && origin.getRowIndex() <= endRow &&
371        !origin.getGridIndex().equals(query.getGrid().get())) {
372      GridColumnRowKey key = new GridColumnRowKey(gridName, new ColumnRowKey(origin.getColumnIndex(), origin.getRowIndex()));
373      return performSingleCellLookup(key);
374    }
375
376    // Case 4: This grid, and exists. #REF! error - we're looking up the origin.
377    if (origin.getColumnIndex() >= startColumn && origin.getColumnIndex() <= endColumn &&
378        origin.getRowIndex() >= startRow && origin.getRowIndex() <= endRow &&
379        origin.getGridIndex().equals(query.getGrid().get())) {
380      return new RefException("Cycle detected");
381    }
382
383    // Case 5: This grid, not contained. #VALUE! error.
384    return new ValueException("Array value could not be found");
385  }
386
387  private Object performSingleCellLookup(GridColumnRowKey key) {
388    if (computationOrderKeyStack.contains(key)) {
389      throw new RefException("Cycle detected.");
390    }
391    processCell(key);
392    Optional<Cell> cell = getCell(key.getGridIndex(), key.toColumnRowKey());
393    if (cell.isPresent()) {
394      return cell.get().getComputedValue();
395    }
396    return null;
397  }
398
399  /**
400   * Simple way to build an executor.
401   */
402  public static class Builder {
403    Map<String, Grid<Cell>> grids = new HashMap<>();
404    Map<String, String> variables = new HashMap<>();
405
406    public Builder addGrids(Map<String, Grid<Cell>> toAdd) {
407      grids.putAll(toAdd);
408      return this;
409    }
410
411    public Builder addGrid(String name, Map<ColumnRowKey, String> values) {
412      Grid<Cell> grid = new Grid<>();
413      values.forEach((key, value) -> grid.set(key, new Cell(value)));
414      grids.put(name, grid);
415      return this;
416    }
417
418    public Builder addNamedRanges(Map<String, String> ranges) {
419      variables.putAll(ranges);
420      return this;
421    }
422
423    public SpreadsheetExecutor build() {
424      Map<String, Worksheet> worksheets = new HashMap<>();
425      for (Map.Entry<String, Grid<Cell>> entry : grids.entrySet()) {
426        worksheets.put(entry.getKey(), new Worksheet(entry.getKey(), entry.getValue()));
427      }
428      return new SpreadsheetExecutor(worksheets, variables);
429    }
430  }
431}