多维表格
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

697 lines
25 KiB

<template>
<div class="formula-wrapper">
<v-text-field
ref="input"
v-model="formula.value"
dense
outlined
class="caption"
hide-details="auto"
label="Formula"
:rules="[v => !!v || 'Required', v => parseAndValidateFormula(v)]"
autocomplete="off"
@input="handleInputDeb"
@keydown.down.prevent="suggestionListDown"
@keydown.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText"
/>
<div class="hint">
Hint: Use {} to reference columns, e.g: {column_name}. For more, please check out
<a href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank">Formulas</a
>.
</div>
<v-card v-if="suggestion && suggestion.length" class="formula-suggestion">
<v-card-text>Suggestions</v-card-text>
<v-divider />
<v-list ref="sugList" dense max-height="50vh" style="overflow: auto">
<v-list-item-group v-model="selected" color="primary">
<v-list-item
v-for="(it, i) in suggestion"
:key="i"
ref="sugOptions"
dense
selectable
@mousedown.prevent="appendText(it)"
>
<!-- Function -->
<template v-if="it.type === 'function'">
<v-tooltip right offset-x nudge-right="100">
<template #activator="{ on }">
<v-list-item-content v-on="on">
<span class="caption primary--text text--lighten-2 font-weight-bold">
<v-icon color="primary lighten-2" small class="mr-1"> mdi-function </v-icon>
{{ it.text }}
</span>
</v-list-item-content>
<v-list-item-action>
<span class="caption"> Function </span>
</v-list-item-action>
</template>
<div>
{{ it.description }} <br /><br />
Syntax: <br />
{{ it.syntax }} <br /><br />
Examples: <br />
<div v-for="(example, idx) in it.examples" :key="idx">
<pre>({{ idx + 1 }}): {{ example }}</pre>
</div>
</div>
</v-tooltip>
</template>
<!-- Column -->
<template v-if="it.type === 'column'">
<v-list-item-content>
<span class="caption text--darken-3 font-weight-bold">
<v-icon color="grey darken-4" small class="mr-1">
{{ it.icon }}
</v-icon>
{{ it.text }}
</span>
</v-list-item-content>
<v-list-item-action>
<span class="caption"> Column </span>
</v-list-item-action>
</template>
<!-- Operator -->
<template v-if="it.type === 'op'">
<v-list-item-content>
<span class="caption indigo--text text--darken-3 font-weight-bold">
<v-icon color="indigo darken-3" small class="mr-1"> mdi-calculator-variant </v-icon>
{{ it.text }}
</span>
</v-list-item-content>
<v-list-item-action>
<span class="caption"> Operator </span>
</v-list-item-action>
</template>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card>
</div>
</template>
<script>
import debounce from 'debounce';
import jsep from 'jsep';
import { UITypes, jsepCurlyHook } from 'nocodb-sdk';
import { getUIDTIcon } from '~/components/project/spreadsheet/helpers/uiTypes';
import formulaList, { formulas, formulaTypes } from '@/helpers/formulaList';
import { validateDateWithUnknownFormat } from '@/helpers/dateFormatHelper';
import { getWordUntilCaret, insertAtCursor } from '@/helpers';
import NcAutocompleteTree from '@/helpers/NcAutocompleteTree';
export default {
name: 'FormulaOptions',
props: ['nodes', 'column', 'meta', 'isSQLite', 'alias', 'value', 'sqlUi'],
data: () => ({
formula: {},
availableFunctions: formulaList,
availableBinOps: ['+', '-', '*', '/', '>', '<', '==', '<=', '>=', '!='],
autocomplete: false,
suggestion: null,
wordToComplete: '',
selected: 0,
tooltip: true,
sortOrder: {
column: 0,
function: 1,
op: 2,
},
}),
computed: {
suggestionsList() {
const unsupportedFnList = this.sqlUi.getUnsupportedFnList();
return [
...this.availableFunctions
.filter(fn => !unsupportedFnList.includes(fn))
.map(fn => ({
text: fn + '()',
type: 'function',
description: formulas[fn].description,
syntax: formulas[fn].syntax,
examples: formulas[fn].examples,
})),
...this.meta.columns
.filter(
c =>
!this.column || (this.column.id !== c.id && !(c.uidt === UITypes.LinkToAnotherRecord && c.system === 1))
)
.map(c => ({
text: c.title,
type: 'column',
icon: getUIDTIcon(c.uidt),
c,
})),
...this.availableBinOps.map(op => ({
text: op,
type: 'op',
})),
];
},
acTree() {
const ref = new NcAutocompleteTree();
for (const sug of this.suggestionsList) {
ref.add(sug);
}
return ref;
},
},
watch: {
value(v, o) {
if (v !== o) {
this.formula = this.formula || {};
this.formula.value = v || '';
}
},
'formula.value'(v, o) {
if (v !== o) {
this.$emit('input', v);
}
},
},
created() {
this.formula = { value: this.value || '' };
jsep.plugins.register(jsepCurlyHook);
},
methods: {
async save() {
try {
const formulaCol = {
title: this.alias,
uidt: UITypes.Formula,
formula_raw: this.formula.value,
};
const col = await this.$api.dbTableColumn.create(this.meta.id, formulaCol);
this.$toast.success('Formula column saved successfully').goAway(3000);
return this.$emit('saved', this.alias);
} catch (e) {
this.$toast.error(await this._extractSdkResponseErrorMsg(e)).goAway(3000);
}
},
async update() {
try {
const meta = JSON.parse(JSON.stringify(this.$store.state.meta.metas[this.meta.table_name]));
const col = meta.v.find(c => c.title === this.column.title && c.formula);
Object.assign(col, {
_cn: this.alias,
formula: {
...this.formula,
tree: jsep(this.formula.value),
error: undefined,
},
});
await this.$store.dispatch('sqlMgr/ActSqlOp', [
{
env: this.nodes.env,
dbAlias: this.nodes.dbAlias,
},
'xcModelSet',
{
tn: this.nodes.table_name,
meta,
},
]);
this.$toast.success('Formula column updated successfully').goAway(3000);
} catch (e) {
this.$toast.error(e.message).goAway(3000);
}
},
parseAndValidateFormula(formula) {
try {
const parsedTree = jsep(formula);
const metaErrors = this.validateAgainstMeta(parsedTree);
if (metaErrors.size) {
return [...metaErrors].join(', ');
}
return true;
} catch (e) {
return e.message;
}
},
validateAgainstMeta(parsedTree, errors = new Set(), typeErrors = new Set()) {
if (parsedTree.type === jsep.CALL_EXP) {
// validate function name
if (!this.availableFunctions.includes(parsedTree.callee.name)) {
errors.add(`'${parsedTree.callee.name}' function is not available`);
}
// validate arguments
const validation = formulas[parsedTree.callee.name] && formulas[parsedTree.callee.name].validation;
if (validation && validation.args) {
if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required ${validation.args.rqd} arguments`);
} else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required minimum ${validation.args.min} arguments`);
} else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required maximum ${validation.args.max} arguments`);
}
}
parsedTree.arguments.map(arg => this.validateAgainstMeta(arg, errors));
// validate data type
if (parsedTree.callee.type === jsep.IDENTIFIER) {
const expectedType = formulas[parsedTree.callee.name].type;
if (expectedType === formulaTypes.NUMERIC) {
if (parsedTree.callee.name === 'WEEKDAY') {
// parsedTree.arguments[0] = date
this.validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
v => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The first parameter of WEEKDAY() should have date value');
}
},
typeErrors
);
// parsedTree.arguments[1] = startDayOfWeek (optional)
this.validateAgainstType(
parsedTree.arguments[1],
formulaTypes.STRING,
v => {
if (
typeof v !== 'string' ||
!['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(
v.toLowerCase()
)
) {
typeErrors.add(
'The second parameter of WEEKDAY() should have the value either "sunday", "monday", "tuesday", "wednesday", "thursday", "friday" or "saturday"'
);
}
},
typeErrors
);
} else {
parsedTree.arguments.map(arg => this.validateAgainstType(arg, expectedType, null, typeErrors));
}
} else if (expectedType === formulaTypes.DATE) {
if (parsedTree.callee.name === 'DATEADD') {
// parsedTree.arguments[0] = date
this.validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
v => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The first parameter of DATEADD() should have date value');
}
},
typeErrors
);
// parsedTree.arguments[1] = numeric
this.validateAgainstType(
parsedTree.arguments[1],
formulaTypes.NUMERIC,
v => {
if (typeof v !== 'number') {
typeErrors.add('The second parameter of DATEADD() should have numeric value');
}
},
typeErrors
);
// parsedTree.arguments[2] = ["day" | "week" | "month" | "year"]
this.validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
v => {
if (!['day', 'week', 'month', 'year'].includes(v)) {
typeErrors.add(
'The third parameter of DATEADD() should have the value either "day", "week", "month" or "year"'
);
}
},
typeErrors
);
}
}
}
errors = new Set([...errors, ...typeErrors]);
} else if (parsedTree.type === jsep.IDENTIFIER) {
if (
this.meta.columns.filter(c => !this.column || this.column.id !== c.id).every(c => c.title !== parsedTree.name)
) {
errors.add(`Column '${parsedTree.name}' is not available`);
}
// check circular reference
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
// get all formula columns excluding itself
const formulaPaths = this.meta.columns
.filter(c => c.id !== this.column.id && c.uidt === UITypes.Formula)
.reduce((res, c) => {
// in `formula`, get all the target neighbours
// i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type
const neighbours = (c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(
colId => this.meta.columns.filter(col => col.id === colId && col.uidt === UITypes.Formula).length
);
if (neighbours.length > 0) {
// e.g. formula column 1 -> [formula column 2, formula column3]
res.push({ [c.id]: neighbours });
}
return res;
}, []);
// include target formula column (i.e. the one to be saved if applicable)
const targetFormulaCol = this.meta.columns.find(c => c.title === parsedTree.name && c.uidt === UITypes.Formula);
if (targetFormulaCol) {
formulaPaths.push({
[this.column.id]: [targetFormulaCol.id],
});
}
const vertices = formulaPaths.length;
if (vertices > 0) {
// perform kahn's algo for cycle detection
const adj = new Map();
const inDegrees = new Map();
// init adjacency list & indegree
for (const [_, v] of Object.entries(formulaPaths)) {
const src = Object.keys(v)[0];
const neighbours = v[src];
inDegrees.set(src, inDegrees.get(src) || 0);
for (const neighbour of neighbours) {
adj.set(src, (adj.get(src) || new Set()).add(neighbour));
inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1);
}
}
const queue = [];
// put all vertices with in-degree = 0 (i.e. no incoming edges) to queue
inDegrees.forEach((inDegree, col) => {
if (inDegree === 0) {
// in-degree = 0 means we start traversing from this node
queue.push(col);
}
});
// init count of visited vertices
let visited = 0;
// BFS
while (queue.length !== 0) {
// remove a vertex from the queue
const src = queue.shift();
// if this node has neighbours, increase visited by 1
const neighbours = adj.get(src) || new Set();
if (neighbours.size > 0) {
visited += 1;
}
// iterate each neighbouring nodes
neighbours.forEach(neighbour => {
// decrease in-degree of its neighbours by 1
inDegrees.set(neighbour, inDegrees.get(neighbour) - 1);
// if in-degree becomes 0
if (inDegrees.get(neighbour) === 0) {
// then put the neighboring node to the queue
queue.push(neighbour);
}
});
}
// vertices not same as visited = cycle found
if (vertices !== visited) {
errors.add('Can’t save field because it causes a circular reference');
}
}
} else if (parsedTree.type === jsep.BINARY_EXP) {
if (!this.availableBinOps.includes(parsedTree.operator)) {
errors.add(`'${parsedTree.operator}' operation is not available`);
}
this.validateAgainstMeta(parsedTree.left, errors);
this.validateAgainstMeta(parsedTree.right, errors);
} else if (parsedTree.type === jsep.LITERAL || parsedTree.type === jsep.UNARY_EXP) {
// do nothing
} else if (parsedTree.type === jsep.COMPOUND) {
if (parsedTree.body.length) {
errors.add('Can’t save field because the formula is invalid');
}
} else {
errors.add('Can’t save field because the formula is invalid');
}
return errors;
},
validateAgainstType(parsedTree, expectedType, func, typeErrors = new Set()) {
if (parsedTree === false || typeof parsedTree === 'undefined') {
return typeErrors;
}
if (parsedTree.type === jsep.LITERAL) {
if (typeof func === 'function') {
func(parsedTree.value);
} else if (expectedType === formulaTypes.NUMERIC) {
if (typeof parsedTree.value !== 'number') {
typeErrors.add('Numeric type is expected');
}
} else if (expectedType === formulaTypes.STRING) {
if (typeof parsedTree.value !== 'string') {
typeErrors.add('string type is expected');
}
}
} else if (parsedTree.type === jsep.IDENTIFIER) {
const col = this.meta.columns.find(c => c.title === parsedTree.name);
if (col === undefined) {
return;
}
if (col.uidt === UITypes.Formula) {
const foundType = this.getRootDataType(jsep(col.colOptions.formula_raw));
if (foundType === 'N/A') {
typeErrors.add(`Not supported to reference column ${c.title}`);
} else if (expectedType !== foundType) {
typeErrors.add(`Type ${expectedType} is expected but found Type ${foundType}`);
}
} else {
switch (col.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
if (expectedType !== formulaTypes.STRING) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.STRING} type is found but ${expectedType} type is expected`
);
}
break;
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
case UITypes.Currency:
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`
);
}
break;
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
if (expectedType !== formulaTypes.DATE) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.DATE} type is found but ${expectedType} type is expected`
);
}
break;
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
default:
typeErrors.add(`Not supported to reference column '${parsedTree.name}'`);
break;
}
}
} else if (parsedTree.type === jsep.UNARY_EXP || parsedTree.type === jsep.BINARY_EXP) {
if (expectedType !== formulaTypes.NUMERIC) {
// parsedTree.name won't be available here
typeErrors.add(`${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`);
}
} else if (parsedTree.type === jsep.CALL_EXP) {
if (formulas[parsedTree.callee.name]?.type && expectedType !== formulas[parsedTree.callee.name].type) {
typeErrors.add(`${expectedType} not matched with ${formulas[parsedTree.callee.name].type}`);
}
}
return typeErrors;
},
getRootDataType(parsedTree) {
// given a parse tree, return the data type of it
if (parsedTree.type === jsep.CALL_EXP) {
return formulas[parsedTree.callee.name].type;
} else if (parsedTree.type === jsep.IDENTIFIER) {
const col = this.meta.columns.find(c => c.title === parsedTree.name);
if (col.uidt === UITypes.Formula) {
return this.getRootDataType(jsep(col.colOptions.formula_raw));
} else {
switch (col.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
return formulaTypes.STRING;
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
return formulaTypes.NUMERIC;
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
return formulaTypes.DATE;
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
default:
return 'N/A';
}
}
} else if (parsedTree.type === jsep.BINARY_EXP || parsedTree.type === jsep.UNARY_EXP) {
return formulaTypes.NUMERIC;
} else if (parsedTree.type === jsep.LITERAL) {
return typeof parsedTree.value;
} else {
return 'N/A';
}
},
isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets
const cntCurlyBrackets = (this.$refs.input.$el.querySelector('input').value.match(/\{|}/g) || []).reduce(
(acc, cur) => ((acc[cur] = (acc[cur] || 0) + 1), acc),
{}
);
return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0);
},
appendText(it) {
const text = it.text;
const len = this.wordToComplete.length;
if (it.type === 'function') {
this.$set(this.formula, 'value', insertAtCursor(this.$refs.input.$el.querySelector('input'), text, len, 1));
} else if (it.type === 'column') {
this.$set(
this.formula,
'value',
insertAtCursor(
this.$refs.input.$el.querySelector('input'),
'{' + text + '}',
len + !this.isCurlyBracketBalanced()
)
);
} else {
this.$set(this.formula, 'value', insertAtCursor(this.$refs.input.$el.querySelector('input'), text, len));
}
this.autocomplete = false;
this.suggestion = null;
},
_handleInputDeb: debounce(async function (self) {
await self.handleInput();
}, 250),
handleInputDeb() {
this._handleInputDeb(this);
},
handleInput() {
this.selected = 0;
this.suggestion = null;
const query = getWordUntilCaret(this.$refs.input.$el.querySelector('input'));
const parts = query.split(/\W+/);
this.wordToComplete = parts.pop();
this.suggestion = this.acTree
.complete(this.wordToComplete)
?.sort((x, y) => this.sortOrder[x.type] - this.sortOrder[y.type]);
if (!this.isCurlyBracketBalanced()) {
this.suggestion = this.suggestion.filter(v => v.type === 'column');
}
this.autocomplete = !!this.suggestion.length;
},
selectText() {
if (this.suggestion && this.selected > -1 && this.selected < this.suggestion.length) {
this.appendText(this.suggestion[this.selected]);
}
},
suggestionListDown() {
if (this.suggestion) {
this.selected = ++this.selected % this.suggestion.length;
this.scrollToSelectedOption();
}
},
suggestionListUp() {
if (this.suggestion) {
this.selected = --this.selected > -1 ? this.selected : this.suggestion.length - 1;
this.scrollToSelectedOption();
}
},
scrollToSelectedOption() {
this.$nextTick(() => {
if (this.$refs.sugOptions[this.selected]) {
try {
this.$refs.sugList.$el.scrollTo({
top: this.$refs.sugOptions[this.selected].$el.offsetTop,
behavior: 'smooth',
});
} catch (e) {}
}
});
},
},
};
</script>
<style scoped lang="scss">
::v-deep {
.formula-suggestion .v-card__text {
font-size: 0.75rem;
padding: 8px;
text-align: center;
}
}
.hint {
font-size: 0.75rem;
line-height: normal;
padding: 10px 5px;
}
</style>