Browse Source

Merge pull request #1979 from nocodb/fix/formula

fix: formula
pull/2074/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
3b57dfbb16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/nc-gui/components/project/spreadsheet/RowsXcDataTable.vue
  2. 2
      packages/nc-gui/components/project/spreadsheet/components/EditVirtualColumn.vue
  3. 163
      packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue
  4. 18
      packages/nc-gui/package-lock.json
  5. 2
      packages/nc-gui/package.json
  6. 109
      packages/noco-docs/content/en/setup-and-usages/formulas.md
  7. 14
      packages/nocodb-sdk/package-lock.json
  8. 2
      packages/nocodb-sdk/package.json
  9. 102
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  10. 18
      packages/nocodb/package-lock.json
  11. 2
      packages/nocodb/package.json
  12. 10
      packages/nocodb/src/lib/dataMapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  13. 67
      packages/nocodb/src/lib/noco/meta/api/columnApis.ts
  14. 65
      packages/nocodb/src/lib/noco/meta/helpers/formulaHelpers.ts
  15. 8
      packages/nocodb/src/lib/noco/upgrader/jobs/ncProjectUpgraderV2_0090000.ts
  16. 13
      scripts/cypress/integration/common/3b_formula_column.js

2
packages/nc-gui/components/project/spreadsheet/RowsXcDataTable.vue

@ -1439,7 +1439,7 @@ export default {
}))
// }
} catch (e) {
console.log(e)
this.$toast.error(await this._extractSdkResponseErrorMsg(e)).goAway(3000)
}
this.loadingData = false
},

2
packages/nc-gui/components/project/spreadsheet/components/EditVirtualColumn.vue

@ -7,7 +7,7 @@
class=" card nc-col-create-or-edit-card"
>
<v-form v-model="valid" @submit.prevent="save">
<v-container fluid @click.stop.prevent>
<v-container fluid @click.stop>
<v-row>
<v-col cols="12">
<v-text-field

163
packages/nc-gui/components/project/spreadsheet/components/editColumn/FormulaOptions.vue

