Browse Source

Merge pull request #5392 from nocodb/feat/formula-query-builder-opt

refactor: Formula query builder optimization
pull/5413/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
37ae71e71f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 37
      packages/nc-gui/components/cell/ClampedText.vue
  2. 24
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 21
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 40
      packages/nc-gui/components/cell/attachment/index.vue
  5. 93
      packages/nc-gui/components/smartsheet/Cell.vue
  6. 15
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  7. 53
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  8. 1
      packages/nc-gui/components/smartsheet/toolbar/AddRow.vue
  9. 3
      packages/nc-gui/composables/useSmartsheetStore.ts
  10. 2
      packages/nc-gui/composables/useViewData.ts
  11. 1
      packages/nc-gui/context/index.ts
  12. 18
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  13. 146
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts
  14. 106
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/commonFns.ts
  15. 226
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts
  16. 140
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts
  17. 190
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts
  18. 230
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts
  19. 9
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/mapFunctionName.ts
  20. 4
      tests/playwright/pages/Dashboard/Kanban/index.ts
  21. 3
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  22. 1
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  23. 5
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  24. 9
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  25. 4
      tests/playwright/tests/columnAttachments.spec.ts
  26. 160
      tests/playwright/tests/megaTable.spec.ts

37
packages/nc-gui/components/cell/ClampedText.vue

@ -3,35 +3,18 @@ const props = defineProps<{
value?: string | number | null
lines?: number
}>()
const wrapper = ref()
const key = ref(0)
const debouncedRefresh = useDebounceFn(() => {
key.value++
}, 500)
onMounted(() => {
const observer = new ResizeObserver(() => {
debouncedRefresh()
})
observer.observe(wrapper.value)
})
</script>
<template>
<div ref="wrapper">
<!--
using '' for :text in text-clamp would keep the previous cell value after changing a filter
use ' ' instead of '' to trigger update
-->
<text-clamp
:key="`clamp-${key}-${props.value?.toString().length || 0}`"
class="w-full h-full break-word"
:text="`${props.value || ' '}`"
:max-lines="props.lines || 1"
/>
<div
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': props.lines || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
{{ props.value || '' }}
</div>
</template>

24
packages/nc-gui/components/cell/MultiSelect.vue

@ -313,11 +313,35 @@ const handleClose = (e: MouseEvent) => {
}
useEventListener(document, 'click', handleClose, true)
// todo: maintain order
const selectedOpts = computed(() => {
return options.value.filter((o) => vModel.value.includes(o.value!))
})
</script>
<template>
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!editable && !active" class="flex flex-nowrap">
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
</span>
</a-tag>
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"

21
packages/nc-gui/components/cell/SingleSelect.vue

@ -242,11 +242,32 @@ const handleClose = (e: MouseEvent) => {
}
useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value)
})
</script>
<template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!editable && !active">
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
</span>
</a-tag>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
class="w-full overflow-hidden"

40
packages/nc-gui/components/cell/attachment/index.vue

@ -4,13 +4,11 @@ import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort'
import {
ActiveCellInj,
CurrentCellInj,
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
iconMap,
inject,
isImage,
nextTick,
ref,
useAttachment,
useDropZone,
@ -29,23 +27,19 @@ interface Emits {
(event: 'update:modelValue', value: string | Record<string, any>[]): void
}
const { modelValue, rowIndex } = defineProps<Props>()
const { modelValue } = defineProps<Props>()
const emits = defineEmits<Emits>()
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const dropZoneInjection = inject(DropZoneRef, ref())
const attachmentCellRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>()
const currentCellRef = ref<Element | undefined>(dropZoneInjection.value)
const currentCellRef = inject(CurrentCellInj, dropZoneInjection.value)
const { cellRefs, isSharedForm } = useSmartsheetStoreOrThrow()!
const { isSharedForm } = useSmartsheetStoreOrThrow()!
const { getPossibleAttachmentSrc, openAttachment } = useAttachment()
@ -65,32 +59,6 @@ const {
storedFiles,
} = useProvideAttachmentCell(updateModelValue)
watch(
[() => rowIndex, isForm, attachmentCellRef],
() => {
if (dropZoneInjection?.value) return
if (!rowIndex && (isForm.value || isGallery.value || isKanban.value)) {
currentCellRef.value = attachmentCellRef.value
} else {
nextTick(() => {
const nextCell = cellRefs.value.reduceRight((cell, curr) => {
if (!cell && curr.dataset.key === `${rowIndex}${column.value!.id}`) cell = curr
return cell
}, undefined as HTMLTableDataCellElement | undefined)
if (!nextCell) {
currentCellRef.value = attachmentCellRef.value
} else {
currentCellRef.value = nextCell
}
})
}
},
{ immediate: true, flush: 'post' },
)
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { state: rowState } = useSmartsheetRowStoreOrThrow()

93
packages/nc-gui/components/smartsheet/Cell.vue

@ -151,10 +151,43 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation()
}
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
let intersectionObserver = $ref<IntersectionObserver>()
const elementToObserve = $ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver?.disconnect()
intersectionObserver = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver?.observe(elementToObserve!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
class="nc-cell w-full h-full relative"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
@ -167,35 +200,37 @@ const onContextmenu = (e: MouseEvent) => {
@contextmenu="onContextmenu"
>
<template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellMultiSelect v-else-if="isMultiSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker v-else-if="isDateTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="(isLocked || (isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
@dblclick.stop.prevent
/>
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellMultiSelect v-else-if="isMultiSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker v-else-if="isDateTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="(isLocked || (isPublic && readOnly && !isForm) || isSystemColumn(column)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
@dblclick.stop.prevent
/>
</template>
</template>
</div>
</template>

15
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,7 +1,5 @@
<script lang="ts" setup>
import { CellClickHookInj, createEventHook, onBeforeUnmount, onMounted, ref, useSmartsheetStoreOrThrow } from '#imports'
const { cellRefs } = useSmartsheetStoreOrThrow()
import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports'
const el = ref<HTMLTableDataCellElement>()
@ -9,16 +7,7 @@ const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
onMounted(() => {
cellRefs.value.push(el.value!)
})
onBeforeUnmount(() => {
const index = cellRefs.value.indexOf(el.value!)
if (index > -1) {
cellRefs.value.splice(index, 1)
}
})
provide(CurrentCellInj, el)
</script>
<template>

53
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -52,23 +52,58 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation()
}
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
let intersectionObserver = $ref<IntersectionObserver>()
const elementToObserve = $ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver?.disconnect()
intersectionObserver = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver?.observe(elementToObserve!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver?.disconnect()
})
</script>
<template>
<div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center"
:class="{ 'text-right justify-end': isGrid && !isForm && isRollup(column) }"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>
<LazyVirtualCellHasMany v-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
<template v-if="intersected">
<LazyVirtualCellHasMany v-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
</template>
</div>
</template>

1
packages/nc-gui/components/smartsheet/toolbar/AddRow.vue

