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/F7CodeExecutor.java
-rw-r--r--
15807
  1package io.protobase.f7.spreadsheet;
  2
  3import com.google.common.collect.ImmutableList;
  4import com.google.common.collect.ImmutableMap;
  5import io.protobase.f7.antlr.F7Lexer;
  6import io.protobase.f7.antlr.F7Parser;
  7import io.protobase.f7.errors.F7Exception;
  8import io.protobase.f7.errors.F7ExceptionName;
  9import io.protobase.f7.errors.NAException;
 10import io.protobase.f7.errors.NameException;
 11import io.protobase.f7.errors.ParseException;
 12import io.protobase.f7.errors.ValueException;
 13import io.protobase.f7.formulas.FormulaCaller;
 14import io.protobase.f7.formulas.FormulaName;
 15import io.protobase.f7.models.BinaryOperationNode;
 16import io.protobase.f7.models.CellQuery;
 17import io.protobase.f7.models.ColumnRowKey;
 18import io.protobase.f7.models.ErrorNode;
 19import io.protobase.f7.models.FormulaNode;
 20import io.protobase.f7.models.Grid;
 21import io.protobase.f7.models.GridColumnRowKey;
 22import io.protobase.f7.models.ListNode;
 23import io.protobase.f7.models.LogicalNode;
 24import io.protobase.f7.models.Node;
 25import io.protobase.f7.models.NumberNode;
 26import io.protobase.f7.models.RangeNode;
 27import io.protobase.f7.models.RangeQueryNode;
 28import io.protobase.f7.models.TextNode;
 29import io.protobase.f7.models.UnaryMinusOperationNode;
 30import io.protobase.f7.models.UnaryPercentOperationNode;
 31import io.protobase.f7.models.UnaryPlusOperationNode;
 32import io.protobase.f7.models.VariableNode;
 33import io.protobase.f7.transpiler.TranspilationVisitor;
 34import io.protobase.f7.utils.Converters;
 35import org.antlr.v4.runtime.CharStreams;
 36import org.antlr.v4.runtime.CommonTokenStream;
 37import org.antlr.v4.runtime.misc.ParseCancellationException;
 38
 39import java.io.ByteArrayInputStream;
 40import java.io.IOException;
 41import java.nio.charset.StandardCharsets;
 42import java.util.Iterator;
 43import java.util.Map;
 44import java.util.Objects;
 45import java.util.function.BiFunction;
 46import java.util.function.Function;
 47
 48public class F7CodeExecutor {
 49  /**
 50   * Default variables. Basically should only be TRUE and FALSE, but could be any Node depending on what we're doing
 51   * with the executor.
 52   */
 53  private static final ImmutableMap<String, Node> DEFAULT_VARIABLES = ImmutableMap.of(
 54      "TRUE", LogicalNode.TRUE,
 55      "FALSE", LogicalNode.FALSE
 56  );
 57  /**
 58   * Collateral lookup is how we access values in a grid relative to the cell that contains the code we're currently
 59   * running.
 60   */
 61  private BiFunction<GridColumnRowKey, Object, Object> collateralLookup;
 62  /**
 63   * Look up values from the spreadsheet.
 64   */
 65  private Function<Object, Object> lookup;
 66  /**
 67   * All bound formulas.
 68   */
 69  private FormulaCaller formulaCaller;
 70  /**
 71   * Variables accessible.
 72   */
 73  private Map<String, Node> variables;
 74  /**
 75   * The key where this cell is running.
 76   */
 77  private GridColumnRowKey origin;
 78  /**
 79   * Depth of formula.
 80   */
 81  private Integer depth = 0;
 82
 83  public F7CodeExecutor(Map<String, Node> variables, Function<Object, Object> lookup,
 84      BiFunction<GridColumnRowKey, Object, Object> collateralLookup, FormulaCaller formulaCaller) {
 85    this.variables = variables;
 86    this.lookup = lookup;
 87    this.collateralLookup = collateralLookup;
 88    this.formulaCaller = formulaCaller;
 89
 90    ImmutableMap.Builder<String, Node> variablesBuilder = ImmutableMap.builder();
 91    variablesBuilder.putAll(variables);
 92    variablesBuilder.putAll(DEFAULT_VARIABLES);
 93    this.variables = variablesBuilder.build();
 94  }
 95
 96  /**
 97   * Utility to convert a string input to tokens that the parser can read.
 98   *
 99   * @param input - F7 code.
100   * @return token stream.
101   * @throws IOException - if there's a problem reading the stream.
102   */
103  private static CommonTokenStream stringToTokens(String input) throws IOException {
104    return new CommonTokenStream(new F7Lexer(CharStreams.fromStream(
105        new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)));
106  }
107
108  /**
109   * Some raw values are cast as types. Right now:
110   * 1) All Strings that are error literals are cast as errors.
111   *
112   * @param rawValue - raw value object.
113   * @return cast value or unchanged raw value.
114   */
115  private static Object rawValueOverrides(Object rawValue) {
116    if (rawValue instanceof String) {
117      String rawValueString = Converters.castAsString(rawValue);
118      F7ExceptionName possibleErrorLiteralString = F7ExceptionName.fromString(rawValueString);
119      if (Objects.nonNull(possibleErrorLiteralString)) {
120        return F7Exception.fromString(rawValueString);
121      }
122    }
123    return rawValue;
124  }
125
126  /**
127   * Execute the given code with an origin.
128   *
129   * @param origin - the cell key where this code is running.
130   * @param code   - to execute.
131   * @return - computed value.
132   */
133  public Object execute(GridColumnRowKey origin, String code) {
134    this.origin = origin;
135    if (!code.startsWith("=")) {
136      return rawValueOverrides(code);
137    }
138    String formulaCode = code.substring(1);
139    return visit(parse(formulaCode));
140  }
141
142  /**
143   * Visit error node by returning the error value it holds.
144   *
145   * @param node - error node.
146   * @return error value.
147   */
148  private Object visitError(ErrorNode node) {
149    return node.getValue();
150  }
151
152  /**
153   * Visit number node by returning the number value it holds.
154   *
155   * @param node - number node.
156   * @return number value.
157   */
158  private Object visitNumber(NumberNode node) {
159    return node.getValue();
160  }
161
162  /**
163   * Visit logical node by returning the boolean value it holds.
164   *
165   * @param node - logical node.
166   * @return - boolean/logical value.
167   */
168  private Object visitLogical(LogicalNode node) {
169    return node.getValue();
170  }
171
172  /**
173   * Visit text node by returning the text value it holds.
174   *
175   * @param node - text node.
176   * @return text value.
177   */
178  private Object visitText(TextNode node) {
179    return node.getValue();
180  }
181
182  /**
183   * Visit and execute a binary operation by visiting the left and right nodes - in that order -  and then applying the
184   * operator.
185   *
186   * @param node- binary operation node.
187   * @return value or error
188   */
189  private Object visitBinaryOperation(BinaryOperationNode node) {
190    Object left = visit(node.getLeft());
191    Object right = visit(node.getRight());
192    switch (node.getOperator()) {
193      case "+":
194        return formulaCaller.call(origin, FormulaName.ADD, left, right);
195      case "-":
196        return formulaCaller.call(origin, FormulaName.MINUS, left, right);
197      case "*":
198        return formulaCaller.call(origin, FormulaName.MULTIPLY, left, right);
199      case "/":
200        return formulaCaller.call(origin, FormulaName.DIVIDE, left, right);
201      case "^":
202        return formulaCaller.call(origin, FormulaName.POW, left, right);
203      case "&":
204        return formulaCaller.call(origin, FormulaName.CONCAT, left, right);
205      case "=":
206        return formulaCaller.call(origin, FormulaName.EQ, left, right);
207      case "<>":
208        return formulaCaller.call(origin, FormulaName.NE, left, right);
209      case "<":
210        return formulaCaller.call(origin, FormulaName.LT, left, right);
211      case "<=":
212        return formulaCaller.call(origin, FormulaName.LTE, left, right);
213      case ">":
214        return formulaCaller.call(origin, FormulaName.GT, left, right);
215      case ">=":
216        return formulaCaller.call(origin, FormulaName.GTE, left, right);
217      default:
218        throw new UnsupportedOperationException(String.format("Binary operation %s is not supported.",
219            node.getOperator()));
220    }
221  }
222
223  /**
224   * Visit and execute a unary operation by visiting the operand, and then applying the unary operator.
225   *
226   * @param node - unary minus node
227   * @return value or error.
228   */
229  private Object visitUnaryMinusOperation(UnaryMinusOperationNode node) {
230    Object value = visit(node.getOperand());
231    return formulaCaller.call(origin, FormulaName.UMINUS, value);
232  }
233
234  /**
235   * Visit and execute a unary operation by visiting the operand, and then applying the unary operator.
236   *
237   * @param node - unary plus node
238   * @return value or error.
239   */
240  private Object visitUnaryPlusOperation(UnaryPlusOperationNode node) {
241    Object value = visit(node.getOperand());
242    return formulaCaller.call(origin, FormulaName.UPLUS, value);
243  }
244
245  /**
246   * Visit and execute a unary percentage operation by visiting the operand and then applying the unary operator.
247   *
248   * @param node - unary percent node
249   * @return value or error.
250   */
251  private Object visitUnaryPercentOperation(UnaryPercentOperationNode node) {
252    Object value = Converters.first(visit(node.getOperand()));
253    return formulaCaller.call(null, FormulaName.UNARY_PERCENT, value);
254  }
255
256  /**
257   * Visit any node. Pass-through to typed visitors.
258   *
259   * @param node of any type.
260   * @return value after execution.
261   */
262  private Object visit(Node node) {
263    depth++;
264    Object returnObject = new ParseException("Execution error.");
265    if (node instanceof FormulaNode) {
266      returnObject = visitFormula(((FormulaNode) node));
267    }
268    if (node instanceof NumberNode) {
269      returnObject = visitNumber(((NumberNode) node));
270    }
271    if (node instanceof LogicalNode) {
272      returnObject = visitLogical(((LogicalNode) node));
273    }
274    if (node instanceof ListNode) {
275      returnObject = visitList(((ListNode) node));
276    }
277    if (node instanceof ErrorNode) {
278      returnObject = visitError(((ErrorNode) node));
279    }
280    if (node instanceof RangeQueryNode) {
281      returnObject = visitRangeQuery(((RangeQueryNode) node));
282    }
283    if (node instanceof TextNode) {
284      returnObject = visitText(((TextNode) node));
285    }
286    if (node instanceof VariableNode) {
287      returnObject = visitVariable(((VariableNode) node));
288    }
289    if (node instanceof BinaryOperationNode) {
290      returnObject = visitBinaryOperation(((BinaryOperationNode) node));
291    }
292    if (node instanceof UnaryMinusOperationNode) {
293      returnObject = visitUnaryMinusOperation(((UnaryMinusOperationNode) node));
294    }
295    if (node instanceof UnaryPlusOperationNode) {
296      returnObject = visitUnaryPlusOperation(((UnaryPlusOperationNode) node));
297    }
298    if (node instanceof UnaryPercentOperationNode) {
299      returnObject = visitUnaryPercentOperation(((UnaryPercentOperationNode) node));
300    }
301    if (node instanceof RangeNode) {
302      returnObject = visitRange((RangeNode) node);
303    }
304    depth--;
305    if (depth == 0 && returnObject instanceof CellQuery) {
306      return this.collateralLookup.apply(origin, returnObject);
307    }
308    return returnObject;
309  }
310
311  /**
312   * Visit a variable by accessing whatever node is stored under that variable name.
313   *
314   * @param node - to visit.
315   * @return value
316   */
317  private Object visitVariable(VariableNode node) {
318    String variableNameUpperCase = node.getName().toUpperCase();
319    if (variables.containsKey(variableNameUpperCase)) {
320      Node variableValueNode = variables.get(variableNameUpperCase);
321      if (variableValueNode instanceof RangeNode) {
322        return Converters.castAsDataGrid(lookup.apply(((RangeNode) variableValueNode).getCellQuery()));
323      }
324      return visit(variableValueNode);
325    }
326    return new NameException(String.format("Unknown range name: '%s'.", variableNameUpperCase));
327  }
328
329  /**
330   * Visit a range query by iterating over all values in the range, combining them into a single query. All inner node
331   * values must be a query as well or else it fails with #N/A error.
332   *
333   * @param node - node to build single query from.
334   * @return built query.
335   */
336  private Object visitRangeQuery(RangeQueryNode node) {
337    CellQuery.Builder query = CellQuery.builder();
338    for (Node inner : node.getNodes()) {
339      if (inner instanceof RangeNode) {
340        try {
341          CellQuery innerQuery = ((RangeNode) inner).getCellQuery();
342          if (!innerQuery.getGrid().isPresent()) {
343            innerQuery = CellQuery.builder(innerQuery).grid(origin.getGridIndex()).build();
344          }
345          query = query.expand(innerQuery);
346        } catch (ValueException error) {
347          return error;
348        }
349      } else {
350        return new NAException("Argument must be a range.");
351      }
352    }
353    return query.build();
354  }
355
356  /**
357   * Visit and execute a formula node by visiting all arguments for the node, and then calling the formula.
358   *
359   * @param node - formula node.
360   * @return value or error.
361   */
362  private Object visitFormula(FormulaNode node) {
363    ImmutableList.Builder<Object> inProgress = ImmutableList.builder();
364    for (Node value : node.getArguments()) {
365      inProgress.add(visit(value));
366    }
367    FormulaName formulaName;
368    // Try to convert the name of the formula to a valid enum, but if it fails, return NameException.
369    try {
370      formulaName = FormulaName.valueOf(node.getName().toUpperCase());
371    } catch (IllegalArgumentException exception) {
372      return new NameException(String.format("Unknown formula: '%s'.", node.getName()));
373    }
374    return formulaCaller.call(null, formulaName, inProgress.build().toArray(new Object[0]));
375  }
376
377  /**
378   * Visit and execute a list node by visiting all values in the list, and returning the grid.
379   *
380   * @param node - list node.
381   * @return value or error.
382   */
383  private Object visitList(ListNode node) {
384    Iterator<ColumnRowKey> iterator = node.getGrid().indexIterator();
385    Grid<Object> returnGrid = new Grid<>(0, 0);
386    while (iterator.hasNext()) {
387      ColumnRowKey key = iterator.next();
388      Node gridChildNode = node.getGrid().get(key);
389      if (Objects.nonNull(gridChildNode)) {
390        Object value = visit(gridChildNode);
391        if (value instanceof Grid) {
392          Grid<Object> valueGrid = Converters.castAsDataGrid(value);
393          if (returnGrid.getRowSize() > valueGrid.getRowSize() && returnGrid.getRowSize() != 0) {
394            return new ValueException("Encountered a grid literal that was missing values for one or more rows or columns.");
395          }
396          returnGrid.addGridToRight(valueGrid);
397        } else if (value instanceof CellQuery) {
398          Grid<Object> foundGrid = Converters.castAsDataGrid(lookup.apply(value));
399          if (returnGrid.getRowSize() > foundGrid.getRowSize() && returnGrid.getRowSize() != 0) {
400            return new ValueException("Encountered a grid literal that was missing values for one or more rows or columns.");
401          }
402          returnGrid.addGridToRight(foundGrid);
403        } else {
404          returnGrid.set(key.getColumnIndex(), key.getRowIndex(), value);
405        }
406      }
407    }
408    if (!returnGrid.isComplete()) {
409      return new ValueException("Encountered a grid literal that was missing values for one or more rows or columns.");
410    }
411    return returnGrid;
412  }
413
414  /**
415   * Visit a reference.
416   *
417   * @param node - range node.
418   * @return value resolved from reference
419   */
420  private Object visitRange(RangeNode node) {
421    CellQuery cellQuery = node.getCellQuery();
422    if (cellQuery.getGrid().isPresent()) {
423      return node.getCellQuery();
424    }
425    return CellQuery.builder(cellQuery).grid(origin.getGridIndex()).build();
426  }
427
428  /**
429   * Parse a string value to a node for execution.
430   *
431   * @param value - formula string.
432   * @return node
433   */
434  public Node parse(String value) {
435    try {
436      CommonTokenStream tokens = stringToTokens(value);
437      try {
438        F7Parser parser = new F7Parser(tokens);
439        parser.removeErrorListeners();
440        parser.addErrorListener(new ParseErrorListener());
441        return new TranspilationVisitor().visit(parser.start().block());
442      } catch (ParseCancellationException parseException) {
443        return new ErrorNode(new ParseException("Parse error"));
444      } catch (F7Exception f7Exception) {
445        return new ErrorNode(f7Exception);
446      }
447    } catch (IOException io) {
448      return new ErrorNode(new ParseException("Parse error"));
449    }
450  }
451}