@ -1,35 +1,28 @@
<template>
<div class="formula-wrapper">
<v-menu
v-model="autocomplete"
bottom
offset-y
nudge-bottom="-25px"
allow-overflow
>
<template #activator="_args">
<!-- todo: autocomplete based on available functions and metadata -->
<!-- <v-tooltip color="info" right>-->
<!-- <template #activator="{on}">-->
<v-text-field
ref="input"
v-model="formula.value"
dense
outlined
class="caption"
hide-details="auto"
label="Formula"
persistent-hint
hint="Available formulas are ADD, AVG, CONCAT, +, -, /"
:rules="[v => !!v || 'Required', v => parseAndValidateFormula(v)]"
autocomplete="off"
@input="handleInputDeb"
@keydown.down.prevent="suggestionListDown"
@keydown.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText"
/>
</template>
<v-list v-if="suggestion" ref="sugList" dense max-height="50vh" style="overflow: auto">
<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"
@ -42,16 +35,59 @@
selectable
@mousedown.prevent="appendText(it)"
>
<span
class="caption"
:class="{
'primary--text text--lighten-2 font-weight-bold': it.type ==='function'
}"
>{{ it.text }}<span v-if="it.type ==='function'">(...)</span></span>
<!-- Function -->
<template v-if="it.type ==='function'">
<v-list-item-content>
<span
class="caption primary--text text--lighten-2 font-weight-bold"
>
{{ it.text }}
</span>
</v-list-item-content>
<v-list-item-action>
<span class="caption">
Function
</span>
</v-list-item-action>
</template>
<!-- Column -->
<template v-if="it.type ==='column'">
<v-list-item-content>
<span
class="caption text--darken-3 font-weight-bold"
>
{{ 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"
>
{{ 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-menu>
</v-card>
</div>
</template>
@ -59,7 +95,7 @@
import debounce from 'debounce'
import jsep from 'jsep'
import { UITypes } from 'nocodb-sdk'
import { UITypes, jsepCurlyHook } from 'nocodb-sdk'
import formulaList, { validations } from '../../../../../helpers/formulaList'
import { getWordUntilCaret, insertAtCursor } from '@/helpers'
import NcAutocompleteTree from '@/helpers/NcAutocompleteTree'
@ -76,14 +112,19 @@ export default {
suggestion: null,
wordToComplete: '',
selected: 0,
tooltip: true
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,
text: fn + '()',
type: 'function'
})),
...this.meta.columns.filter(c => !this.column || this.column.id !== c.id).map(c => ({
@ -120,6 +161,7 @@ export default {
},
created() {
this.formula = { value: this.value || '' }
jsep.plugins.register(jsepCurlyHook)
},
methods: {
async save() {
@ -135,7 +177,7 @@ export default {
this.$toast.success('Formula column saved successfully').goAway(3000)
return this.$emit('saved', this.alias)
} catch (e) {
this.$toast.error(e.message).goAway(3000)
this.$toast.error(await this._extractSdkResponseErrorMsg(e)).goAway(3000)
}
},
async update() {
@ -165,7 +207,6 @@ export default {
this.$toast.error(e.message).goAway(3000)
}
},
// todo: validate formula based on meta
parseAndValidateFormula(formula) {
try {
const pt = jsep(formula)
@ -196,7 +237,7 @@ export default {
pt.arguments.map(arg => this.validateAgainstMeta(arg, arr))
} else if (pt.type === 'Identifier') {
if (this.meta.columns.filter(c => !this.column || this.column.id !== c.id).every(c => c.title !== pt.name)) {
arr.push(`Column with name '${pt.name}' is not available`)
arr.push(`Column '${pt.name}' is not available`)
}
} else if (pt.type === 'BinaryExpression') {
if (!this.availableBinOps.includes(pt.operator)) {
@ -211,10 +252,14 @@ export default {
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))
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))
} 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()
@ -228,22 +273,25 @@ export default {
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)
this.suggestion = this.acTree.complete(this.wordToComplete)?.sort((x, y) => this.sortOrder[x.type] - this.sortOrder[y.type])
this.autocomplete = !!this.suggestion.length
},
selectText() {
if (this.selected > -1 && this.selected < this.suggestion.length) {
if (this.suggestion && this.selected > -1 && this.selected < this.suggestion.length) {
this.appendText(this.suggestion[this.selected])
this.autocomplete = false
}
},
suggestionListDown() {
this.selected = ++this.selected % this.suggestion.length
this.scrollToSelectedOption()
if (this.suggestion) {
this.selected = ++this.selected % this.suggestion.length
this.scrollToSelectedOption()
}
},
suggestionListUp() {
this.selected = --this.selected > -1 ? this.selected : this.suggestion.length - 1
this.scrollToSelectedOption()
if (this.suggestion) {
this.selected = --this.selected > -1 ? this.selected : this.suggestion.length - 1
this.scrollToSelectedOption()
}
},
scrollToSelectedOption() {
this.$nextTick(() => {
@ -263,4 +311,17 @@ export default {
</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>

18
packages/nc-gui/package-lock.json generated

@ -22,7 +22,7 @@
"fix-path": "^3.0.0",
"httpsnippet": "^2.0.0",
"inflection": "^1.12.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"material-design-icons-iconfont": "^5.0.1",
"monaco-editor": "^0.19.3",
"monaco-themes": "^0.2.5",
@ -76,7 +76,7 @@
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^0.4.0"
"jsep": "^1.3.6"
},
"devDependencies": {
"@ava/typescript": "^1.1.1",
@ -9952,9 +9952,9 @@
}
},
"node_modules/jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA==",
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w==",
"engines": {
"node": ">= 10.16.0"
}
@ -24714,9 +24714,9 @@
}
},
"jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA=="
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w=="
},
"jsesc": {
"version": "2.5.2",
@ -25389,7 +25389,7 @@
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"gh-pages": "^3.1.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"open-cli": "^6.0.1",

2
packages/nc-gui/package.json

@ -25,7 +25,7 @@
"fix-path": "^3.0.0",
"httpsnippet": "^2.0.0",
"inflection": "^1.12.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"material-design-icons-iconfont": "^5.0.1",
"monaco-editor": "^0.19.3",
"monaco-themes": "^0.2.5",

109
packages/noco-docs/content/en/setup-and-usages/formulas.md

@ -18,10 +18,10 @@ menuTitle: "Formulas"
### 4. Insert required formula
- Can use column names in equation
- Can use explicit numberical values/ strings as needed
- You can use explicit numerical values/ strings as needed, e.g. `123` (numeric) or `"123"` (string).
- You can reference column names in equation with `{}`, e.g. `{column_name}`, if the column name conflicts with literals
- Table below lists supported formula & associated syntax
- Nested formula (formula equation referring to another formula column) are not supported
- Nested formula (formula equation referring to another formula column) is supported
### 5. Click on 'Save'
@ -31,99 +31,98 @@ menuTitle: "Formulas"
| Name | Syntax | Sample | Output |
|-------------|----------------------------|----------------------------------|------------------------------------------------------------------|
| **ABS** | `ABS(value)` | `ABS(Column)` | Absolute value of the input parameter |
| **ADD** | `ADD(value1,[value2,...])` | `ADD(Column1, Column1)` | Sum of input parameters |
| **AVG** | `AVG(value1,[value2,...])` | `AVG(Column1, Column1)` | Average of input parameters |
| **CEILING** | `CEILING(value)` | `CEILING(Column)` | Rounded next largest integer value of input parameter |
| **EXP** | `EXP(value)` | `EXP(Column)` | Exponential value of input parameter (`e^x`) |
| **FLOOR** | `FLOOR(value)` | `FLOOR(Column)` | Rounded largest integer less than or equal to input parameter |
| **INT** | `INT(value)` | `INT(Column)` | Integer value of input parameter |
| **LOG** | `LOG([base], value)` | `LOG(10, Column)` | Logarithm of input parameter to the base (default = e) specified |
| **MAX** | `MAX(value1,[value2,...])` | `MAX(Column1, Column2, Column3)` | Maximum value amongst input parameters |
| **MIN** | `MIN(value1,[value2,...])` | `MIN(Column1, Column2, Column3)` | Minimum value amongst input parameters |
| **MOD** | `MOD(value1, value2)` | `MOD(Column, 2)` | Remainder after integer division of input parameters |
| **POWER** | `POWER(base, exponent)` | `POWER(Column, 3)` | `base` to the `exponent` power, as in `base^exponent` |
| **ROUND** | `ROUND(value)` | `ROUND(Column)` | Nearest integer to the input parameter |
| **SQRT** | `SQRT(value)` | `SQRT(Column)` | Square root of the input parameter |
| **ABS** | `ABS(value)` | `ABS({Column})` | Absolute value of the input parameter |
| **ADD** | `ADD(value1,[value2,...])` | `ADD({Column1}, {Column2})` | Sum of input parameters |
| **AVG** | `AVG(value1,[value2,...])` | `AVG({Column1}, {Column2})` | Average of input parameters |
| **CEILING** | `CEILING(value)` | `CEILING({Column})` | Rounded next largest integer value of input parameter |
| **EXP** | `EXP(value)` | `EXP({Column})` | Exponential value of input parameter (`e^x`) |
| **FLOOR** | `FLOOR(value)` | `FLOOR({Column})` | Rounded largest integer less than or equal to input parameter |
| **INT** | `INT(value)` | `INT({Column})` | Integer value of input parameter |
| **LOG** | `LOG([base], value)` | `LOG(10, {Column})` | Logarithm of input parameter to the base (default = e) specified |
| **MAX** | `MAX(value1,[value2,...])` | `MAX({Column1}, {Column2}, {Column3})` | Maximum value amongst input parameters |
| **MIN** | `MIN(value1,[value2,...])` | `MIN({Column1}, {Column2}, {Column3})` | Minimum value amongst input parameters |
| **MOD** | `MOD(value1, value2)` | `MOD({Column}, 2)` | Remainder after integer division of input parameters |
| **POWER** | `POWER(base, exponent)` | `POWER({Column}, 3)` | `base` to the `exponent` power, as in `base ^ exponent` |
| **ROUND** | `ROUND(value)` | `ROUND({Column})` | Nearest integer to the input parameter |
| **SQRT** | `SQRT(value)` | `SQRT({Column})` | Square root of the input parameter |
### Numeric Operators
| Operator | Sample | Description |
| -------- | ----------------------- | -------------------------------- |
| `+` | `column1 + column2 + 2` | Addition of numeric values |
| `-` | `column1 - column2` | Subtraction of numeric values |
| `*` | `column1 * column2` | Multiplication of numeric values |
| `/` | `column1 / column2` | Division of numeric values |
| `+` | `{Column1} + {Column2} + 2` | Addition of numeric values |
| `-` | `{Column1} - {Column2}` | Subtraction of numeric values |
| `*` | `{Column1} * {Column2}` | Multiplication of numeric values |
| `/` | `{Column1} / {Column2}` | Division of numeric values |
<alert type="success">
Tip :To change the order of arithmetic operation, you can use round bracket parantheses (). <br/>
Example: (column1 + (column2 * column3) / (3 - column4 ))
Example: ({Column1} + ({Column2} * {Column3}) / (3 - $Column4$ ))
</alert>
### String Functions
| Name | Syntax | Sample | Output |
|-------------|----------------------------------|---------------------------------|---------------------------------------------------------------------------|
| **CONCAT** | `CONCAT(str1, [str2,...])` | `CONCAT(fName, ' ', lName)` | Concatenated string of input parameters |
| **LEFT** | `LEFT(str1, [str2,...])` | `LEFT(Column, 3)` | `n` characters from the beginning of input parameter |
| **LEN** | `LEN(str)` | `LEN(Title)` | Input parameter character length |
| **LOWER** | `LOWER(str)` | `LOWER(Title)` | Lower case converted string of input parameter |
| **MID** | `SUBTR(str, position, [count])` | `MID(Column, 3, 2)` | Alias for `SUBSTR` |
| **REPEAT** | `REPEAT(str, count)` | `REPEAT(Column, 2)` | Specified copies of the input parameter string concatenated together |
| **REPLACE** | `REPLACE(str, srchStr, rplcStr)` | `REPLACE(Column, 'int', 'num')` | String, after replacing all occurrences of `srchStr` with `rplcStr` |
| **RIGHT** | `RIGHT(str, count)` | `RIGHT(Column, 3)` | `n` characters from the end of input parameter |
| **SEARCH** | `SEARCH(str, srchStr)` | `SEARCH(Column, 'str')` | Index of `srchStr` specified if found, 0 otherwise |
| **SUBSTR** | `SUBTR(str, position, [count])` | `SUBSTR(Column, 3, 2)` | Substring of length 'count' of input string, from the postition specified |
| **TRIM** | `TRIM(str)` | `TRIM(Title)` | Remove trailing and leading whitespaces from input parameter |
| **UPPER** | `UPPER(str)` | `UPPER(Title)` | Upper case converted string of input parameter |
| **URL** | `URL(str)` | `URL(Column)` | Convert to a hyperlink if it is a valid URL |
| **CONCAT** | `CONCAT(str1, [str2,...])` | `CONCAT({Column1}, ' ', {Column2})` | Concatenated string of input parameters |
| **LEFT** | `LEFT(str1, [str2,...])` | `LEFT({Column}, 3)` | `n` characters from the beginning of input parameter |
| **LEN** | `LEN(str)` | `LEN({Column})` | Input parameter character length |
| **LOWER** | `LOWER(str)` | `LOWER({Column})` | Lower case converted string of input parameter |
| **MID** | `SUBTR(str, position, [count])` | `MID({Column}, 3, 2)` | Alias for `SUBSTR` |
| **REPEAT** | `REPEAT(str, count)` | `REPEAT({Column}, 2)` | Specified copies of the input parameter string concatenated together |
| **REPLACE** | `REPLACE(str, srchStr, rplcStr)` | `REPLACE({Column}, 'int', 'num')` | String, after replacing all occurrences of `srchStr` with `rplcStr` |
| **RIGHT** | `RIGHT(str, count)` | `RIGHT({Column}, 3)` | `n` characters from the end of input parameter |
| **SEARCH** | `SEARCH(str, srchStr)` | `SEARCH({Column}, 'str')` | Index of `srchStr` specified if found, 0 otherwise |
| **SUBSTR** | `SUBTR(str, position, [count])` | `SUBSTR({Column}, 3, 2)` | Substring of length 'count' of input string, from the postition specified |
| **TRIM** | `TRIM(str)` | `TRIM({Column})` | Remove trailing and leading whitespaces from input parameter |
| **UPPER** | `UPPER(str)` | `UPPER({Column})` | Upper case converted string of input parameter |
| **URL** | `URL(str)` | `URL({Column})` | Convert to a hyperlink if it is a valid URL |
### Date Functions
| Name | Syntax | Sample | Output | Remark |
|---|---|---|---|---|
| **DATEADD** | `DATEADD(DATE_COL, 1, 'day')` | `DATEADD(date, 1, 'day')` | Supposing the DATE_COL is 2022-03-14. The result is 2022-03-15. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'day')` |
| | `DATEADD(DATE_COL, 2, 'month')` | `DATEADD(date, 2, 'month')` | Supposing the DATE_COL is 2022-03-14 03:14. The result is 2022-05-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -2, 'month')` |
| | `IF(NOW() < DATE_COL, "true", "false")` | `IF(NOW() < date, "true", "false")` | If current date is less than DATE_COL, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
| | `IF(NOW() < DATEADD(DATE_COL,10,'day'), "true", "false")` | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than DATE_COL plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
| **DATEADD** | `DATEADD({DATE_COL}, 1, 'day')` | `DATEADD(date, 1, 'day')` | Supposing {DATE_COL} is 2022-03-14. The result is 2022-03-15. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -1, 'day')` |
| | `DATEADD({DATE_COL}, 2, 'month')` | `DATEADD(date, 2, 'month')` | Supposing {DATE_COL} is 2022-03-14 03:14. The result is 2022-05-14 03:14. | DateTime columns and negative values are supported. Example: `DATEADD(DATE_TIME_COL, -2, 'month')` |
| | `IF(NOW() < {DATE_COL}, "true", "false")` | `IF(NOW() < date, "true", "false")` | If current date is less than {DATE_COL}, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
| | `IF(NOW() < DATEADD({DATE_COL},10,'day'), "true", "false")` | `IF(NOW() < DATEADD(date,10,'day'), "true", "false")` | If the current date is less than {DATE_COL} plus 10 days, it returns true. Otherwise, it returns false. | DateTime columns and negative values are supported. |
### Logical Operators
| Operator | Sample | Description |
| -------- | -------------------- | ------------------------ |
| `<` | `column1 < column2` | Less than |
| `>` | `column1 > column2` | Greater than |
| `<=` | `column1 <= column2` | Less than or equal to |
| `>=` | `column1 >= column2` | Greater than or equal to |
| `==` | `column1 == column2` | Equal to |
| `!=` | `column1 != column2` | Not equal to |
| `<` | `{Column1} < {Column2}` | Less than |
| `>` | `{Column1} > {Column2}` | Greater than |
| `<=` | `{Column1} <= {Column2}` | Less than or equal to |
| `>=` | `{Column1} >= {Column2}` | Greater than or equal to |
| `==` | `{Column1} == {Column2}` | Equal to |
| `!=` | `{Column1} != {Column2}` | Not equal to |
### Conditional Expressions
| Name | Syntax | Sample | Output |
|------------|------------------------------------------------|---------------------------------------------|-------------------------------------------------------------|
| **IF** | `IF(expr, successCase, [failCase])` | `IF(Column > 1, Value1, Value2)` | successCase if `expr` evaluates to TRUE, elseCase otherwise |
| **SWITCH** | `SWITCH(expr, [pattern, value, ..., default])` | `SWITCH(Column1, 1, 'One', 2, 'Two', '--')` | Switch case value based on `expr` output |
| **AND** | `AND(expr1, [expr2,...])` | `AND(Column > 2, Column < 10)` | TRUE if all `expr` evaluate to TRUE |
| **OR** | `OR(expr1, [expr2,...])` | `OR(Column > 2, Column < 10)` | TRUE if at least one `expr` evaluates to TRUE |
| **IF** | `IF(expr, successCase, [failCase])` | `IF({Column} > 1, Value1, Value2)` | successCase if `expr` evaluates to TRUE, elseCase otherwise |
| **SWITCH** | `SWITCH(expr, [pattern, value, ..., default])` | `SWITCH({Column}, 1, 'One', 2, 'Two', '--')` | Switch case value based on `expr` output |
| **AND** | `AND(expr1, [expr2,...])` | `AND({Column} > 2, {Column} < 10)` | TRUE if all `expr` evaluate to TRUE |
| **OR** | `OR(expr1, [expr2,...])` | `OR({Column} > 2, {Column} < 10)` | TRUE if at least one `expr` evaluates to TRUE |
Logical operators, along with Numerical operators can be used to build conditional `expressions`.
Examples:
```bash
IF(marksSecured > 80, "GradeA", "GradeB")
```
IF({marksSecured} > 80, "GradeA", "GradeB")
```
```bash
SWITCH(quarterNumber,
```
SWITCH({quarterNumber},
1, 'Jan-Mar',
2, 'Apr-Jun',
3, 'Jul-Sep',
4, 'Oct-Dec',
'INVALID'
)
```
```

14
packages/nocodb-sdk/package-lock.json generated

@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^0.4.0"
"jsep": "^1.3.6"
},
"devDependencies": {
"@ava/typescript": "^1.1.1",
@ -6764,9 +6764,9 @@
}
},
"node_modules/jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA==",
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w==",
"engines": {
"node": ">= 10.16.0"
}
@ -15535,9 +15535,9 @@
}
},
"jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA=="
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w=="
},
"jsesc": {
"version": "2.5.2",

2
packages/nocodb-sdk/package.json

@ -44,7 +44,7 @@
},
"dependencies": {
"axios": "^0.21.1",
"jsep": "^0.4.0"
"jsep": "^1.3.6"
},
"devDependencies": {
"@ava/typescript": "^1.1.1",

102
packages/nocodb-sdk/src/lib/formulaHelpers.ts

@ -1,6 +1,67 @@
import jsep from 'jsep';
import { ColumnType } from './Api';
export const jsepCurlyHook = {
name: 'curly',
init(jsep) {
jsep.hooks.add('gobble-token', function gobbleCurlyLiteral(env) {
const OCURLY_CODE = 123; // {
const CCURLY_CODE = 125; // }
const { context } = env;
if (
!jsep.isIdentifierStart(context.code) &&
context.code === OCURLY_CODE
) {
context.index += 1;
const nodes = context.gobbleExpressions(CCURLY_CODE);
if (context.code === CCURLY_CODE) {
context.index += 1;
env.node = {
type: jsep.IDENTIFIER,
name: nodes.map((node) => node.name).join(' '),
};
return env.node;
} else {
context.throwError('Unclosed }');
}
}
});
},
} as jsep.IPlugin;
export async function substituteColumnAliasWithIdInFormula(
formula,
columns: ColumnType[]
) {
const substituteId = async (pt: any) => {
if (pt.type === 'CallExpression') {
for (const arg of pt.arguments || []) {
await substituteId(arg);
}
} else if (pt.type === 'Literal') {
return;
} else if (pt.type === 'Identifier') {
const colNameOrId = pt.name;
const column = columns.find(
(c) =>
c.id === colNameOrId ||
c.column_name === colNameOrId ||
c.title === colNameOrId
);
pt.name = '{' + column.id + '}';
} else if (pt.type === 'BinaryExpression') {
await substituteId(pt.left);
await substituteId(pt.right);
}
};
// register jsep curly hook
jsep.plugins.register(jsepCurlyHook);
const parsedFormula = jsep(formula);
await substituteId(parsedFormula);
return jsepTreeToFormula(parsedFormula);
}
export function substituteColumnIdWithAliasInFormula(
formula,
columns: ColumnType[],
@ -15,7 +76,7 @@ export function substituteColumnIdWithAliasInFormula(
} else if (pt.type === 'Literal') {
return;
} else if (pt.type === 'Identifier') {
const colNameOrId = pt.name;
const colNameOrId = pt?.name;
const column = columns.find(
(c) =>
c.id === colNameOrId ||
@ -29,6 +90,8 @@ export function substituteColumnIdWithAliasInFormula(
}
};
// register jsep curly hook
jsep.plugins.register(jsepCurlyHook);
const parsedFormula = jsep(formula);
const parsedRawFormula = rawFormula && jsep(rawFormula);
substituteId(parsedFormula, parsedRawFormula);
@ -62,6 +125,43 @@ export function jsepTreeToFormula(node) {
}
if (node.type === 'Identifier') {
const formulas = [
'AVG',
'ADD',
'DATEADD',
'AND',
'OR',
'CONCAT',
'TRIM',
'UPPER',
'LOWER',
'LEN',
'MIN',
'MAX',
'CEILING',
'FLOOR',
'ROUND',
'MOD',
'REPEAT',
'LOG',
'EXP',
'POWER',
'SQRT',
'SQRT',
'ABS',
'NOW',
'REPLACE',
'SEARCH',
'INT',
'RIGHT',
'LEFT',
'SUBSTR',
'MID',
'IF',
'SWITCH',
'URL',
];
if (!formulas.includes(node.name)) return '{' + node.name + '}';
return node.name;
}

18
packages/nocodb/package-lock.json generated

@ -52,7 +52,7 @@
"ioredis-mock": "^7.1.0",
"is-docker": "^2.2.1",
"js-beautify": "^1.11.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"json2csv": "^5.0.6",
"jsonfile": "^6.1.0",
"jsonwebtoken": "^8.5.1",
@ -160,7 +160,7 @@
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"jsep": "^0.4.0"
"jsep": "^1.3.6"
},
"devDependencies": {
"@ava/typescript": "^1.1.1",
@ -13834,9 +13834,9 @@
"optional": true
},
"node_modules/jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA==",
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w==",
"engines": {
"node": ">= 10.16.0"
}
@ -35846,9 +35846,9 @@
"optional": true
},
"jsep": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-0.4.0.tgz",
"integrity": "sha512-UDkrzhJK8hmgXeGK8WIiecc/cuW4Vnx5nnrRma7yaxK0WXlvZ4VerGrcxPzifd/CA6QdcI1hpXqr22tHKXpcQA=="
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w=="
},
"jsesc": {
"version": "2.5.2",
@ -37849,7 +37849,7 @@
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"gh-pages": "^3.1.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"open-cli": "^6.0.1",

2
packages/nocodb/package.json

@ -134,7 +134,7 @@
"ioredis-mock": "^7.1.0",
"is-docker": "^2.2.1",
"js-beautify": "^1.11.0",
"jsep": "^0.4.0",
"jsep": "^1.3.6",
"json2csv": "^5.0.6",
"jsonfile": "^6.1.0",
"jsonwebtoken": "^8.5.1",

10
packages/nocodb/src/lib/dataMapper/lib/sql/formulav2/formulaQueryBuilderv2.ts

@ -7,7 +7,7 @@ import FormulaColumn from '../../../../noco-models/FormulaColumn';
import { XKnex } from '../../..';
import LinkToAnotherRecordColumn from '../../../../noco-models/LinkToAnotherRecordColumn';
import LookupColumn from '../../../../noco-models/LookupColumn';
import { UITypes } from 'nocodb-sdk';
import { jsepCurlyHook, UITypes } from 'nocodb-sdk';
// todo: switch function based on database
@ -51,6 +51,8 @@ export default async function formulaQueryBuilderv2(
model: Model,
aliasToColumn = {}
) {
// register jsep curly hook
jsep.plugins.register(jsepCurlyHook);
const tree = jsep(_tree);
// todo: improve - implement a common solution for filter, sort, formula, etc
@ -647,7 +649,11 @@ export default async function formulaQueryBuilderv2(
return query;
} else if (pt.type === 'UnaryExpression') {
const query = knex.raw(
`${pt.operator}${fn(pt.argument, null, pt.operator).toQuery()}${colAlias}`
`${pt.operator}${fn(
pt.argument,
null,
pt.operator
).toQuery()}${colAlias}`
);
if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')');

67
packages/nocodb/src/lib/noco/meta/api/columnApis.ts

@ -3,7 +3,6 @@ import Model from '../../../noco-models/Model';
import ProjectMgrv2 from '../../../sqlMgr/v2/ProjectMgrv2';
import Base from '../../../noco-models/Base';
import Column from '../../../noco-models/Column';
import { substituteColumnAliasWithIdInFormula } from '../helpers/formulaHelpers';
import validateParams from '../helpers/validateParams';
import { Tele } from 'nc-help';
@ -19,6 +18,8 @@ import {
isVirtualCol,
LinkToAnotherRecordType,
RelationTypes,
substituteColumnAliasWithIdInFormula,
substituteColumnIdWithAliasInFormula,
TableType,
UITypes
} from 'nocodb-sdk';
@ -32,6 +33,8 @@ import getColumnPropsFromUIDT from '../helpers/getColumnPropsFromUIDT';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import NcConnectionMgrv2 from '../../common/NcConnectionMgrv2';
import { metaApiMetrics } from '../helpers/apiMetrics';
import FormulaColumn from '../../../noco-models/FormulaColumn';
import { MetaTable } from '../../../utils/globals';
const randomID = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 10);
@ -493,9 +496,12 @@ export async function columnAdd(req: Request, res: Response<TableType>) {
}> = (await sqlClient.columnList({ tn: table.table_name }))?.data?.list;
const insertedColumnMeta =
columns.find(c => c.cn === colBody.column_name) || {} as any;
columns.find(c => c.cn === colBody.column_name) || ({} as any);
if (colBody.uidt === UITypes.SingleSelect || colBody.uidt === UITypes.MultiSelect) {
if (
colBody.uidt === UITypes.SingleSelect ||
colBody.uidt === UITypes.MultiSelect
) {
insertedColumnMeta.dtxp = colBody.dtxp;
}
@ -610,20 +616,47 @@ export async function columnUpdate(req: Request, res: Response<TableType>) {
cn: c.column_name,
cno: c.column_name
})),
columns: table.columns.map(c => {
if (c.id === req.params.columnId) {
return {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN
};
} else {
(c as any).cn = c.column_name;
}
return c;
})
columns: await Promise.all(
table.columns.map(async c => {
if (c.id === req.params.columnId) {
const res = {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN
};
// update formula with new column name
if (c.column_name != colBody.column_name) {
const formulas = await Noco.ncMeta
.knex(MetaTable.COL_FORMULA)
.where('formula', 'like', `%${c.id}%`);
if (formulas) {
const new_column = c;
new_column.column_name = colBody.column_name;
new_column.title = colBody.title;
for (const f of formulas) {
// the formula with column IDs only
const formula = f.formula;
// replace column IDs with alias to get the new formula_raw
const new_formula_raw = substituteColumnIdWithAliasInFormula(
formula,
[new_column]
);
await FormulaColumn.update(f.id, {
formula_raw: new_formula_raw
});
}
}
}
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
})
)
};
const sqlMgr = await ProjectMgrv2.getSqlMgr({ id: base.project_id });