@ -13,7 +13,6 @@ const onClick = () => {
<template>
<a-tooltip placement="bottom">
<template #title> {{ $t('activity.addRow') }} </template>
<IonImageOutline />
<div
v-e="['c:row:add:grid-top']"
:class="{ 'group': !isLocked, 'disabled-ring': isLocked }"

3
packages/nc-gui/composables/useSmartsheetStore.ts

@ -32,8 +32,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(meta.value as TableType)?.base_id ? sqlUis.value[(meta.value as TableType).base_id!] : Object.values(sqlUis.value)[0],
)
const cellRefs = ref<HTMLTableDataCellElement[]>([])
const { search } = useFieldQuery()
const eventBus = useEventBus<SmartsheetStoreEvents>(Symbol('SmartsheetStore'))
@ -78,7 +76,6 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
isGallery,
isKanban,
isMap,
cellRefs,
isSharedForm,
sorts,
nestedFilters,

2
packages/nc-gui/composables/useViewData.ts

@ -213,7 +213,7 @@ export function useViewData(
}
if (viewMeta.value?.type === ViewTypes.GRID) {
await loadAggCommentsCount()
loadAggCommentsCount()
}
}

1
packages/nc-gui/context/index.ts

@ -36,3 +36,4 @@ export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-
export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-injection')
export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection')
export const SaveRowInj: InjectionKey<(() => void) | undefined> = Symbol('save-row-injection')
export const CurrentCellInj: InjectionKey<Ref<Element | undefined>> = Symbol('current-cell-injection')

