gristlabs_grist-core/app/common/PredicateFormula.ts
George Gevoian 3112433a58 (core) Add dropdown conditions
Summary:
Dropdown conditions let you specify a predicate formula that's used to filter
choices and references in their respective autocomplete dropdown menus.

Test Plan: Python and browser tests (WIP).

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4235
2024-04-26 16:57:55 -04:00

224 lines
8.1 KiB
TypeScript

/**
* Representation and compilation of predicate formulas.
*
* An example of a predicate formula is: "rec.office == 'Seattle' and user.email in ['sally@', 'xie@']".
* These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].
* See sandbox/grist/predicate_formula.py for details.
*
* This module includes typings for the nodes, and the compilePredicateFormula() function that
* turns such trees into actual predicate functions.
*/
import {CellValue, RowRecord} from 'app/common/DocActions';
import {ErrorWithCode} from 'app/common/ErrorWithCode';
import {InfoView, UserInfo} from 'app/common/GranularAccessClause';
import {decodeObject} from 'app/plugin/objtypes';
import constant = require('lodash/constant');
/**
* Representation of a parsed predicate formula.
*/
export type PrimitiveCellValue = number|string|boolean|null;
export type ParsedPredicateFormula = [string, ...(ParsedPredicateFormula|PrimitiveCellValue)[]];
/**
* Inputs to a predicate formula function.
*/
export interface PredicateFormulaInput {
user?: UserInfo;
rec?: RowRecord|InfoView;
newRec?: InfoView;
docId?: string;
choice?: string|RowRecord|InfoView;
}
export class EmptyRecordView implements InfoView {
public get(_colId: string): CellValue { return null; }
public toJSON() { return {}; }
}
/**
* The result of compiling ParsedPredicateFormula.
*/
export type CompiledPredicateFormula = (input: PredicateFormulaInput) => boolean;
const GRIST_CONSTANTS: Record<string, string> = {
EDITOR: 'editors',
OWNER: 'owners',
VIEWER: 'viewers',
};
/**
* An intermediate predicate formula returned during compilation, which may return
* a non-boolean value.
*/
type IntermediatePredicateFormula = (input: PredicateFormulaInput) => any;
export interface CompilePredicateFormulaOptions {
/** Defaults to `'acl'`. */
variant?: 'acl'|'dropdown-condition';
}
/**
* Compiles a parsed predicate formula and returns it.
*/
export function compilePredicateFormula(
parsedPredicateFormula: ParsedPredicateFormula,
options: CompilePredicateFormulaOptions = {}
): CompiledPredicateFormula {
const {variant = 'acl'} = options;
function compileNode(node: ParsedPredicateFormula): IntermediatePredicateFormula {
const rawArgs = node.slice(1);
const args = rawArgs as ParsedPredicateFormula[];
switch (node[0]) {
case 'And': { const parts = args.map(compileNode); return (input) => parts.every(p => p(input)); }
case 'Or': { const parts = args.map(compileNode); return (input) => parts.some(p => p(input)); }
case 'Add': return compileAndCombine(args, ([a, b]) => a + b);
case 'Sub': return compileAndCombine(args, ([a, b]) => a - b);
case 'Mult': return compileAndCombine(args, ([a, b]) => a * b);
case 'Div': return compileAndCombine(args, ([a, b]) => a / b);
case 'Mod': return compileAndCombine(args, ([a, b]) => a % b);
case 'Not': return compileAndCombine(args, ([a]) => !a);
case 'Eq': return compileAndCombine(args, ([a, b]) => a === b);
case 'NotEq': return compileAndCombine(args, ([a, b]) => a !== b);
case 'Lt': return compileAndCombine(args, ([a, b]) => a < b);
case 'LtE': return compileAndCombine(args, ([a, b]) => a <= b);
case 'Gt': return compileAndCombine(args, ([a, b]) => a > b);
case 'GtE': return compileAndCombine(args, ([a, b]) => a >= b);
case 'Is': return compileAndCombine(args, ([a, b]) => a === b);
case 'IsNot': return compileAndCombine(args, ([a, b]) => a !== b);
case 'In': return compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a)));
case 'NotIn': return compileAndCombine(args, ([a, b]) => !b?.includes(a));
case 'List': return compileAndCombine(args, (values) => values);
case 'Const': return constant(node[1] as CellValue);
case 'Name': {
const name = rawArgs[0] as keyof PredicateFormulaInput;
if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }
let validNames: string[];
switch (variant) {
case 'acl': {
validNames = ['newRec', 'rec', 'user'];
break;
}
case 'dropdown-condition': {
validNames = ['rec', 'choice'];
break;
}
}
if (!validNames.includes(name)) { throw new Error(`Unknown variable '${name}'`); }
return (input) => input[name];
}
case 'Attr': {
const attrName = rawArgs[1] as string;
return compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));
}
case 'Comment': return compileNode(args[0]);
}
throw new Error(`Unknown node type '${node[0]}'`);
}
/**
* Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and
* combine the array of results using the given combine() function.
*/
function compileAndCombine(
args: ParsedPredicateFormula[],
combine: (values: any[]) => any
): IntermediatePredicateFormula {
const compiled = args.map(compileNode);
return (input: PredicateFormulaInput) => combine(compiled.map(c => c(input)));
}
const compiledPredicateFormula = compileNode(parsedPredicateFormula);
return (input) => Boolean(compiledPredicateFormula(input));
}
function describeNode(node: ParsedPredicateFormula): string {
if (node[0] === 'Name') {
return node[1] as string;
} else if (node[0] === 'Attr') {
return describeNode(node[1] as ParsedPredicateFormula) + '.' + (node[2] as string);
} else {
return 'value';
}
}
function getAttr(value: any, attrName: string, valueNode: ParsedPredicateFormula): any {
if (value == null) {
if (valueNode[0] === 'Name' && (valueNode[1] === 'rec' || valueNode[1] === 'newRec')) {
// This code is recognized by GranularAccess to know when an ACL rule is row-specific.
throw new ErrorWithCode('NEED_ROW_DATA', `Missing row data '${valueNode[1]}'`);
}
throw new Error(`No value for '${describeNode(valueNode)}'`);
}
return typeof value.get === 'function'
? decodeObject(value.get(attrName)) // InfoView
: value[attrName];
}
/**
* Predicate formula properties.
*/
export interface PredicateFormulaProperties {
/**
* List of column ids that are referenced by either `$` or `rec.` notation.
*/
recColIds?: string[];
/**
* List of column ids that are referenced by `choice.` notation.
*
* Only applies to the `dropdown-condition` variant of predicate formulas,
* and only for Reference and Reference List columns.
*/
choiceColIds?: string[];
}
/**
* Returns properties about a predicate `formula`.
*
* Properties include the list of column ids referenced in the formula.
* Currently, this information is used for error validation; specifically, to
* report when invalid column ids are referenced in ACL formulas and dropdown
* conditions.
*/
export function getPredicateFormulaProperties(
formula: ParsedPredicateFormula
): PredicateFormulaProperties {
return {
recColIds: [...getRecColIds(formula)],
choiceColIds: [...getChoiceColIds(formula)],
};
}
function isRecOrNewRec(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
return Array.isArray(formula) &&
formula[0] === 'Name' &&
(formula[1] === 'rec' || formula[1] === 'newRec');
}
function getRecColIds(formula: ParsedPredicateFormula): string[] {
return [...new Set(collectColIds(formula, isRecOrNewRec))];
}
function isChoice(formula: ParsedPredicateFormula|PrimitiveCellValue): boolean {
return Array.isArray(formula) && formula[0] === 'Name' && formula[1] === 'choice';
}
function getChoiceColIds(formula: ParsedPredicateFormula): string[] {
return [...new Set(collectColIds(formula, isChoice))];
}
function collectColIds(
formula: ParsedPredicateFormula,
isIdentifierWithColIds: (formula: ParsedPredicateFormula|PrimitiveCellValue) => boolean,
): string[] {
if (!Array.isArray(formula)) { throw new Error('expected a list'); }
if (formula[0] === 'Attr' && isIdentifierWithColIds(formula[1])) {
const colId = String(formula[2]);
return [colId];
}
return formula.flatMap(el => Array.isArray(el) ? collectColIds(el, isIdentifierWithColIds) : []);
}