65
packages/nocodb/src/lib/noco/meta/helpers/formulaHelpers.ts

@ -1,65 +0,0 @@
import jsep from 'jsep';
import jsepTreeToFormula from '../../common/helpers/jsepTreeToFormula';
import Column from '../../../noco-models/Column';
export async function substituteColumnAliasWithIdInFormula(
formula,
columns: Column[]
) {
const substituteId = async (pt: any) => {
if (pt.type === 'CallExpression') {
for (const arg of pt.arguments || []) {
await substituteId(arg);
}
} else if (pt.type === 'Literal') {
return;
} else if (pt.type === 'Identifier') {
const colNameOrId = pt.name;
const column = columns.find(
c =>
c.id === colNameOrId ||
c.column_name === colNameOrId ||
c.title === colNameOrId
);
pt.name = column.id;
} else if (pt.type === 'BinaryExpression') {
await substituteId(pt.left);
await substituteId(pt.right);
}
};
const parsedFormula = jsep(formula);
await substituteId(parsedFormula);
return jsepTreeToFormula(parsedFormula);
}
export function substituteColumnIdWithAliasInFormula(
formula,
columns: Column[]
) {
const substituteId = (pt: any) => {
if (pt.type === 'CallExpression') {
for (const arg of pt.arguments || []) {
substituteId(arg);
}
} else if (pt.type === 'Literal') {
return;
} else if (pt.type === 'Identifier') {
const colNameOrId = pt.name;
const column = columns.find(
c =>
c.id === colNameOrId ||
c.column_name === colNameOrId ||
c.title === colNameOrId
);
pt.name = column.id;
} else if (pt.type === 'BinaryExpression') {
substituteId(pt.left);
substituteId(pt.right);
}
};
const parsedFormula = jsep(formula);
substituteId(parsedFormula);
return jsepTreeToFormula(parsedFormula);
}