18
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -1296,7 +1296,8 @@ class BaseModelSqlv2 {
private async getSelectQueryBuilderForFormula(
column: Column<any>,
tableAlias?: string,
validateFormula = false
validateFormula = false,
aliasToColumnBuilder = {}
) {
const formula = await column.getColOptions<FormulaColumn>();
if (formula.error) throw new Error(`Formula error: ${formula.error}`);
@ -1306,7 +1307,7 @@ class BaseModelSqlv2 {
this.dbDriver,
this.model,
column,
{},
aliasToColumnBuilder,
tableAlias,
validateFormula
);
@ -1530,7 +1531,7 @@ class BaseModelSqlv2 {
validateFormula,
}: {
fieldsSet?: Set<string>;
qb: Knex.QueryBuilder;
qb: Knex.QueryBuilder & Knex.QueryInterface;
columns?: Column[];
fields?: string[] | string;
extractPkAndPv?: boolean;
@ -1538,6 +1539,8 @@ class BaseModelSqlv2 {
alias?: string;
validateFormula?: boolean;
}): Promise<void> {
// keep a common object for all columns to share across all columns
const aliasToColumnBuilder = {};
let viewOrTableColumns: Column[] | { fk_column_id?: string }[];
const res = {};
@ -1601,7 +1604,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula(
qrValueColumn,
alias,
validateFormula
validateFormula,
aliasToColumnBuilder
);
qb.select({
[column.column_name]: selectQb.builder,
@ -1635,7 +1639,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula(
barcodeValueColumn,
alias,
validateFormula
validateFormula,
aliasToColumnBuilder
);
qb.select({
[column.column_name]: selectQb.builder,
@ -1660,7 +1665,8 @@ class BaseModelSqlv2 {
const selectQb = await this.getSelectQueryBuilderForFormula(
column,
alias,
validateFormula
validateFormula,
aliasToColumnBuilder
);
qb.select(
this.dbDriver.raw(`?? as ??`, [

146
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/formulav2/formulaQueryBuilderv2.ts

@ -53,7 +53,7 @@ async function _formulaQueryBuilder(
alias,
knex: XKnex,
model: Model,
aliasToColumn = {},
aliasToColumn: Record<string, () => Promise<{ builder: any }>> = {},
tableAlias?: string
) {
// formula may include double curly brackets in previous version
@ -69,21 +69,25 @@ async function _formulaQueryBuilder(
switch (col.uidt) {
case UITypes.Formula:
{
const formulOption = await col.getColOptions<FormulaColumn>();
const { builder } = await _formulaQueryBuilder(
formulOption.formula,
alias,
knex,
model,
{ ...aliasToColumn, [col.id]: null },
tableAlias
);
builder.sql = '(' + builder.sql + ')';
aliasToColumn[col.id] = builder;
aliasToColumn[col.id] = async () => {
const formulOption = await col.getColOptions<FormulaColumn>();
const { builder } = await _formulaQueryBuilder(
formulOption.formula,
alias,
knex,
model,
{ ...aliasToColumn, [col.id]: null },
tableAlias
);
builder.sql = '(' + builder.sql + ')';
return {
builder,
};
};
}
break;
case UITypes.Lookup:
{
aliasToColumn[col.id] = async (): Promise<any> => {
let aliasCount = 0;
let selectQb;
let isMany = false;
@ -398,25 +402,27 @@ async function _formulaQueryBuilder(
}
if (selectQb)
aliasToColumn[col.id] =
typeof selectQb === 'function'
? selectQb
: knex.raw(selectQb as any).wrap('(', ')');
return {
builder:
typeof selectQb === 'function'
? selectQb
: knex.raw(selectQb as any).wrap('(', ')'),
};
}
}
};
break;
case UITypes.Rollup:
{
aliasToColumn[col.id] = async (): Promise<any> => {
const qb = await genRollupSelectv2({
knex,
columnOptions: (await col.getColOptions()) as RollupColumn,
alias: tableAlias,
});
aliasToColumn[col.id] = knex.raw(qb.builder).wrap('(', ')');
}
return { builder: knex.raw(qb.builder).wrap('(', ')') };
};
break;
case UITypes.LinkToAnotherRecord:
{
aliasToColumn[col.id] = async (): Promise<any> => {
const alias = `__nc_formula_ll`;
const relation = await col.getColOptions<LinkToAnotherRecordColumn>();
// if (relation.type !== 'bt') continue;
@ -520,19 +526,22 @@ async function _formulaQueryBuilder(
.wrap('(', ')');
}
if (selectQb)
aliasToColumn[col.id] =
typeof selectQb === 'function'
? selectQb
: knex.raw(selectQb as any).wrap('(', ')');
}
return {
builder:
typeof selectQb === 'function'
? selectQb
: knex.raw(selectQb as any).wrap('(', ')'),
};
};
break;
default:
aliasToColumn[col.id] = col.column_name;
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
break;
}
}
const fn = (pt, a?, prevBinaryOp?) => {
const fn = async (pt, a?, prevBinaryOp?) => {
const colAlias = a ? ` as ${a}` : '';
pt.arguments?.forEach?.((arg) => {
if (arg.fnName) return;
@ -558,18 +567,6 @@ async function _formulaQueryBuilder(
return fn(pt.arguments[0], a, prevBinaryOp);
}
break;
// case 'AVG':
// if (pt.arguments.length > 1) {
// return fn({
// type: 'BinaryExpression',
// operator: '/',
// left: {...pt, callee: {name: 'SUM'}},
// right: {type: 'Literal', value: pt.arguments.length}
// }, a, prevBinaryOp)
// } else {
// return fn(pt.arguments[0], a, prevBinaryOp)
// }
// break;
case 'CONCAT':
if (knex.clientType() === 'sqlite3') {
if (pt.arguments.length > 1) {
@ -616,7 +613,7 @@ async function _formulaQueryBuilder(
break;
default:
{
const res = mapFunctionName({
const res = await mapFunctionName({
pt,
knex,
alias,
@ -631,32 +628,37 @@ async function _formulaQueryBuilder(
break;
}
return knex.raw(
`${pt.callee.name}(${pt.arguments
.map((arg) => {
const query = fn(arg).toQuery();
if (pt.callee.name === 'CONCAT') {
if (knex.clientType() === 'mysql2') {
// mysql2: CONCAT() returns NULL if any argument is NULL.
// adding IFNULL to convert NULL values to empty strings
return `IFNULL(${query}, '')`;
} else {
// do nothing
// pg / mssql: Concatenate all arguments. NULL arguments are ignored.
// sqlite3: special handling - See BinaryExpression
}
}
return query;
})
.join()})${colAlias}`.replace(/\?/g, '\\?')
);
return {
builder: knex.raw(
`${pt.callee.name}(${(
await Promise.all(
pt.arguments.map(async (arg) => {
const query = (await fn(arg)).builder.toQuery();
if (pt.callee.name === 'CONCAT') {
if (knex.clientType() === 'mysql2') {
// mysql2: CONCAT() returns NULL if any argument is NULL.
// adding IFNULL to convert NULL values to empty strings
return `IFNULL(${query}, '')`;
} else {
// do nothing
// pg / mssql: Concatenate all arguments. NULL arguments are ignored.
// sqlite3: special handling - See BinaryExpression
}
}
return query;
})
)
).join()})${colAlias}`.replace(/\?/g, '\\?')
),
};
} else if (pt.type === 'Literal') {
return knex.raw(`?${colAlias}`, [pt.value]);
return { builder: knex.raw(`?${colAlias}`, [pt.value]) };
} else if (pt.type === 'Identifier') {
if (typeof aliasToColumn?.[pt.name] === 'function') {
return knex.raw(`??${colAlias}`, aliasToColumn?.[pt.name](pt.fnName));
const { builder } = await aliasToColumn?.[pt.name]?.();
if (typeof builder === 'function') {
return { builder: knex.raw(`??${colAlias}`, await builder(pt.fnName)) };
}
return knex.raw(`??${colAlias}`, [aliasToColumn?.[pt.name] || pt.name]);
return { builder: knex.raw(`??${colAlias}`, [builder || pt.name]) };
} else if (pt.type === 'BinaryExpression') {
if (pt.operator === '==') {
pt.operator = '=';
@ -677,8 +679,8 @@ async function _formulaQueryBuilder(
pt.left.fnName = pt.left.fnName || 'ARITH';
pt.right.fnName = pt.right.fnName || 'ARITH';
const left = fn(pt.left, null, pt.operator).toQuery();
const right = fn(pt.right, null, pt.operator).toQuery();
const left = (await fn(pt.left, null, pt.operator)).builder.toQuery();
const right = (await fn(pt.right, null, pt.operator)).builder.toQuery();
let sql = `${left} ${pt.operator} ${right}${colAlias}`;
// comparing a date with empty string would throw
@ -772,7 +774,7 @@ async function _formulaQueryBuilder(
if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')');
}
return query;
return { builder: query };
} else if (pt.type === 'UnaryExpression') {
const query = knex.raw(
`${pt.operator}${fn(
@ -784,10 +786,12 @@ async function _formulaQueryBuilder(
if (prevBinaryOp && pt.operator !== prevBinaryOp) {
query.wrap('(', ')');
}
return query;
return { builder: query };
}
};
return { builder: fn(tree, alias) };
const builder = (await fn(tree, alias)).builder;
return { builder };
}
function getTnPath(tb: Model, knex, tableAlias?: string) {
@ -842,7 +846,7 @@ export default async function formulaQueryBuilderv2(
// dry run qb.builder to see if it will break the grid view or not
// if so, set formula error and show empty selectQb instead
await knex(getTnPath(model, knex, tableAlias))
.select(qb.builder)
.select(knex.raw(`?? as ??`, [qb.builder, '__dry_run_alias']))
.as('dry-run-only');
// if column is provided, i.e. formula has been created

106
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/commonFns.ts

@ -2,72 +2,96 @@ import type { MapFnArgs } from '../mapFunctionName';
export default {
// todo: handle default case
SWITCH: (args: MapFnArgs) => {
SWITCH: async (args: MapFnArgs) => {
const count = Math.floor((args.pt.arguments.length - 1) / 2);
let query = '';
const switchVal = args.fn(args.pt.arguments[0]).toQuery();
const switchVal = (await args.fn(args.pt.arguments[0])).builder.toQuery();
for (let i = 0; i < count; i++) {
query += args.knex
.raw(
`\n\tWHEN ${args
.fn(args.pt.arguments[i * 2 + 1])
.toQuery()} THEN ${args.fn(args.pt.arguments[i * 2 + 2]).toQuery()}`
`\n\tWHEN ${(
await args.fn(args.pt.arguments[i * 2 + 1])
).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[i * 2 + 2])
).builder.toQuery()}`
)
.toQuery();
}
if (args.pt.arguments.length % 2 === 0) {
query += args.knex
.raw(
`\n\tELSE ${args
.fn(args.pt.arguments[args.pt.arguments.length - 1])
.toQuery()}`
`\n\tELSE ${(
await args.fn(args.pt.arguments[args.pt.arguments.length - 1])
).builder.toQuery()}`
)
.toQuery();
}
return args.knex.raw(`CASE ${switchVal} ${query}\n END${args.colAlias}`);
return {
builder: args.knex.raw(
`CASE ${switchVal} ${query}\n END${args.colAlias}`
),
};
},
IF: (args: MapFnArgs) => {
IF: async (args: MapFnArgs) => {
let query = args.knex
.raw(
`\n\tWHEN ${args.fn(args.pt.arguments[0]).toQuery()} THEN ${args
.fn(args.pt.arguments[1])
.toQuery()}`
`\n\tWHEN ${(
await args.fn(args.pt.arguments[0])
).builder.toQuery()} THEN ${(
await args.fn(args.pt.arguments[1])
).builder.toQuery()}`
)
.toQuery();
if (args.pt.arguments[2]) {
query += args.knex
.raw(`\n\tELSE ${args.fn(args.pt.arguments[2]).toQuery()}`)
.raw(
`\n\tELSE ${(await args.fn(args.pt.arguments[2])).builder.toQuery()}`
)
.toQuery();
}
return args.knex.raw(`CASE ${query}\n END${args.colAlias}`);
return { builder: args.knex.raw(`CASE ${query}\n END${args.colAlias}`) };
},
TRUE: (_args) => 1,
FALSE: (_args) => 0,
AND: (args: MapFnArgs) => {
return args.knex.raw(
`${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar).toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()}${args.colAlias}`
);
TRUE: 1,
FALSE: 0,
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery()
)
)
).join(' AND ')}`
)
.wrap('(', ')')
.toQuery()}${args.colAlias}`
),
};
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`${args.knex
.raw(
`${args.pt.arguments.map((ar) => args.fn(ar).toQuery()).join(' OR ')}`
)
.wrap('(', ')')
.toQuery()}${args.colAlias}`
);
OR: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar)).builder.toQuery()
)
)
).join(' OR ')}`
)
.wrap('(', ')')
.toQuery()}${args.colAlias}`
),
};
},
AVG: (args: MapFnArgs) => {
AVG: async (args: MapFnArgs) => {
if (args.pt.arguments.length > 1) {
return args.fn(
{
@ -83,7 +107,9 @@ export default {
return args.fn(args.pt.arguments[0], args.a, args.prevBinaryOp);
}
},
FLOAT: (args: MapFnArgs) => {
return args.fn(args.pt?.arguments?.[0]).wrap('(', ')');
FLOAT: async (args: MapFnArgs) => {
return {
builder: (await args.fn(args.pt?.arguments?.[0])).builder.wrap('(', ')'),
};
},
};

226
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mssql.ts

@ -6,62 +6,78 @@ import type { MapFnArgs } from '../mapFunctionName';
const mssql = {
...commonFns,
MIN: (args: MapFnArgs) => {
MIN: async (args: MapFnArgs) => {
if (args.pt.arguments.length === 1) {
return args.fn(args.pt.arguments[0]);
}
let query = '';
for (const [i, arg] of Object.entries(args.pt.arguments)) {
if (+i === args.pt.arguments.length - 1) {
query += args.knex.raw(`\n\tElse ${args.fn(arg).toQuery()}`).toQuery();
query += args.knex
.raw(`\n\tElse ${(await args.fn(arg)).builder.toQuery()}`)
.toQuery();
} else {
query += args.knex
.raw(
`\n\tWhen ${args.pt.arguments
.filter((_, j) => +i !== j)
.map(
(arg1) =>
`${args.fn(arg).toQuery()} < ${args.fn(arg1).toQuery()}`
`\n\tWhen ${(
await Promise.all(
args.pt.arguments
.filter((_, j) => +i !== j)
.map(
async (arg1) =>
`${(await args.fn(arg)).builder.toQuery()} < ${(
await args.fn(arg1)
).builder.toQuery()}`
)
)
.join(' And ')} Then ${args.fn(arg).toQuery()}`
).join(' And ')} Then ${(await args.fn(arg)).builder.toQuery()}`
)
.toQuery();
}
}
return args.knex.raw(`Case ${query}\n End${args.colAlias}`);
return { builder: args.knex.raw(`Case ${query}\n End${args.colAlias}`) };
},
MAX: (args: MapFnArgs) => {
MAX: async (args: MapFnArgs) => {
if (args.pt.arguments.length === 1) {
return args.fn(args.pt.arguments[0]);
}
let query = '';
for (const [i, arg] of Object.entries(args.pt.arguments)) {
if (+i === args.pt.arguments.length - 1) {
query += args.knex.raw(`\nElse ${args.fn(arg).toQuery()}`).toQuery();
query += args.knex
.raw(`\nElse ${(await args.fn(arg)).builder.toQuery()}`)
.toQuery();
} else {
query += args.knex
.raw(
`\nWhen ${args.pt.arguments
.filter((_, j) => +i !== j)
.map(
(arg1) =>
`${args.fn(arg).toQuery()} > ${args.fn(arg1).toQuery()}`
async (arg1) =>
`${(await args.fn(arg)).builder.toQuery()} > ${(
await args.fn(arg1)
).builder.toQuery()}`
)
.join(' And ')} Then ${args.fn(arg).toQuery()}`
.join(' And ')} Then ${(await args.fn(arg)).builder.toQuery()}`
)
.toQuery();
}
}
return args.knex.raw(`Case ${query}\n End${args.colAlias}`);
return { builder: args.knex.raw(`Case ${query}\n End${args.colAlias}`) };
},
LOG: (args: MapFnArgs) => {
return args.knex.raw(
`LOG(${args.pt.arguments
.reverse()
.map((ar) => args.fn(ar).toQuery())
.join(',')})${args.colAlias}`
);
LOG: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`LOG(${(
await Promise.all(
args.pt.arguments
.reverse()
.map(async (ar) => (await args.fn(ar)).builder.toQuery())
)
).join(',')})${args.colAlias}`
),
};
},
MOD: (pt) => {
Object.assign(pt, {
@ -73,91 +89,125 @@ const mssql = {
},
REPEAT: 'REPLICATE',
NOW: 'getdate',
SEARCH: (args: MapFnArgs) => {
SEARCH: async (args: MapFnArgs) => {
args.pt.callee.name = 'CHARINDEX';
const temp = args.pt.arguments[0];
args.pt.arguments[0] = args.pt.arguments[1];
args.pt.arguments[1] = temp;
},
INT: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ISNUMERIC(${args
.fn(args.pt.arguments[0])
.toQuery()}) = 1 THEN FLOOR(${args
.fn(args.pt.arguments[0])
.toQuery()}) ELSE 0 END${args.colAlias}`
);
INT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ISNUMERIC(${(
await args.fn(args.pt.arguments[0])
).builder.toQuery()}) = 1 THEN FLOOR(${(
await args.fn(args.pt.arguments[0])
).builder.toQuery()}) ELSE 0 END${args.colAlias}`
),
};
},
MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => {
return args.knex
.raw(`CAST(${args.fn(args.pt.arguments[0])} as FLOAT)${args.colAlias}`)
.wrap('(', ')');
FLOAT: async (args: MapFnArgs) => {
return {
builder: args.knex
.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as FLOAT)${
args.colAlias
}`
)
.wrap('(', ')'),
};
},
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = fn(pt.arguments[1]);
return knex.raw(
`CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN
FORMAT(DATEADD(${String(fn(pt.arguments[2])).replace(/["']/g, '')},
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])}, ${fn(
pt.arguments[0]
)}), 'yyyy-MM-dd HH:mm')
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(
`CASE
WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
FORMAT(DATEADD(${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${
(await fn(pt.arguments[0])).builder
}), 'yyyy-MM-dd HH:mm')
ELSE
FORMAT(DATEADD(${String(fn(pt.arguments[2])).replace(/["']/g, '')},
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])}, ${fn(
pt.arguments[0]
)}), 'yyyy-MM-dd')
FORMAT(DATEADD(${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${fn(
pt.arguments[0]
)}), 'yyyy-MM-dd')
END${colAlias}`
);
),
};
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds';
const unit = convertUnits(rawUnit, 'mssql');
return knex.raw(
`DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
);
return {
builder: knex.raw(
`DATEDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
),
};
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// DATEPART(WEEKDAY, DATE): sunday = 1, monday = 2, ..., saturday = 7
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw(
`(DATEPART(WEEKDAY, ${
pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'`
: fn(pt.arguments[0])
}) - 2 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
);
return {
builder: knex.raw(
`(DATEPART(WEEKDAY, ${
pt.arguments[0].type === 'Literal'
? `'${dayjs((await fn(pt.arguments[0])).builder).format(
'YYYY-MM-DD'
)}'`
: fn(pt.arguments[0])
}) - 2 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
),
};
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
),
};
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
OR: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
)
).join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
),
};
},
};