8
packages/nocodb/src/lib/noco/upgrader/jobs/ncProjectUpgraderV2_0090000.ts

@ -4,11 +4,15 @@ import User from '../../../noco-models/User';
import Project from '../../../noco-models/Project';
import ProjectUser from '../../../noco-models/ProjectUser';
import Model from '../../../noco-models/Model';
import { ModelTypes, UITypes, ViewTypes } from 'nocodb-sdk';
import {
ModelTypes,
substituteColumnAliasWithIdInFormula,
UITypes,
ViewTypes
} from 'nocodb-sdk';
import Column from '../../../noco-models/Column';
import LinkToAnotherRecordColumn from '../../../noco-models/LinkToAnotherRecordColumn';
import NcHelp from '../../../utils/NcHelp';
import { substituteColumnAliasWithIdInFormula } from '../../meta/helpers/formulaHelpers';
import RollupColumn from '../../../noco-models/RollupColumn';
import View from '../../../noco-models/View';
import GridView from '../../../noco-models/GridView';

13
scripts/cypress/integration/common/3b_formula_column.js

@ -61,7 +61,7 @@ export const genTest = (apiType, dbType) => {
.contains("Formula")
.parent()
.click()
.type(formula)
.type(formula, { parseSpecialCharSequences: false })
.click();
// click on Save
@ -115,7 +115,7 @@ export const genTest = (apiType, dbType) => {
.parent()
.find("input")
.clear()
.type(newFormula)
.type(newFormula, { parseSpecialCharSequences: false })
.click();
cy.get(".nc-col-create-or-edit-card")
@ -189,7 +189,7 @@ export const genTest = (apiType, dbType) => {
it("Formula: ADD, AVG, LEN", () => {
addFormulaBasedColumn(
"NC_MATH_0",
"ADD(CityId, CountryId) + AVG(CityId, CountryId) + LEN(City)"
"ADD({CityId}, {CountryId}) + AVG({CityId}, {CountryId}) + LEN({City})"
);
rowValidation("NC_MATH_0", RESULT_MATH_0);
});
@ -198,7 +198,7 @@ export const genTest = (apiType, dbType) => {
editColumnByName(
"NC_MATH_0",
"NC_STR_1",
`CONCAT(UPPER(City), LOWER(City), TRIM(' trimmed '))`
`CONCAT(UPPER({City}), LOWER({City}), TRIM(' trimmed '))`
);
rowValidation("NC_STR_1", RESULT_STRING);
});
@ -207,7 +207,7 @@ export const genTest = (apiType, dbType) => {
editColumnByName(
"NC_STR_1",
"NC_MATH_1",
`CEILING(1.4) + FLOOR(1.6) + ROUND(2.5) + MOD(CityId, 3) + MIN(CityId, CountryId) + MAX(CityId, CountryId)`
`CEILING(1.4) + FLOOR(1.6) + ROUND(2.5) + MOD({CityId}, 3) + MIN({CityId}, {CountryId}) + MAX({CityId}, {CountryId})`
);
rowValidation("NC_MATH_1", RESULT_MATH_1);
});
@ -218,7 +218,7 @@ export const genTest = (apiType, dbType) => {
editColumnByName(
"NC_MATH_1",
"NC_MATH_2",
`LOG(CityId) + EXP(CityId) + POWER(CityId, 3) + SQRT(CountryId)`
`LOG({CityId}) + EXP({CityId}) + POWER({CityId}, 3) + SQRT({CountryId})`
);
rowValidation("NC_MATH_2", RESULT_MATH_2);
}
@ -237,6 +237,7 @@ export const genTest = (apiType, dbType) => {
*
* @author Pranav C Balan <pranavxc@gmail.com>
* @author Raju Udava <sivadstala@gmail.com>
* @author Wing-Kam Wong <wingkwong.code@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*

Loading…
Cancel
Save