140
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/mysql.ts

@ -9,90 +9,110 @@ const mysql2 = {
LEN: 'CHAR_LENGTH',
MIN: 'LEAST',
MAX: 'GREATEST',
SEARCH: (args: MapFnArgs) => {
SEARCH: async (args: MapFnArgs) => {
args.pt.callee.name = 'LOCATE';
const temp = args.pt.arguments[0];
args.pt.arguments[0] = args.pt.arguments[1];
args.pt.arguments[1] = temp;
},
INT: (args: MapFnArgs) => {
return args.knex.raw(
`CAST(${args.fn(args.pt.arguments[0])} as SIGNED)${args.colAlias}`
);
INT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as SIGNED)${
args.colAlias
}`
),
};
},
LEFT: (args: MapFnArgs) => {
return args.knex.raw(
`SUBSTR(${args.fn(args.pt.arguments[0])},1,${args.fn(
args.pt.arguments[1]
)})${args.colAlias}`
);
LEFT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},1,${
(await args.fn(args.pt.arguments[1])).builder
})${args.colAlias}`
),
};
},
RIGHT: (args: MapFnArgs) => {
return args.knex.raw(
`SUBSTR(${args.fn(args.pt.arguments[0])}, -(${args.fn(
args.pt.arguments[1]
)}))${args.colAlias}`
);
RIGHT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`SUBSTR(${(await args.fn(args.pt.arguments[0])).builder}, -(${
(await args.fn(args.pt.arguments[1])).builder
}))${args.colAlias}`
),
};
},
MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => {
return args.knex
.raw(
`CAST(CAST(${args.fn(args.pt.arguments[0])} as CHAR) AS DOUBLE)${
args.colAlias
}`
)
.wrap('(', ')');
FLOAT: async (args: MapFnArgs) => {
return {
builder: args.knex
.raw(
`CAST(CAST(${
(await args.fn(args.pt.arguments[0])).builder
} as CHAR) AS DOUBLE)${args.colAlias}`
)
.wrap('(', ')'),
};
},
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw(
`CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN
DATE_FORMAT(DATE_ADD(${fn(pt.arguments[0])}, INTERVAL
${fn(pt.arguments[1])} ${String(fn(pt.arguments[2])).replace(
/["']/g,
''
)}), '%Y-%m-%d %H:%i')
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`CASE
WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
DATE_FORMAT(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${(await fn(pt.arguments[1])).builder} ${String(
(await fn(pt.arguments[2])).builder
).replace(/["']/g, '')}), '%Y-%m-%d %H:%i')
ELSE
DATE(DATE_ADD(${fn(pt.arguments[0])}, INTERVAL
${fn(pt.arguments[1])} ${String(fn(pt.arguments[2])).replace(
/["']/g,
''
)}))
DATE(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${(await fn(pt.arguments[1])).builder} ${String(
(await fn(pt.arguments[2])).builder
).replace(/["']/g, '')}))
END${colAlias}`
);
),
};
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const unit = convertUnits(
pt.arguments[2] ? fn(pt.arguments[2]).bindings[0] : 'seconds',
pt.arguments[2]
? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds',
'mysql'
);
if (unit === 'MICROSECOND') {
// MySQL doesn't support millisecond
// hence change from MICROSECOND to millisecond manually
return knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}`
);
return {
builder: knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) div 1000 ${colAlias}`
),
};
}
return knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
);
return {
builder: knex.raw(
`TIMESTAMPDIFF(${unit}, ${datetime_expr2}, ${datetime_expr1}) ${colAlias}`
),
};
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw(
`(WEEKDAY(${
pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'`
: fn(pt.arguments[0])
}) - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
);
return {
builder: knex.raw(
`(WEEKDAY(${
pt.arguments[0].type === 'Literal'
? `'${dayjs((await fn(pt.arguments[0])).builder).format(
'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
),
};
},
};

190
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/pg.ts

@ -12,50 +12,66 @@ const pg = {
CEILING: 'ceil',
POWER: 'pow',
SQRT: 'sqrt',
SEARCH: (args: MapFnArgs) => {
return args.knex.raw(
`POSITION(${args.knex.raw(
args.fn(args.pt.arguments[1]).toQuery()
)} in ${args.knex.raw(args.fn(args.pt.arguments[0]).toQuery())})${
args.colAlias
}`
);
SEARCH: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`POSITION(${args.knex.raw(
(await args.fn(args.pt.arguments[1])).builder.toQuery()
)} in ${args.knex
.raw((await args.fn(args.pt.arguments[0])).builder)
.toQuery()})${args.colAlias}`
),
};
},
INT(args: MapFnArgs) {
// todo: correction
return args.knex.raw(
`REGEXP_REPLACE(COALESCE(${args.fn(
args.pt.arguments[0]
)}::character varying, '0'), '[^0-9]+|\\.[0-9]+' ,'')${args.colAlias}`
);
return {
builder: args.knex.raw(
`REGEXP_REPLACE(COALESCE(${args.fn(
args.pt.arguments[0]
)}::character varying, '0'), '[^0-9]+|\\.[0-9]+' ,'')${args.colAlias}`
),
};
},
MID: 'SUBSTR',
FLOAT: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex
.raw(`CAST(${fn(pt.arguments[0])} as DOUBLE PRECISION)${colAlias}`)
.wrap('(', ')');
FLOAT: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex
.raw(
`CAST(${
(await fn(pt.arguments[0])).builder
} as DOUBLE PRECISION)${colAlias}`
)
.wrap('(', ')'),
};
},
ROUND: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw(
`ROUND((${fn(pt.arguments[0])})::numeric, ${
pt?.arguments[1] ? fn(pt.arguments[1]) : 0
}) ${colAlias}`
);
ROUND: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`ROUND((${(await fn(pt.arguments[0])).builder})::numeric, ${
pt?.arguments[1] ? (await fn(pt.arguments[1])).builder : 0
}) ${colAlias}`
),
};
},
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return knex.raw(
`${fn(pt.arguments[0])} + (${fn(pt.arguments[1])} ||
'${String(fn(pt.arguments[2])).replace(
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
return {
builder: knex.raw(
`${(await fn(pt.arguments[0])).builder} + (${
(await fn(pt.arguments[1])).builder
} ||
'${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)}')::interval${colAlias}`
);
),
};
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = fn(pt.arguments[0]);
const datetime_expr2 = fn(pt.arguments[1]);
DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const datetime_expr1 = (await fn(pt.arguments[0])).builder;
const datetime_expr2 = (await fn(pt.arguments[1])).builder;
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds';
let sql;
const unit = convertUnits(rawUnit, 'pg');
@ -99,59 +115,77 @@ const pg = {
default:
sql = '';
}
return knex.raw(`${sql} ${colAlias}`);
return { builder: knex.raw(`${sql} ${colAlias}`) };
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// isodow: the day of the week as Monday (1) to Sunday (7)
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw(
`(EXTRACT(ISODOW FROM ${
pt.arguments[0].type === 'Literal'
? `date '${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'`
: fn(pt.arguments[0])
}) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) ::INTEGER % 7 ${colAlias}`
);
return {
builder: knex.raw(
`(EXTRACT(ISODOW FROM ${
pt.arguments[0].type === 'Literal'
? `date '${dayjs((await fn(pt.arguments[0])).builder).format(
'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) ::INTEGER % 7 ${colAlias}`
),
};
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
);
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
),
};
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
);
OR: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN TRUE ELSE FALSE END ${args.colAlias}`
),
};
},
SUBSTR: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const str = fn(pt.arguments[0]);
const positionFrom = fn(pt.arguments[1] ?? 1);
const numberOfCharacters = fn(pt.arguments[2] ?? '');
return knex.raw(
`SUBSTR(${str}::TEXT, ${positionFrom}${
numberOfCharacters ? ', ' + numberOfCharacters : ''
})${colAlias}`
);
SUBSTR: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const str = (await fn(pt.arguments[0])).builder;
const positionFrom = (await fn(pt.arguments[1] ?? 1)).builder;
const numberOfCharacters = (await fn(pt.arguments[2] ?? '')).builder;
return {
builder: knex.raw(
`SUBSTR(${str}::TEXT, ${positionFrom}${
numberOfCharacters ? ', ' + numberOfCharacters : ''
})${colAlias}`
),
};
},
MOD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const x = fn(pt.arguments[0]);
const y = fn(pt.arguments[1]);
return knex.raw(`MOD((${x})::NUMERIC, (${y})::NUMERIC) ${colAlias}`);
MOD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const x = (await fn(pt.arguments[0])).builder;
const y = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(`MOD((${x})::NUMERIC, (${y})::NUMERIC) ${colAlias}`),
};
},
};

230
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/functionMappings/sqlite.ts

@ -11,17 +11,25 @@ import type { MapFnArgs } from '../mapFunctionName';
const sqlite3 = {
...commonFns,
LEN: 'LENGTH',
CEILING(args) {
return args.knex.raw(
`round(${args.fn(args.pt.arguments[0])} + 0.5)${args.colAlias}`
);
async CEILING(args) {
return {
builder: args.knex.raw(
`round(${(await args.fn(args.pt.arguments[0])).builder} + 0.5)${
args.colAlias
}`
),
};
},
FLOOR(args) {
return args.knex.raw(
`round(${args.fn(args.pt.arguments[0])} - 0.5)${args.colAlias}`
);
async FLOOR(args) {
return {
builder: args.knex.raw(
`round(${(await args.fn(args.pt.arguments[0])).builder} - 0.5)${
args.colAlias
}`
),
};
},
MOD: (args: MapFnArgs) => {
MOD: async (args: MapFnArgs) => {
return args.fn({
type: 'BinaryExpression',
operator: '%',
@ -29,62 +37,88 @@ const sqlite3 = {
right: args.pt.arguments[1],
});
},
REPEAT(args: MapFnArgs) {
return args.knex.raw(
`replace(printf('%.' || ${args.fn(
args.pt.arguments[1]
)} || 'c', '/'),'/',${args.fn(args.pt.arguments[0])})${args.colAlias}`
);
async REPEAT(args: MapFnArgs) {
return {
builder: args.knex.raw(
`replace(printf('%.' || ${
(await args.fn(args.pt.arguments[1])).builder
} || 'c', '/'),'/',${(await args.fn(args.pt.arguments[0])).builder})${
args.colAlias
}`
),
};
},
NOW: 'DATE',
SEARCH: 'INSTR',
INT(args: MapFnArgs) {
return args.knex.raw(
`CAST(${args.fn(args.pt.arguments[0])} as INTEGER)${args.colAlias}`
);
async INT(args: MapFnArgs) {
return {
builder: args.knex.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as INTEGER)${
args.colAlias
}`
),
};
},
LEFT: (args: MapFnArgs) => {
return args.knex.raw(
`SUBSTR(${args.fn(args.pt.arguments[0])},1,${args.fn(
args.pt.arguments[1]
)})${args.colAlias}`
);
LEFT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},1,${
(await args.fn(args.pt.arguments[1])).builder
})${args.colAlias}`
),
};
},
RIGHT: (args: MapFnArgs) => {
return args.knex.raw(
`SUBSTR(${args.fn(args.pt.arguments[0])},-(${args.fn(
args.pt.arguments[1]
)}))${args.colAlias}`
);
RIGHT: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`SUBSTR(${(await args.fn(args.pt.arguments[0])).builder},-(${
(await args.fn(args.pt.arguments[1])).builder
}))${args.colAlias}`
),
};
},
MID: 'SUBSTR',
FLOAT: (args: MapFnArgs) => {
return args.knex
.raw(`CAST(${args.fn(args.pt.arguments[0])} as FLOAT)${args.colAlias}`)
.wrap('(', ')');
FLOAT: async (args: MapFnArgs) => {
return {
builder: args.knex
.raw(
`CAST(${(await args.fn(args.pt.arguments[0])).builder} as FLOAT)${
args.colAlias
}`
)
.wrap('(', ')'),
};
},
DATEADD: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = fn(pt.arguments[1]);
return knex.raw(
`CASE
WHEN ${fn(pt.arguments[0])} LIKE '%:%' THEN
DATEADD: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
const dateIN = (await fn(pt.arguments[1])).builder;
return {
builder: knex.raw(
`CASE
WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
STRFTIME('%Y-%m-%d %H:%M', DATETIME(DATETIME(${fn(
pt.arguments[0]
)}, 'localtime'),
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])} || ' ${String(
fn(pt.arguments[2])
).replace(/["']/g, '')}'))
ELSE
DATE(DATETIME(${fn(pt.arguments[0])}, 'localtime'),
${dateIN > 0 ? '+' : ''}${fn(pt.arguments[1])} || ' ${String(
fn(pt.arguments[2])
).replace(/["']/g, '')}')
)}, 'localtime'),
${dateIN > 0 ? '+' : ''}${
(await fn(pt.arguments[1])).builder
} || ' ${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)}'))
ELSE
DATE(DATETIME(${(await fn(pt.arguments[0])).builder}, 'localtime'),
${dateIN > 0 ? '+' : ''}${
(await fn(pt.arguments[1])).builder
} || ' ${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
''
)}')
END${colAlias}`
);
),
};
},
DATETIME_DIFF: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
let datetime_expr1 = fn(pt.arguments[0]);
let datetime_expr2 = fn(pt.arguments[1]);
DATETIME_DIFF: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
let datetime_expr1 = (await fn(pt.arguments[0])).builder;
let datetime_expr2 = (await fn(pt.arguments[1])).builder;
// JULIANDAY takes YYYY-MM-DD
if (datetime_expr1.sql === '?' && datetime_expr1.bindings?.[0]) {
datetime_expr1 = `'${convertToTargetFormat(
@ -103,7 +137,7 @@ const sqlite3 = {
}
const rawUnit = pt.arguments[2]
? fn(pt.arguments[2]).bindings[0]
? (await fn(pt.arguments[2])).builder.bindings[0]
: 'seconds';
let sql;
const unit = convertUnits(rawUnit, 'sqlite');
@ -130,15 +164,15 @@ const sqlite3 = {
sql = `(strftime('%Y', ${datetime_expr1}) - strftime('%Y', ${datetime_expr2})) * 4 + (strftime('%m', ${datetime_expr1}) - strftime('%m', ${datetime_expr2})) / 3`;
break;
case 'years':
sql = `CASE
WHEN (${datetime_expr2} < ${datetime_expr1}) THEN
sql = `CASE
WHEN (${datetime_expr2} < ${datetime_expr1}) THEN
(
(strftime('%Y', ${datetime_expr1}) - strftime('%Y', ${datetime_expr2}))
- (strftime('%m', ${datetime_expr1}) < strftime('%m', ${datetime_expr2})
OR (strftime('%m', ${datetime_expr1}) = strftime('%m', ${datetime_expr2})
AND strftime('%d', ${datetime_expr1}) < strftime('%d', ${datetime_expr2})))
)
WHEN (${datetime_expr2} > ${datetime_expr1}) THEN
WHEN (${datetime_expr2} > ${datetime_expr1}) THEN
-1 * (
(strftime('%Y', ${datetime_expr2}) - strftime('%Y', ${datetime_expr1}))
- (strftime('%m', ${datetime_expr2}) < strftime('%m', ${datetime_expr1})
@ -154,44 +188,60 @@ const sqlite3 = {
default:
sql = '';
}
return knex.raw(`${sql} ${colAlias}`);
return { builder: knex.raw(`${sql} ${colAlias}`) };
},
WEEKDAY: ({ fn, knex, pt, colAlias }: MapFnArgs) => {
WEEKDAY: async ({ fn, knex, pt, colAlias }: MapFnArgs) => {
// strftime('%w', date) - day of week 0 - 6 with Sunday == 0
// WEEKDAY() returns an index from 0 to 6 for Monday to Sunday
return knex.raw(
`(strftime('%w', ${
pt.arguments[0].type === 'Literal'
? `'${dayjs(fn(pt.arguments[0])).format('YYYY-MM-DD')}'`
: fn(pt.arguments[0])
}) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
);
return {
builder: knex.raw(
`(strftime('%w', ${
pt.arguments[0].type === 'Literal'
? `'${dayjs((await fn(pt.arguments[0])).builder).format(
'YYYY-MM-DD'
)}'`
: (await fn(pt.arguments[0])).builder
}) - 1 - ${getWeekdayByText(
pt?.arguments[1]?.value
)} % 7 + 7) % 7 ${colAlias}`
),
};
},
AND: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'AND').toQuery())
.join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
AND: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'AND')).builder.toQuery()
)
)
).join(' AND ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
),
};
},
OR: (args: MapFnArgs) => {
return args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${args.pt.arguments
.map((ar) => args.fn(ar, '', 'OR').toQuery())
.join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
);
OR: async (args: MapFnArgs) => {
return {
builder: args.knex.raw(
`CASE WHEN ${args.knex
.raw(
`${(
await Promise.all(
args.pt.arguments.map(async (ar) =>
(await args.fn(ar, '', 'OR')).builder.toQuery()
)
)
).join(' OR ')}`
)
.wrap('(', ')')
.toQuery()} THEN 1 ELSE 0 END ${args.colAlias}`
),
};
},
};

9
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/mapFunctionName.ts

@ -7,16 +7,19 @@ import type { Knex } from 'knex';
export interface MapFnArgs {
pt: any;
aliasToCol: { [alias: string]: string };
aliasToCol: Record<
string,
(() => Promise<{ builder: any }>) | string | undefined
>;
knex: XKnex;
alias: string;
a?: string;
fn: (...args: any) => Knex.QueryBuilder | any;
fn: (...args: any) => Promise<{ builder: Knex.QueryBuilder | any }>;
colAlias: string;
prevBinaryOp?: any;
}
const mapFunctionName = (args: MapFnArgs): any => {
const mapFunctionName = async (args: MapFnArgs): Promise<any> => {
const name = args.pt.callee.name;
let val;

4
tests/playwright/pages/Dashboard/Kanban/index.ts

@ -65,6 +65,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
// Since otherwise stack title will be repeated as title is in two divs, with one having hidden class
const stackTitle = await stack.locator(`.nc-kanban-stack-head >> [data-testid="truncate-label"]`);
await expect(stackTitle).toHaveText(order[i], { ignoreCase: true });
@ -76,6 +77,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackFooter = await stack.locator(`.nc-kanban-data-count`).innerText();
await expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`);
}
@ -86,6 +88,7 @@ export class KanbanPage extends BasePage {
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
await stack.scrollIntoViewIfNeeded();
const stackCards = stack.locator(`.nc-kanban-item`);
await expect(stackCards).toHaveCount(count[i]);
}
@ -96,6 +99,7 @@ export class KanbanPage extends BasePage {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
for (let i = 0; i < order.length; i++) {
const card = await stack.locator(`.nc-kanban-item`).nth(i);
await card.scrollIntoViewIfNeeded();
const cardTitle = await card.locator(`.nc-cell`);
await expect(cardTitle).toHaveText(order[i]);
}

3
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -22,6 +22,7 @@ export class AttachmentCellPageObject extends BasePage {
// e.g. ['path/to/file1', 'path/to/file2']
//
async addFile({ index, columnHeader, filePath }: { index?: number; columnHeader: string; filePath: string[] }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
const attachFileAction = this.get({ index, columnHeader })
.locator('[data-testid="attachment-cell-file-picker-button"]')
.click();
@ -52,7 +53,7 @@ export class AttachmentCellPageObject extends BasePage {
let retryCount = 0;
while (retryCount < 5) {
const attachments = await this.get({ index, columnHeader }).locator('.nc-attachment');
console.log(await attachments.count());
// console.log(await attachments.count());
if ((await attachments.count()) === count) {
break;
}

1
tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts

@ -23,6 +23,7 @@ export class RatingCellPageObject extends BasePage {
}
async verify({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await expect(await this.get({ index, columnHeader }).locator(`div[role="radio"][aria-checked="true"]`)).toHaveCount(
rating
);

5
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -85,9 +85,8 @@ export class SelectOptionCellPageObject extends BasePage {
if (multiSelect) {
return await expect(this.cell.get({ index, columnHeader })).toContainText(option, { useInnerText: true });
}
return await expect(
this.cell.get({ index, columnHeader }).locator('.ant-select-selection-item > .ant-tag')
).toHaveText(option, { useInnerText: true });
const text = await (await this.cell.get({ index, columnHeader }).locator('.ant-tag')).allInnerTexts();
return expect(text).toContain(option);
}
async verifyNoOptionsSelected({ index, columnHeader }: { index: number; columnHeader: string }) {

9
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -114,6 +114,10 @@ export class CellPageObject extends BasePage {
// if text is found, return
// if text is not found, throw error
let count = 0;
await this.get({
index,
columnHeader,
}).scrollIntoViewIfNeeded();
while (count < 5) {
const innerTexts = await this.get({
index,
@ -265,9 +269,11 @@ export class CellPageObject extends BasePage {
value: string[];
}) {
// const count = value.length;
const cell = this.get({ index, columnHeader });
const cell = await this.get({ index, columnHeader });
const chips = cell.locator('.chips > .chip');
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
// verify chip count & contents
if (count) await expect(chips).toHaveCount(count);
@ -316,6 +322,7 @@ export class CellPageObject extends BasePage {
}
async copyToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters<Locator['click']>) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click(...clickOptions);
await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable');

4
tests/playwright/tests/columnAttachments.spec.ts

@ -39,12 +39,12 @@ test.describe('Attachment column', () => {
});
}
await dashboard.grid.cell.attachment.addFile({
index: 14,
index: 4,
columnHeader: 'testAttach',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
});
await dashboard.grid.cell.attachment.verifyFile({
index: 14,
index: 4,
columnHeader: 'testAttach',
});

160
tests/playwright/tests/megaTable.spec.ts

@ -0,0 +1,160 @@
import { test } from '@playwright/test';
import setup from '../setup';
import { UITypes } from 'nocodb-sdk';
import { Api } from 'nocodb-sdk';
let api: Api<any>;
// configuration
// To use, modify the test.skip to test.only
// Add columns as required to megaTblColumns
// Add row count as required to megaTblRows
const megaTblColumns = [
{ type: 'SingleLineText', count: 30 },
{ type: 'LongText', count: 100 },
{ type: 'Number', count: 30 },
{ type: 'Checkbox', count: 30 },
{ type: 'SingleSelect', count: 30 },
{ type: 'MultiSelect', count: 100 },
{ type: 'Date', count: 100 },
{ type: 'DateTime', count: 100 },
{ type: 'Email', count: 100 },
{ type: 'Currency', count: 100 },
{ type: 'Duration', count: 100 },
{ type: 'Rating', count: 100 },
];
const megaTblRows = 1000;
const bulkInsertAfterRows = 1000;
const formulaRowCnt = 100;
test.describe.serial('Test table', () => {
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
});
test.skip('mega table', async ({ page }) => {
let table_1;
const table_1_columns = [];
// a Primary key column & display column
table_1_columns.push(
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
pv: true,
}
);
for (let i = 0; i < megaTblColumns.length; i++) {
for (let j = 0; j < megaTblColumns[i].count; j++) {
// skip if Formula
if (megaTblColumns[i].type === 'Formula') continue;
const column = {
column_name: `${megaTblColumns[i].type}${j}`,
title: `${megaTblColumns[i].type}${j}`,
uidt: UITypes[megaTblColumns[i].type],
};
if (megaTblColumns[i].type === 'SingleSelect' || megaTblColumns[i].type === 'MultiSelect') {
column['dtxp'] = "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'";
}
if (megaTblColumns[i].type === 'Email') {
column['meta'] = {
validate: true,
};
}
table_1_columns.push(column);
}
}
try {
const project = await api.project.read(context.project.id);
table_1 = await api.base.tableCreate(context.project.id, project.bases?.[0].id, {
table_name: 'table_1',
title: 'table_1',
columns: table_1_columns,
});
// run loop for formula count
for (let i = 0; i < formulaRowCnt; i++) {
table_1 = await api.dbTableColumn.create(table_1.id, {
column_name: `Formula${i}`,
title: `Formula${i}`,
uidt: UITypes.Formula,
formula_raw: '{SingleLineText}',
});
}
const table_1_rows = [];
for (let rowCnt = 0; rowCnt < megaTblRows; rowCnt++) {
const row = {
Id: rowCnt + 1,
SingleLineText: `SingleLineText${rowCnt + 1}`,
};
for (let colCnt = 0; colCnt < megaTblColumns.length; colCnt++) {
if (megaTblColumns[colCnt].type === 'Formula') continue;
for (let colInstanceCnt = 0; colInstanceCnt < megaTblColumns[colCnt].count; colInstanceCnt++) {
const columnName = `${megaTblColumns[colCnt].type}${colInstanceCnt}`;
if (megaTblColumns[colCnt].type === 'SingleLineText') {
row[columnName] = `SingleLineText${rowCnt + 1}`;
} else if (
megaTblColumns[colCnt].type === 'Number' ||
megaTblColumns[colCnt].type === 'Currency' ||
megaTblColumns[colCnt].type === 'Duration'
) {
row[columnName] = rowCnt + 1;
} else if (megaTblColumns[colCnt].type === 'Checkbox') {
row[columnName] = rowCnt % 2 === 0;
} else if (megaTblColumns[colCnt].type === 'SingleSelect') {
row[columnName] = 'jan';
} else if (megaTblColumns[colCnt].type === 'MultiSelect') {
row[columnName] = 'jan,feb,mar,apr';
} else if (megaTblColumns[colCnt].type === 'LongText') {
row[columnName] = `Some length text here. Some length text here`;
} else if (megaTblColumns[colCnt].type === 'DateTime') {
row[columnName] = '2023-04-25 16:25:11+05:30';
} else if (megaTblColumns[colCnt].type === 'Date') {
row[columnName] = '2023-04-25 16:25:11+05:30';
} else if (megaTblColumns[colCnt].type === 'Email') {
row[columnName] = 'raju@nocodb.com';
} else if (megaTblColumns[colCnt].type === 'Rating') {
row[columnName] = (rowCnt % 5) + 1;
}
}
}
table_1_rows.push(row);
// insert as soon as we have 1k records ready
if (table_1_rows.length === bulkInsertAfterRows) {
await api.dbTableRow.bulkCreate('noco', context.project.id, table_1.id, table_1_rows);
console.log(`table_1_rows ${rowCnt + 1} created`);
table_1_rows.length = 0;
}
}
if (table_1_rows.length > 0) {
await api.dbTableRow.bulkCreate('noco', context.project.id, table_1.id, table_1_rows);
console.log(`table_1_rows ${megaTblRows} created`);
}
} catch (e) {
console.log(e);
}
await page.reload();
});
});
Loading…
Cancel
Save