Browse Source

feat: Field aggregation (#8786)

* feat: basic ui for aggregation

* feat: update aggregation in ui

* feat: aggregation api implementation

* feat: attachment aggregation.ts

* fix: some changes

* fix: rebase

* feat: aggregation for links, rollup, ltar, formula, lookup

* fix: type errors

* fix: move from data-alias controller, service to data-table service, controller

* chore: inline docs for aggregations

* fix: handle edge cases

* fix: ui bugs

* feat: working ui aggregation

* fix: minor issue

* fix: rollup and links fix count

* fix: handle ID Column

* fix: minor fixes

* fix: update aggregation on data change

* fix: round to 2 decimal places

* fix: stddev computation error replace with stddev_pop

* fix: use pg age function

* feat: new record layout

* fix: shared view aggregations

* feat: aggregations based on formula result

* fix: temp pagination

* feat: ncpagination v2

* feat: ncpagination v2

* fix: playwright tests

* fix: pending changes

* fix: failing tests

* feat: mysql2 aggregations

* fix: build

* fix: record count

* fix: cleanup

* fix: disable count aggregation

* feat: expiremental sqlite3 aggregation

* fix: mysql2 median

* fix:minor issues

* refactor: rename column to column_query fix: remove default aggregations fix: disable aggregation for specific dbtype and Foreign Key

* fix: remove unwanted else case

* fix: aggregation not loading

* fix: rebase

* fix: rebase

* fix: pagination fixed height

* fix: respect locked mode for aggregations

* fix: pagination component

* fix: pagination component

* fix: replace Math.random
pull/8840/head
Anbarasu 2 weeks ago committed by GitHub
parent
commit
b7acf202e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      packages/nc-gui/assets/style.scss
  2. 3
      packages/nc-gui/components/nc/Dropdown.vue
  3. 241
      packages/nc-gui/components/nc/PaginationV2.vue
  4. 8
      packages/nc-gui/components/nc/SubMenu.vue
  5. 295
      packages/nc-gui/components/smartsheet/grid/PaginationV2.vue
  6. 74
      packages/nc-gui/components/smartsheet/grid/Table.vue
  7. 2
      packages/nc-gui/components/smartsheet/grid/index.vue
  8. 2
      packages/nc-gui/components/tabs/Smartsheet.vue
  9. 11
      packages/nc-gui/composables/useData.ts
  10. 25
      packages/nc-gui/composables/useSharedView.ts
  11. 145
      packages/nc-gui/composables/useViewAggregate.ts
  12. 11
      packages/nc-gui/composables/useViewColumns.ts
  13. 3
      packages/nc-gui/context/index.ts
  14. 54
      packages/nc-gui/lang/en.json
  15. 133
      packages/nc-gui/utils/aggregationUtils.ts
  16. 136
      packages/nocodb-sdk/src/lib/aggregationHelper.ts
  17. 1
      packages/nocodb-sdk/src/lib/index.ts
  18. 15
      packages/nocodb/src/controllers/data-table.controller.ts
  19. 18
      packages/nocodb/src/controllers/public-datas.controller.ts
  20. 112
      packages/nocodb/src/db/BaseModelSqlv2.ts
  21. 202
      packages/nocodb/src/db/aggregation.ts
  22. 405
      packages/nocodb/src/db/aggregations/mysql2.ts
  23. 398
      packages/nocodb/src/db/aggregations/pg.ts
  24. 423
      packages/nocodb/src/db/aggregations/sqlite3.ts
  25. 6
      packages/nocodb/src/db/sql-data-mapper/lib/BaseModel.ts
  26. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  27. 16
      packages/nocodb/src/meta/migrations/v2/nc_052_field_aggregation.ts
  28. 3
      packages/nocodb/src/models/GridViewColumn.ts
  29. 22
      packages/nocodb/src/models/View.ts
  30. 97
      packages/nocodb/src/schema/swagger-v2.json
  31. 184
      packages/nocodb/src/schema/swagger.json
  32. 40
      packages/nocodb/src/services/data-table.service.ts
  33. 45
      packages/nocodb/src/services/public-datas.service.ts
  34. 2
      packages/nocodb/src/utils/acl.ts
  35. 11
      tests/playwright/pages/Dashboard/Grid/AggregationBar.ts
  36. 7
      tests/playwright/pages/Dashboard/Grid/index.ts
  37. 5
      tests/playwright/pages/Dashboard/common/Footbar/index.ts

14
packages/nc-gui/assets/style.scss

@ -2,6 +2,20 @@
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@layer utilities {
@variants responsive {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
}
html {
overflow: hidden;
}

3
packages/nc-gui/components/nc/Dropdown.vue

@ -4,6 +4,7 @@ const props = withDefaults(
trigger?: Array<'click' | 'hover' | 'contextmenu'>
visible?: boolean | undefined
overlayClassName?: string | undefined
disabled?: boolean
placement?: 'bottom' | 'top' | 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight' | 'topCenter' | 'bottomCenter'
autoClose?: boolean
}>(),
@ -11,6 +12,7 @@ const props = withDefaults(
trigger: () => ['click'],
visible: undefined,
placement: 'bottomLeft',
disabled: false,
overlayClassName: undefined,
autoClose: true,
},
@ -61,6 +63,7 @@ const onVisibleUpdate = (event: any) => {
<template>
<a-dropdown
:disabled="disabled"
:visible="visible"
:placement="placement"
:trigger="trigger"

241
packages/nc-gui/components/nc/PaginationV2.vue

@ -0,0 +1,241 @@
<script setup lang="ts">
import NcTooltip from '~/components/nc/Tooltip.vue'
const props = defineProps<{
current: number
total: number
pageSize: number
entityName?: string
mode?: 'simple' | 'full'
prevPageTooltip?: string
nextPageTooltip?: string
firstPageTooltip?: string
lastPageTooltip?: string
showSizeChanger?: boolean
}>()
const emits = defineEmits(['update:current', 'update:pageSize'])
const { total, showSizeChanger } = toRefs(props)
const current = useVModel(props, 'current', emits)
const pageSize = useVModel(props, 'pageSize', emits)
const { gridViewPageSize, setGridViewPageSize } = useGlobal()
const localPageSize = computed({
get: () => {
if (!showSizeChanger.value) return pageSize.value
const storedPageSize = gridViewPageSize.value || 25
if (pageSize.value !== storedPageSize) {
pageSize.value = storedPageSize
}
return pageSize.value
},
set: (val) => {
setGridViewPageSize(val)
pageSize.value = val
},
})
const entityName = computed(() => props.entityName || 'item')
const totalPages = computed(() => Math.max(Math.ceil(total.value / localPageSize.value), 1))
const { isMobileMode } = useGlobal()
const mode = computed(() => props.mode || (isMobileMode.value ? 'simple' : 'full'))
const changePage = ({ increase, set }: { increase?: boolean; set?: number }) => {
if (set) {
current.value = set
} else if (increase && current.value < totalPages.value) {
current.value = current.value + 1
} else if (current.value > 0) {
current.value = current.value - 1
}
}
const goToLastPage = () => {
current.value = totalPages.value
}
const goToFirstPage = () => {
current.value = 1
}
const pagesList = computed(() => {
return Array.from({ length: totalPages.value }, (_, i) => ({
value: i + 1,
label: i + 1,
}))
})
const pageSizeOptions = [
{
value: 25,
label: '25 / page',
},
{
value: 50,
label: '50 / page',
},
{
value: 75,
label: '75 / page',
},
{
value: 100,
label: '100 / page',
},
]
</script>
<template>
<div class="nc-pagination flex flex-row items-center gap-x-0.25">
<template v-if="totalPages > 1">
<component :is="props.firstPageTooltip && mode === 'full' ? NcTooltip : 'div'" v-if="mode === 'full'">
<template v-if="props.firstPageTooltip" #title>
{{ props.firstPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:first-page`]"
class="first-page !border-0"
type="text"
size="xsmall"
:disabled="current === 1"
@click="goToFirstPage"
>
<GeneralIcon icon="doubleLeftArrow" class="nc-pagination-icon" />
</NcButton>
</component>
<component :is="props.prevPageTooltip && mode === 'full' ? NcTooltip : 'div'">
<template v-if="props.prevPageTooltip" #title>
{{ props.prevPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:prev-page`]"
class="prev-page !border-0"
type="secondary"
size="xsmall"
:disabled="current === 1"
@click="changePage({ increase: false })"
>
<GeneralIcon icon="arrowLeft" class="nc-pagination-icon" />
</NcButton>
</component>
<div v-if="!isMobileMode" class="text-gray-500">
<NcDropdown placement="top" overlay-class-name="!shadow-none">
<NcButton class="!border-0 nc-select-page" type="secondary" size="xsmall">
<div class="flex gap-1 items-center px-2">
<span class="nc-current-page">
{{ current }}
</span>
<GeneralIcon icon="arrowDown" class="text-gray-800 mt-0.5 nc-select-expand-btn" />
</div>
</NcButton>
<template #overlay>
<NcMenu class="nc-scrollbar-thin nc-pagination-menu max-h-54 overflow-y-auto">
<NcSubMenu :key="`${localPageSize}page`" class="bg-gray-100 z-20 top-0 !sticky">
<template #title>
<div class="rounded-lg text-[13px] font-medium w-full">{{ localPageSize }} / page</div>
</template>
<NcMenuItem v-for="option in pageSizeOptions" :key="option.value" @click="localPageSize = option.value">
<span
class="text-[13px]"
:class="{
'!text-brand-500': option.value === localPageSize,
}"
>
{{ option.value }} / page
</span>
</NcMenuItem>
</NcSubMenu>
<div :key="localPageSize" class="flex flex-col mt-1 max-h-48 overflow-hidden nc-scrollbar-md gap-1">
<NcMenuItem
v-for="x in pagesList"
:key="`${localPageSize}${x.value}`"
@click.stop="
changePage({
set: x.value,
})
"
>
<div
:class="{
'text-brand-500': x.value === current,
}"
class="flex text-[13px] !w-full text-gray-800 items-center justify-between"
>
{{ x.label }}
</div>
</NcMenuItem>
</div>
</NcMenu>
</template>
</NcDropdown>
</div>
<component :is="props.nextPageTooltip && mode === 'full' ? NcTooltip : 'div'">
<template v-if="props.nextPageTooltip" #title>
{{ props.nextPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:next-page`]"
class="next-page !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="changePage({ increase: true })"
>
<GeneralIcon icon="arrowRight" class="nc-pagination-icon" />
</NcButton>
</component>
<component :is="props.lastPageTooltip && mode === 'full' ? NcTooltip : 'div'" v-if="mode === 'full'">
<template v-if="props.lastPageTooltip" #title>
{{ props.lastPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:last-page`]"
class="last-page !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="goToLastPage"
>
<GeneralIcon icon="doubleRightArrow" class="nc-pagination-icon" />
</NcButton>
</component>
</template>
<div v-if="showSizeChanger && !isMobileMode" class="text-gray-500"></div>
</div>
</template>
<style lang="scss" scoped>
.nc-pagination-icon {
@apply w-4 h-4;
}
:deep(.ant-dropdown-menu-title-content) {
@apply justify-center;
}
:deep(.nc-button:not(:disabled)) {
.nc-pagination-icon {
@apply !text-gray-500;
}
}
</style>
<style lang="scss"></style>

8
packages/nc-gui/components/nc/SubMenu.vue

@ -1,5 +1,11 @@
<script lang="ts" setup>
const props = defineProps<{
popupOffset?: number[]
}>()
</script>
<template>
<a-sub-menu class="nc-sub-menu" popup-class-name="nc-submenu-popup">
<a-sub-menu :popup-offset="props.popupOffset" class="nc-sub-menu" popup-class-name="nc-submenu-popup">
<template #title>
<div class="flex flex-row items-center gap-x-1.5 py-1.75 justify-between group hover:text-gray-800">
<div class="flex flex-row items-center gap-x-2">

295
packages/nc-gui/components/smartsheet/grid/PaginationV2.vue

@ -0,0 +1,295 @@
<script setup lang="ts">
import axios from 'axios'
import { type PaginatedType, UITypes } from 'nocodb-sdk'
const props = defineProps<{
scrollLeft?: number
paginationData: PaginatedType
changePage: (page: number) => void
}>()
const emits = defineEmits(['update:paginationData'])
const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
const isLocked = inject(IsLockedInj, ref(false))
const { changePage } = props
const vPaginationData = useVModel(props, 'paginationData', emits)
const { loadViewAggregate, updateAggregate, getAggregations, visibleFieldsComputed, displayFieldComputed } =
useViewAggregateOrThrow()
const scrollLeft = toRef(props, 'scrollLeft')
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const containerElement = ref()
watch(
scrollLeft,
(value) => {
if (containerElement.value) {
containerElement.value.scrollLeft = value
}
},
{
immediate: true,
},
)
reloadViewDataHook?.on(async () => {
await loadViewAggregate()
})
const count = computed(() => vPaginationData.value?.totalRows ?? Infinity)
const page = computed({
get: () => vPaginationData?.value?.page ?? 1,
set: async (p) => {
isPaginationLoading.value = true
try {
await changePage?.(p)
isPaginationLoading.value = false
} catch (e) {
if (axios.isCancel(e)) {
return
}
isPaginationLoading.value = false
}
},
})
const size = computed({
get: () => vPaginationData.value?.pageSize ?? 25,
set: (size: number) => {
if (vPaginationData.value) {
// if there is no change in size then return
if (vPaginationData.value?.pageSize && vPaginationData.value?.pageSize === size) {
return
}
vPaginationData.value.pageSize = size
if (vPaginationData.value.totalRows && page.value * size < vPaginationData.value.totalRows) {
changePage?.(page.value)
} else {
changePage?.(1)
}
}
},
})
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
onMounted(() => {
loadViewAggregate()
})
</script>
<template>
<div ref="containerElement" class="bg-gray-50 w-full pr-1 border-t-1 border-gray-200 overflow-x-hidden no-scrollbar flex h-9">
<div class="sticky flex items-center bg-gray-50 left-0">
<NcDropdown
:disabled="[UITypes.SpecificDBType, UITypes.ForeignKey].includes(displayFieldComputed.column?.uidt!) || isLocked"
overlay-class-name="max-h-96 relative scroll-container nc-scrollbar-thin overflow-auto"
>
<div
v-if="displayFieldComputed.field && displayFieldComputed.column?.id"
class="flex items-center overflow-x-hidden hover:bg-gray-100 cursor-pointer text-gray-500 justify-end transition-all transition-linear px-3 py-2"
:style="{
'min-width': displayFieldComputed?.width,
'max-width': displayFieldComputed?.width,
'width': displayFieldComputed?.width,
}"
>
<div class="flex relative justify-between gap-2 w-full">
<div v-if="isViewDataLoading" class="nc-pagination-skeleton flex justify-center item-center min-h-10 min-w-16 w-16">
<a-skeleton :active="true" :title="true" :paragraph="false" class="w-16 max-w-16" />
</div>
<NcTooltip v-else class="flex sticky items-center h-full">
<template #title> {{ count }} {{ count !== 1 ? $t('objects.records') : $t('objects.record') }} </template>
<span
data-testid="grid-pagination"
class="text-gray-500 text-ellipsis overflow-hidden pl-1 truncate nc-grid-row-count caption text-xs text-nowrap"
>
{{ Intl.NumberFormat('en', { notation: 'compact' }).format(count) }}
{{ count !== 1 ? $t('objects.records') : $t('objects.record') }}
</span>
</NcTooltip>
<template v-if="![UITypes.SpecificDBType, UITypes.ForeignKey].includes(displayFieldComputed.column?.uidt!)">
<div
v-if="!displayFieldComputed.field?.aggregation || displayFieldComputed.field?.aggregation === 'none'"
class="text-gray-500 opacity-0 transition group-hover:opacity-100"
>
<GeneralIcon class="text-gray-500" icon="arrowDown" />
<span class="text-[10px] font-semibold"> Summary </span>
</div>
<NcTooltip
v-else-if="displayFieldComputed.value !== undefined"
:style="{
maxWidth: `${displayFieldComputed?.width}`,
}"
>
<div style="direction: rtl" class="flex gap-2 text-nowrap truncate overflow-hidden items-center">
<span class="text-gray-600 text-[12px] font-semibold">
{{
formatAggregation(
displayFieldComputed.field.aggregation,
displayFieldComputed.value,
displayFieldComputed.column,
)
}}
</span>
<span class="text-gray-500 text-[12px] leading-4">
{{ $t(`aggregation.${displayFieldComputed.field.aggregation}`) }}
</span>
</div>
<template #title>
<div class="flex gap-2 text-nowrap overflow-hidden items-center">
<span class="text-[12px] leading-4">
{{ $t(`aggregation.${displayFieldComputed.field.aggregation}`) }}
</span>
<span class="text-[12px] font-semibold">
{{
formatAggregation(
displayFieldComputed.field.aggregation,
displayFieldComputed.value,
displayFieldComputed.column,
)
}}
</span>
</div>
</template>
</NcTooltip>
</template>
</div>
</div>
<template #overlay>
<NcMenu v-if="displayFieldComputed.field && displayFieldComputed.column?.id">
<NcMenuItem
v-for="(agg, index) in getAggregations(displayFieldComputed.column)"
:key="index"
@click="updateAggregate(displayFieldComputed.column.id, agg)"
>
<div class="flex !w-full text-[13px] text-gray-800 items-center justify-between">
{{ $t(`aggregation_type.${agg}`) }}
<GeneralIcon v-if="displayFieldComputed.field?.aggregation === agg" class="text-brand-500" icon="check" />
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
<template v-for="({ field, width, column, value }, index) in visibleFieldsComputed" :key="index">
<NcDropdown
v-if="field && column?.id"
:disabled="[UITypes.SpecificDBType, UITypes.ForeignKey].includes(column?.uidt!) || isLocked"
overlay-class-name="max-h-96 relative scroll-container nc-scrollbar-thin overflow-auto"
>
<div
class="flex items-center overflow-x-hidden justify-end group hover:bg-gray-100 cursor-pointer text-gray-500 transition-all transition-linear px-3 py-2"
:style="{
'min-width': width,
'max-width': width,
'width': width,
}"
>
<template v-if="![UITypes.SpecificDBType, UITypes.ForeignKey].includes(column?.uidt!)">
<div
v-if="field?.aggregation === 'none' || field?.aggregation === null"
class="text-gray-500 opacity-0 transition group-hover:opacity-100"
>
<GeneralIcon class="text-gray-500" icon="arrowDown" />
<span class="text-[10px] font-semibold"> Summary </span>
</div>
<NcTooltip
v-else-if="value !== undefined"
:style="{
maxWidth: `${field?.width}px`,
}"
>
<div class="flex gap-2 truncate text-nowrap overflow-hidden items-center">
<span class="text-gray-500 text-[12px] leading-4">
{{ $t(`aggregation.${field.aggregation}`).replace('Percent ', '') }}
</span>
<span class="text-gray-600 font-semibold text-[12px]">
{{ formatAggregation(field.aggregation, value, column) }}
</span>
</div>
<template #title>
<div class="flex gap-2 text-nowrap overflow-hidden items-center">
<span class="text-[12px] leading-4">
{{ $t(`aggregation.${field.aggregation}`).replace('Percent ', '') }}
</span>
<span class="font-semibold text-[12px]">
{{ formatAggregation(field.aggregation, value, column) }}
</span>
</div>
</template>
</NcTooltip>
</template>
</div>
<template #overlay>
<NcMenu>
<NcMenuItem v-for="(agg, index) in getAggregations(column)" :key="index" @click="updateAggregate(column.id, agg)">
<div class="flex !w-full text-[13px] text-gray-800 items-center justify-between">
{{ $t(`aggregation_type.${agg}`) }}
<GeneralIcon v-if="field?.aggregation === agg" class="text-brand-500" icon="check" />
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</template>
<div class="!pl-8 pr-60 !w-8 h-1"></div>
<div class="fixed h-9 bg-white border-l-1 border-gray-200 px-1 flex items-center right-0">
<NcPaginationV2
v-if="count !== Infinity"
v-model:current="page"
v-model:page-size="size"
class="xs:(mr-2)"
:total="+count"
entity-name="grid"
:prev-page-tooltip="`${renderAltOrOptlKey()}+←`"
:next-page-tooltip="`${renderAltOrOptlKey()}+→`"
:first-page-tooltip="`${renderAltOrOptlKey()}+↓`"
:last-page-tooltip="`${renderAltOrOptlKey()}+↑`"
:show-size-changer="true"
/>
</div>
</div>
</template>
<style scoped lang="scss">
:deep(.nc-menu-item-inner) {
@apply w-full;
}
.nc-grid-pagination-wrapper {
.ant-pagination-item-active {
a {
@apply text-sm !text-gray-700 !hover:text-gray-800;
}
}
}
</style>
<style lang="scss"></style>

74
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -981,6 +981,8 @@ const colPositions = computed(() => {
const scrollWrapper = computed(() => scrollParent.value || gridWrapper.value)
const scrollLeft = ref()
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? activeCell.row
col = col ?? activeCell.col
@ -1367,7 +1369,8 @@ async function reloadViewDataHandler(params: void | { shouldShowLoading?: boolea
let frame: number | null = null
useEventListener(scrollWrapper, 'scroll', () => {
useEventListener(scrollWrapper, 'scroll', (e) => {
scrollLeft.value = e.target.scrollLeft
if (frame) {
cancelAnimationFrame(frame)
}
@ -1916,6 +1919,7 @@ onKeyStroke('ArrowDown', onDown)
'mobile': isMobileMode,
'desktop': !isMobileMode,
'w-full': dataRef.length === 0,
'pr-60 pb-12': !headerOnly && !isGroupBy,
}"
:style="{
transform: `translateY(${topOffset}px) translateX(${leftOffset}px)`,
@ -2339,7 +2343,7 @@ onKeyStroke('ArrowDown', onDown)
<div class="relative">
<LazySmartsheetPagination
v-if="headerOnly !== true && paginationDataRef"
v-if="headerOnly !== true && paginationDataRef && isGroupBy"
:key="`nc-pagination-${isMobileMode}`"
v-model:pagination-data="paginationDataRef"
:show-api-timing="!isGroupBy"
@ -2434,6 +2438,72 @@ onKeyStroke('ArrowDown', onDown)
</div>
</template>
</LazySmartsheetPagination>
<LazySmartsheetGridPaginationV2
v-else-if="paginationDataRef"
v-model:pagination-data="paginationDataRef"
:change-page="changePage"
:scroll-left="scrollLeft"
/>
</div>
<div v-if="headerOnly !== true && paginationDataRef && !isGroupBy" class="absolute bottom-12 left-2">
<NcDropdown v-if="isAddingEmptyRowAllowed && !showSkeleton">
<div class="flex">
<NcButton
v-if="isMobileMode"
v-e="[isAddNewRecordGridMode ? 'c:row:add:grid' : 'c:row:add:form']"
:disabled="isPaginationLoading"
class="!rounded-r-none !border-r-0 nc-grid-add-new-row"
size="small"
type="secondary"
@click.stop="onNewRecordToFormClick()"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" />
New Record
</div>
</NcButton>
<NcButton
v-e="[isAddNewRecordGridMode ? 'c:row:add:grid' : 'c:row:add:form']"
:disabled="isPaginationLoading"
class="!rounded-r-none !border-r-0 nc-grid-add-new-row"
size="small"
type="secondary"
@click.stop="isAddNewRecordGridMode ? addEmptyRow() : onNewRecordToFormClick()"
>
<div data-testid="nc-pagination-add-record" class="flex items-center gap-2">
<GeneralIcon icon="plus" />
<template v-if="isAddNewRecordGridMode">
{{ $t('activity.newRecord') }}
</template>
<template v-else> {{ $t('activity.newRecord') }} - {{ $t('objects.viewType.form') }} </template>
</div>
</NcButton>
<NcButton v-if="!isMobileMode" size="small" class="!rounded-l-none nc-add-record-more-info" type="secondary">
<GeneralIcon icon="arrowUp" />
</NcButton>
</div>
<template #overlay>
<NcMenu>
<NcMenuItem v-e="['c:row:add:grid']" class="nc-new-record-with-grid group" @click="onNewRecordToGridClick">
<div class="flex flex-row items-center justify-start gap-x-3">
<component :is="viewIcons[ViewTypes.GRID]?.icon" class="nc-view-icon text-inherit" />
{{ $t('activity.newRecord') }} - {{ $t('objects.viewType.grid') }}
</div>
<GeneralIcon v-if="isAddNewRecordGridMode" icon="check" class="w-4 h-4 text-primary" />
</NcMenuItem>
<NcMenuItem v-e="['c:row:add:form']" class="nc-new-record-with-form group" @click="onNewRecordToFormClick">
<div class="flex flex-row items-center justify-start gap-x-3">
<GeneralIcon class="h-4.5 w-4.5" icon="article" />
{{ $t('activity.newRecord') }} - {{ $t('objects.viewType.form') }}
</div>
<GeneralIcon v-if="!isAddNewRecordGridMode" icon="check" class="w-4 h-4 text-primary" />
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
</div>
</template>

2
packages/nc-gui/components/smartsheet/grid/index.vue

@ -79,6 +79,8 @@ provide(IsCalendarInj, ref(false))
provide(RowHeightInj, rowHeight)
useProvideViewAggregate(view, meta, xWhere)
const isPublic = inject(IsPublicInj, ref(false))
// reload table data reload hook as fallback to rowdatareload

2
packages/nc-gui/components/tabs/Smartsheet.vue

@ -59,6 +59,8 @@ provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(IsFormInj, isForm)
provide(TabMetaInj, activeTab)
provide(ActiveSourceInj, activeSource)
provide(ReloadAggregateHookInj, createEventHook())
provide(
ReadonlyInj,
computed(

11
packages/nc-gui/composables/useData.ts

@ -30,6 +30,8 @@ export function useData(args: {
const { isPaginationLoading } = storeToRefs(useViewsStore())
const reloadAggregate = inject(ReloadAggregateHookInj)
const selectedAllRecords = computed({
get() {
return !!formattedData.value.length && formattedData.value.every((row: Row) => row.rowMeta.selected)
@ -76,6 +78,8 @@ export function useData(args: {
{ ...insertObj, ...(ltarState || {}) },
)
await reloadAggregate?.trigger()
if (!undo) {
Object.assign(currentRow, {
row: { ...insertedData, ...row },
@ -214,6 +218,7 @@ export function useData(args: {
// query: { ignoreWebhook: !saved }
// }
)
await reloadAggregate?.trigger({ field: [property] })
if (!undo) {
addUndo({
@ -355,6 +360,7 @@ export function useData(args: {
}
await $api.dbTableRow.bulkUpdate(NOCO, metaValue?.base_id as string, metaValue?.id as string, updateArray)
await reloadAggregate?.trigger({ field: props })
if (!undo) {
addUndo({
@ -446,6 +452,8 @@ export function useData(args: {
viewId: viewMetaValue.id,
})
await reloadAggregate?.trigger()
await callbacks?.loadData?.()
await callbacks?.globalCallback?.()
}
@ -522,6 +530,8 @@ export function useData(args: {
encodeURIComponent(id),
)
await reloadAggregate?.trigger()
if (res.message) {
message.info(
`Record delete failed: ${`Unable to delete record with ID ${id} because of the following:
@ -877,6 +887,7 @@ export function useData(args: {
const bulkDeletedRowsData = await $api.dbDataTableRow.delete(metaValue?.id as string, rows.length === 1 ? rows[0] : rows, {
viewId: viewMetaValue?.id as string,
})
await reloadAggregate?.trigger()
return rows.length === 1 && bulkDeletedRowsData ? [bulkDeletedRowsData] : bulkDeletedRowsData
} catch (error: any) {

25
packages/nc-gui/composables/useSharedView.ts

@ -209,6 +209,30 @@ export function useSharedView() {
)
}
const fetchAggregatedData = async (param: {
aggregation?: Array<{
field: string
type: string
}>
filtersArr?: FilterType[]
where?: string
}) => {
if (!sharedView.value) return {}
return await $api.public.dataTableAggregate(
sharedView.value.uuid!,
{
...param,
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
} as any,
{
headers: {
'xc-password': password.value,
},
},
)
}
const fetchSharedViewActiveDate = async (param: {
from_date: string
to_date: string
@ -293,6 +317,7 @@ export function useSharedView() {
fetchSharedViewActiveDate,
fetchSharedCalendarViewData,
fetchSharedViewGroupedData,
fetchAggregatedData,
paginationData,
sorts,
exportFile,

145
packages/nc-gui/composables/useViewAggregate.ts

@ -0,0 +1,145 @@
import type { Ref } from 'vue'
import { type ColumnType, CommonAggregations, type TableType, UITypes, type ViewType, getAvailableAggregations } from 'nocodb-sdk'
const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
(
view: Ref<ViewType | undefined>,
meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>,
where?: ComputedRef<string | undefined>,
) => {
const { $api: api } = useNuxtApp()
const fields = inject(FieldsInj, ref([]))
const isPublic = inject(IsPublicInj, ref(false))
const { gridViewCols, updateGridViewColumn } = useViewColumnsOrThrow()
const { nestedFilters } = useSmartsheetStoreOrThrow()
const { fetchAggregatedData } = useSharedView()
const aggregations = ref({}) as Ref<Record<string, any>>
const reloadAggregate = inject(ReloadAggregateHookInj, createEventHook())
const visibleFieldsComputed = computed(() => {
const fie = fields.value.map((field, index) => ({ field, index })).filter((f) => f.index !== 0)
return fie.map((f) => {
const gridField = gridViewCols.value[f.field.id!]
if (!gridField) {
return { field: null, index: f.index }
}
return {
value: aggregations.value[f.field.title] ?? null,
field: gridField,
column: f.field,
index: f.index,
width: `${Number(gridField.width.replace('px', ''))}px` || '180px',
}
})
})
const displayFieldComputed = computed(() => {
if (!fields.value?.length || !gridViewCols.value)
return {
field: null,
width: '180px',
}
return {
value: aggregations.value[fields.value[0].title] ?? null,
column: fields.value[0],
field: gridViewCols.value[fields.value[0].id!],
width: `${Number((gridViewCols.value[fields.value[0]!.id!].width ?? '').replace('px', '')) + 64}px` || '244px',
}
})
const getAggregations = (column: ColumnType) => {
if (column.uidt === UITypes.Formula && (column.colOptions as any)?.parsed_tree?.dataType) {
return getAvailableAggregations(column.uidt!, (column.colOptions as any).parsed_tree)
}
return getAvailableAggregations(column.uidt!)
}
const loadViewAggregate = async (
fields?: Array<{
field: string
type: string
}>,
) => {
if (!meta.value?.id || !view.value?.id) return
try {
const data = !isPublic.value
? await api.dbDataTableAggregate.dbDataTableAggregate(meta.value.id, {
viewId: view.value.id,
where: where?.value,
...(fields ? { aggregation: fields } : {}),
})
: await fetchAggregatedData({
where: where?.value,
filtersArr: nestedFilters.value,
...(fields ? { aggregation: fields } : {}),
})
Object.assign(aggregations.value, data)
} catch (error) {
console.log(error)
message.error(await extractSdkResponseErrorMsgv2(error as any))
}
}
const updateAggregate = async (fieldId: string, agg: string) => {
loadViewAggregate([
{
field: fieldId,
type: agg,
},
])
await updateGridViewColumn(fieldId, { aggregation: agg })
}
reloadAggregate?.on(async (_fields) => {
if (!_fields || !_fields.field?.length) {
await loadViewAggregate()
}
if (_fields?.field) {
const fieldAggregateMapping = _fields.field.reduce((acc, field) => {
const f = fields.value.find((f) => f.title === field)
acc[f.id] = gridViewCols.value[f.id].aggregation ?? CommonAggregations.None
return acc
}, {} as Record<string, string>)
await loadViewAggregate(
Object.entries(fieldAggregateMapping).map(([field, type]) => ({
field,
type,
})),
)
}
})
return {
loadViewAggregate,
isPublic,
updateAggregate,
getAggregations,
displayFieldComputed,
visibleFieldsComputed,
}
},
)
export { useProvideViewAggregate }
export function useViewAggregateOrThrow() {
const viewAggregate = useViewAggregate()
if (viewAggregate == null) throw new Error('Please call `useProvideViewAggregate` on the appropriate parent component')
return viewAggregate
}

11
packages/nc-gui/composables/useViewColumns.ts

@ -1,4 +1,12 @@
import type { ColumnType, GridColumnReqType, GridColumnType, MapType, TableType, ViewType } from 'nocodb-sdk'
import {
type ColumnType,
CommonAggregations,
type GridColumnReqType,
type GridColumnType,
type MapType,
type TableType,
type ViewType,
} from 'nocodb-sdk'
import { ViewTypes, isHiddenCol, isSystemColumn } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
@ -83,6 +91,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
...currentColumnField,
show: currentColumnField.show || isColumnViewEssential(currentColumnField),
order: currentColumnField.order || order++,
aggregation: currentColumnField?.aggregation ?? CommonAggregations.None,
system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]),
isViewEssentialField: isColumnViewEssential(column),
}

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

@ -31,6 +31,9 @@ export const ReloadViewDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?:
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?: boolean; offset?: number } | void>> =
Symbol('reload-row-data-injection')
export const ReloadAggregateHookInj: InjectionKey<EventHook<{ field: string[] } | undefined>> = Symbol(
'reload-aggregate-data-injection',
)
export const OpenNewRecordFormHookInj: InjectionKey<EventHook<void>> = Symbol('open-new-record-form-injection')
export const FieldsInj: InjectionKey<Ref<ColumnType[]>> = Symbol('fields-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')

54
packages/nc-gui/lang/en.json

@ -38,6 +38,60 @@
"candlestick_chart": "Candlestick Chart"
}
},
"aggregation": {
"sum": "Sum",
"count": "Count",
"min": "Min",
"max": "Max",
"avg": "Avg",
"median": "Median",
"std_dev": "Std dev",
"histogram": "Histogram",
"range": "Range",
"percent_empty": "Empty",
"percent_filled": "Filled",
"percent_unique": "Unique",
"count_unique": "Unique",
"count_empty": "Empty",
"count_filled": "Filled",
"earliest_date": "Min date",
"latest_date": "Max date",
"date_range": "Range",
"month_range": "Range",
"checked": "Checked",
"unchecked": "Unchecked",
"percent_checked": "Checked",
"percent_unchecked": "Unchecked",
"attachment_size": "Size",
"none": "None"
},
"aggregation_type": {
"sum": "Sum",
"count": "Count",
"min": "Min",
"max": "Max",
"avg": "Average",
"median": "Median",
"std_dev": "Standard Deviation",
"histogram": "Histogram",
"range": "Range",
"percent_empty": "Percent Empty",
"percent_filled": "Percent Filled",
"percent_unique": "Percent Unique",
"count_unique": "Unique",
"count_empty": "Empty",
"count_filled": "Filled",
"earliest_date": "Earliest Date",
"latest_date": "Latest Date",
"date_range": "Date Range",
"month_range": "Month Range",
"checked": "Checked",
"unchecked": "Unchecked",
"percent_checked": "Percent Checked",
"percent_unchecked": "Percent Unchecked",
"attachment_size": "Attachment Size",
"none": "None"
},
"general": {
"role": "Role",
"general": "General",

133
packages/nc-gui/utils/aggregationUtils.ts

@ -0,0 +1,133 @@
import {
AttachmentAggregations,
BooleanAggregations,
type ColumnType,
CommonAggregations,
DateAggregations,
UITypes,
dateFormats,
timeFormats,
} from 'nocodb-sdk'
import dayjs from 'dayjs'
const getDateValue = (modelValue: string | null | number, col: ColumnType, isSystemCol?: boolean) => {
const dateFormat = !isSystemCol ? parseProp(col.meta)?.date_format ?? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'
if (!modelValue || !dayjs(modelValue).isValid()) {
return ''
}
return dayjs(/^\d+$/.test(String(modelValue)) ? +modelValue : modelValue).format(dateFormat)
}
const roundTo = (num: unknown, precision = 1) => {
if (!num || Number.isNaN(num)) return num
const factor = 10 ** precision
return Math.round(+num * factor) / factor
}
const getDateTimeValue = (modelValue: string | null, col: ColumnType, isXcdbBase?: boolean) => {
if (!modelValue || !dayjs(modelValue).isValid()) {
return ''
}
const dateFormat = parseProp(col?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(col?.meta)?.time_format ?? timeFormats[0]
const dateTimeFormat = `${dateFormat} ${timeFormat}`
if (!isXcdbBase) {
return dayjs(/^\d+$/.test(modelValue) ? +modelValue : modelValue, dateTimeFormat).format(dateTimeFormat)
}
return dayjs(modelValue).utc().local().format(dateTimeFormat)
}
const getCurrencyValue = (modelValue: string | number | null | undefined, col: ColumnType): string => {
const currencyMeta = {
currency_locale: 'en-US',
currency_code: 'USD',
...parseProp(col.meta),
}
try {
if (modelValue === null || modelValue === undefined || Number.isNaN(modelValue)) {
return modelValue === null || modelValue === undefined ? '' : (modelValue as string)
}
return new Intl.NumberFormat(currencyMeta.currency_locale || 'en-US', {
style: 'currency',
currency: currencyMeta.currency_code || 'USD',
}).format(+modelValue)
} catch (e) {
return modelValue as string
}
}
function formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
}
const formatAggregation = (aggregation: any, value: any, column: ColumnType) => {
if ([DateAggregations.EarliestDate, DateAggregations.LatestDate].includes(aggregation)) {
if (column.uidt === UITypes.DateTime) {
return getDateTimeValue(value, column)
} else if (column.uidt === UITypes.Date) {
return getDateValue(value, column)
}
return getDateTimeValue(value, column)
}
if (
[
CommonAggregations.PercentEmpty,
CommonAggregations.PercentFilled,
CommonAggregations.PercentUnique,
BooleanAggregations.PercentChecked,
BooleanAggregations.PercentUnchecked,
].includes(aggregation)
) {
return `${roundTo(value, 1) ?? 0}%`
}
if ([DateAggregations.MonthRange, DateAggregations.DateRange].includes(aggregation)) {
return aggregation === DateAggregations.DateRange ? `${value ?? 0} days` : `${value ?? 0} months`
}
if (
[
CommonAggregations.Count,
CommonAggregations.CountEmpty,
CommonAggregations.CountFilled,
CommonAggregations.CountUnique,
].includes(aggregation)
) {
return value
}
if ([AttachmentAggregations.AttachmentSize].includes(aggregation)) {
return formatBytes(value ?? 0)
}
if (column.uidt === UITypes.Currency) {
return getCurrencyValue(value, column)
}
if (column.uidt === UITypes.Percent) {
return `${roundTo(value, 1)}%`
}
if (column.uidt === UITypes.Duration) {
return convertMS2Duration(value, parseProp(column.meta)?.duration || 0)
}
if (typeof value === 'number') {
return roundTo(value, 1) ?? '∞'
}
return value
}
export { formatAggregation }

136
packages/nocodb-sdk/src/lib/aggregationHelper.ts

@ -0,0 +1,136 @@
import UITypes from './UITypes';
import { FormulaDataTypes } from '~/lib/formulaHelpers';
enum NumericalAggregations {
Sum = 'sum',
Min = 'min',
Max = 'max',
Avg = 'avg',
Median = 'median',
StandardDeviation = 'std_dev',
// Histogram = 'histogram',
Range = 'range',
}
enum CommonAggregations {
Count = 'count',
CountEmpty = 'count_empty',
CountFilled = 'count_filled',
CountUnique = 'count_unique',
PercentEmpty = 'percent_empty',
PercentFilled = 'percent_filled',
PercentUnique = 'percent_unique',
None = 'none',
}
enum AttachmentAggregations {
AttachmentSize = 'attachment_size',
}
enum BooleanAggregations {
Checked = 'checked',
Unchecked = 'unchecked',
PercentChecked = 'percent_checked',
PercentUnchecked = 'percent_unchecked',
}
enum DateAggregations {
EarliestDate = 'earliest_date',
LatestDate = 'latest_date',
DateRange = 'date_range',
MonthRange = 'month_range',
}
const AllAggregations = {
...CommonAggregations,
...NumericalAggregations,
...AttachmentAggregations,
...BooleanAggregations,
...DateAggregations,
};
const getAvailableAggregations = (type: string, parsed_tree?): string[] => {
let returnAggregations = [];
if (type === UITypes.Formula && parsed_tree?.dataType) {
switch (parsed_tree.dataType) {
case FormulaDataTypes.BOOLEAN:
returnAggregations = [
...Object.values(BooleanAggregations),
CommonAggregations.None,
];
break;
case FormulaDataTypes.DATE:
returnAggregations = [
...Object.values(DateAggregations),
...Object.values(CommonAggregations),
];
break;
case FormulaDataTypes.NUMERIC:
returnAggregations = [
...Object.values(NumericalAggregations),
...Object.values(CommonAggregations),
];
break;
default:
returnAggregations = [...Object.values(CommonAggregations)];
break;
}
}
switch (type) {
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rating:
case UITypes.Rollup:
case UITypes.Links:
returnAggregations = [
...Object.values(NumericalAggregations),
...Object.values(CommonAggregations),
];
break;
case UITypes.Attachment:
returnAggregations = [
...Object.values(AttachmentAggregations),
...Object.values(CommonAggregations),
];
break;
case UITypes.Checkbox:
returnAggregations = [
...Object.values(BooleanAggregations),
CommonAggregations.None,
];
break;
case UITypes.Date:
case UITypes.DateTime:
case UITypes.LastModifiedTime:
case UITypes.CreatedTime:
returnAggregations = [
...Object.values(DateAggregations),
...Object.values(CommonAggregations),
];
break;
case UITypes.SpecificDBType:
case UITypes.ForeignKey:
return [];
}
if (!returnAggregations.length) {
returnAggregations = [...Object.values(CommonAggregations)];
}
return returnAggregations.filter((item) => item !== CommonAggregations.Count);
};
export {
getAvailableAggregations,
NumericalAggregations,
CommonAggregations,
BooleanAggregations,
DateAggregations,
AttachmentAggregations,
AllAggregations,
};

1
packages/nocodb-sdk/src/lib/index.ts

@ -29,3 +29,4 @@ export * from '~/lib/passwordHelpers';
export * from '~/lib/mergeSwaggerSchema';
export * from '~/lib/dateTimeHelper';
export * from '~/lib/form';
export * from '~/lib/aggregationHelper';

15
packages/nocodb/src/controllers/data-table.controller.ts

@ -117,6 +117,21 @@ export class DataTableController {
});
}
@Get(['/api/v2/tables/:modelId/aggregate'])
@Acl('dataAggregate')
async dataAggregate(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('modelId') modelId: string,
@Query('viewId') viewId: string,
) {
return await this.dataTableService.dataAggregate(context, {
query: req.query,
modelId,
viewId,
});
}
@Get(['/api/v2/tables/:modelId/records/:rowId'])
@Acl('dataRead')
async dataRead(

18
packages/nocodb/src/controllers/public-datas.controller.ts

@ -36,6 +36,24 @@ export class PublicDatasController {
return pagedResponse;
}
@Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/aggregate',
'/api/v2/public/shared-view/:sharedViewUuid/aggregate',
])
async dataAggregate(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('sharedViewUuid') sharedViewUuid: string,
) {
const response = await this.publicDatasService.dataAggregate(context, {
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid,
});
return response;
}
@Get([
'/api/v1/db/public/shared-view/:sharedViewUuid/groupby',
'/api/v2/public/shared-view/:sharedViewUuid/groupby',

112
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -33,7 +33,6 @@ import type {
import type {
BarcodeColumn,
FormulaColumn,
GridViewColumn,
LinkToAnotherRecordColumn,
QrCodeColumn,
RollupColumn,
@ -47,6 +46,7 @@ import {
BaseUser,
Column,
Filter,
GridViewColumn,
Model,
PresignedUrl,
Sort,
@ -74,6 +74,7 @@ import { extractProps } from '~/helpers/extractProps';
import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
import { getAliasGenerator } from '~/utils';
import applyAggregation from '~/db/aggregation';
dayjs.extend(utc);
@ -183,10 +184,10 @@ export function replaceDynamicFieldWithValue(
*/
class BaseModelSqlv2 {
protected _dbDriver: XKnex;
protected model: Model;
protected viewId: string;
protected _proto: any;
protected _columns = {};
public model: Model;
public context: NcContext;
public static config: any = defaultLimitConfig;
@ -696,6 +697,110 @@ class BaseModelSqlv2 {
return await this.execAndParse(qb);
}
async aggregate(args: { filterArr?: Filter[]; where?: string }) {
try {
const { where, aggregation } = this._getListArgs(args as any);
let viewColumns = (
await GridViewColumn.list(this.context, this.viewId)
).filter((c) => c.show);
// By default, the aggregation is done based on the columns configured in the view
// If the aggregation parameter is provided, only the columns mentioned in the aggregation parameter are considered
// Also the aggregation type from the parameter is given preference over the aggregation type configured in the view
if (aggregation?.length) {
viewColumns = viewColumns
.map((c) => {
const agg = aggregation.find((a) => a.field === c.fk_column_id);
return new GridViewColumn({
...c,
show: !!agg,
aggregation: agg ? agg.type : c.aggregation,
});
})
.filter((c) => c.show);
}
const columns = await this.model.getColumns(this.context);
const aliasColObjMap = await this.model.getAliasColObjMap(
this.context,
columns,
);
const qb = this.dbDriver(this.tnPath);
// Apply filers from view configuration, filterArr and where parameter
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(
this,
[
...(this.viewId
? [
new Filter({
children:
(await Filter.rootFilterList(this.context, {
viewId: this.viewId,
})) || [],
is_group: true,
}),
]
: []),
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
],
qb,
);
const selectors: Array<Knex.Raw> = [];
// Generating a knex raw aggregation query for each column in the view
await Promise.all(
viewColumns.map(async (viewColumn) => {
const col = columns.find((c) => c.id === viewColumn.fk_column_id);
if (!col) return null;
const aggSql = await applyAggregation({
baseModelSqlv2: this,
aggregation: viewColumn.aggregation,
column: col,
});
if (aggSql) selectors.push(this.dbDriver.raw(aggSql));
}),
);
// If no queries are generated, return empty object
if (!selectors.length) {
return {};
}
qb.select(...selectors);
console.log('\n\n', qb.toQuery(), '\n\n');
// Some aggregation on Date, DateTime related columns may generate result other than Date, DateTime
// So skip the date conversion
const data = await this.execAndParse(qb, null, {
first: true,
skipDateConversion: true,
});
return data;
} catch (e) {
logger.log(e);
return {};
}
}
async groupBy(args: {
where?: string;
column_name: string;
@ -2403,7 +2508,7 @@ class BaseModelSqlv2 {
if (sortObj) await sortV2(this, sortObj, qb);
}
protected async getSelectQueryBuilderForFormula(
async getSelectQueryBuilderForFormula(
column: Column<any>,
tableAlias?: string,
validateFormula = false,
@ -2804,6 +2909,7 @@ class BaseModelSqlv2 {
obj.fields = args.fields || args.f;
obj.sort = args.sort || args.s;
obj.pks = args.pks;
obj.aggregation = args.aggregation || [];
return obj;
}

202
packages/nocodb/src/db/aggregation.ts

@ -0,0 +1,202 @@
import {
AttachmentAggregations,
BooleanAggregations,
CommonAggregations,
DateAggregations,
getAvailableAggregations,
NumericalAggregations,
UITypes,
} from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { BarcodeColumn, QrCodeColumn, RollupColumn } from '~/models';
import { Column } from '~/models';
import { NcError } from '~/helpers/catchError';
import genRollupSelectv2 from '~/db/genRollupSelectv2';
import generateLookupSelectQuery from '~/db/generateLookupSelectQuery';
import { genPgAggregateQuery } from '~/db/aggregations/pg';
import { genMysql2AggregatedQuery } from '~/db/aggregations/mysql2';
import { genSqlite3AggregateQuery } from '~/db/aggregations/sqlite3';
const validateColType = (column: Column, aggregation: string) => {
const agg = getAvailableAggregations(
column.uidt,
column.colOptions?.parsed_tree,
);
if (!agg.includes(aggregation)) {
NcError.badRequest(
`Aggregation ${aggregation} is not available for column type ${column.uidt}`,
);
}
if (
Object.values(BooleanAggregations).includes(
aggregation as BooleanAggregations,
)
) {
return 'boolean';
}
if (
Object.values(CommonAggregations).includes(
aggregation as CommonAggregations,
)
) {
return 'common';
}
if (
Object.values(DateAggregations).includes(aggregation as DateAggregations)
) {
return 'date';
}
if (
Object.values(NumericalAggregations).includes(
aggregation as NumericalAggregations,
)
) {
return 'numerical';
}
if (
Object.values(AttachmentAggregations).includes(
aggregation as AttachmentAggregations,
)
) {
return 'attachment';
}
return 'unknown';
};
export default async function applyAggregation({
baseModelSqlv2,
aggregation,
column,
}: {
baseModelSqlv2: BaseModelSqlv2;
aggregation: string;
column: Column;
}): Promise<string | undefined> {
if (!aggregation || !column) {
return;
}
const { context, dbDriver: knex, model } = baseModelSqlv2;
/*
All aggregations are not available for all UITypes. We validate the column type
and the aggregation type to make sure that the aggregation is available for the column type.
We also return the type of aggregation that has to be applied on the column.
The return value can be one of the following:
- common - common aggregations like count, count empty, count filled, count unique, etc.
- numerical - numerical aggregations like sum, avg, min, max, etc.
- boolean - boolean aggregations like checked, unchecked, percent checked, percent unchecked, etc.
- date - date aggregations like earliest date, latest date, date range, month range, etc.
- attachment - attachment aggregations like attachment size.
- unknown - if the aggregation is not supported yet
*/
const aggType = validateColType(column, aggregation);
// If the aggregation is not available for the column type, we throw an error.
if (aggType === 'unknown') {
NcError.notImplemented(`Aggregation ${aggregation} is not implemented yet`);
}
// If the column is a barcode or qr code column, we fetch the column that the virtual column refers to.
if (column.uidt === UITypes.Barcode || column.uidt === UITypes.QrCode) {
column = new Column({
...(await column
.getColOptions<BarcodeColumn | QrCodeColumn>(context)
.then((col) => col.getValueColumn(context))),
id: column.id,
});
}
let column_name_query = column.column_name;
if (column.uidt === UITypes.CreatedTime && !column.column_name)
column_name_query = 'created_at';
if (column.uidt === UITypes.LastModifiedTime && !column.column_name)
column_name_query = 'updated_at';
if (column.uidt === UITypes.CreatedBy && !column.column_name)
column_name_query = 'created_by';
if (column.uidt === UITypes.LastModifiedBy && !column.column_name)
column_name_query = 'updated_by';
const parsedFormulaType = column.colOptions?.parsed_tree?.dataType;
/* The following column types require special handling for aggregation:
* - Links
* - Rollup
* - Formula
* - Lookup
* - LinkToAnotherRecord
* These column types require special handling because they are virtual columns and do not have a direct column name.
* We generate the select query for these columns and use the generated query for aggregation.
* */
switch (column.uidt) {
case UITypes.Links:
case UITypes.Rollup:
column_name_query = (
await genRollupSelectv2({
baseModelSqlv2,
knex,
columnOptions: (await column.getColOptions(context)) as RollupColumn,
})
).builder;
break;
case UITypes.Formula:
column_name_query = (
await baseModelSqlv2.getSelectQueryBuilderForFormula(column)
).builder;
break;
case UITypes.LinkToAnotherRecord:
case UITypes.Lookup:
column_name_query = (
await generateLookupSelectQuery({
baseModelSqlv2,
column: column,
alias: null,
model,
})
).builder;
break;
}
if (knex.client.config.client === 'pg') {
return genPgAggregateQuery({
column,
baseModelSqlv2,
aggregation,
column_query: column_name_query,
parsedFormulaType,
aggType,
});
} else if (knex.client.config.client === 'mysql2') {
return genMysql2AggregatedQuery({
column,
baseModelSqlv2,
aggregation,
column_query: column_name_query,
parsedFormulaType,
aggType,
});
} else if (knex.client.config.client === 'sqlite3') {
return genSqlite3AggregateQuery({
column,
baseModelSqlv2,
aggregation,
column_query: column_name_query,
parsedFormulaType,
aggType,
});
} else {
NcError.notImplemented(
`Aggregation is not implemented for ${knex.client.config.client} yet.`,
);
}
}

405
packages/nocodb/src/db/aggregations/mysql2.ts

@ -0,0 +1,405 @@
import {
AttachmentAggregations,
BooleanAggregations,
CommonAggregations,
DateAggregations,
FormulaDataTypes,
NumericalAggregations,
UITypes,
} from 'nocodb-sdk';
import type { Column } from '~/models';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { Knex } from 'knex';
export function genMysql2AggregatedQuery({
column,
baseModelSqlv2,
aggregation,
column_query,
parsedFormulaType,
aggType,
}: {
column: Column;
baseModelSqlv2: BaseModelSqlv2;
aggregation: string;
column_query: string;
parsedFormulaType?: FormulaDataTypes;
aggType:
| 'common'
| 'numerical'
| 'boolean'
| 'date'
| 'attachment'
| 'unknown';
}) {
let aggregationSql: Knex.Raw | undefined;
const { dbDriver: knex } = baseModelSqlv2;
let secondaryCondition: any = "''";
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Time,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
secondaryCondition = 'NULL';
} else if ([UITypes.Rating].includes(column.uidt)) {
secondaryCondition = 0;
}
if (aggType === 'common') {
switch (aggregation) {
case CommonAggregations.Count:
aggregationSql = knex.raw(`COUNT(*) AS ??`, [column.id]);
break;
case CommonAggregations.CountEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`SUM(CASE WHEN JSON_LENGTH(??) IS NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NULL OR (??) = ${secondaryCondition} THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountFilled:
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Time,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NOT NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountUnique:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT(??, '$'))) AS ??`,
[column_query, column.id],
);
break;
}
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Time,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`COUNT(DISTINCT CASE WHEN (??) IS NOT NULL THEN (??) END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`COUNT(DISTINCT CASE WHEN ?? IS NOT NULL AND ?? != ${secondaryCondition} THEN ?? END) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`(SUM(CASE WHEN JSON_LENGTH(??) IS NULL THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NULL OR (??) = ${secondaryCondition} THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentFilled:
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Time,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NOT NULL THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentUnique:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(DISTINCT JSON_UNQUOTE(JSON_EXTRACT((??), '$'))) * 100.0 / NULLIF(COUNT(*), 0) AS ??`,
[column_query, column.id],
);
break;
}
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Time,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(COUNT(DISTINCT CASE WHEN ?? IS NOT NULL THEN ?? END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(COUNT(DISTINCT CASE WHEN ?? IS NOT NULL AND ?? != ${secondaryCondition} THEN ?? END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.None:
break;
}
} else if (aggType === 'numerical') {
switch (aggregation) {
case NumericalAggregations.Avg:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`AVG(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`AVG((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Max:
aggregationSql = knex.raw(`MAX((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Min:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`MIN(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`MIN((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Sum:
aggregationSql = knex.raw(`SUM((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.StandardDeviation:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(`STDDEV((??)) AS ??`, [
column_query,
column.id,
]);
break;
}
aggregationSql = knex.raw(`STDDEV((??)) AS ??`, [
column_query,
column.id,
]);
break;
case NumericalAggregations.Range:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`(MAX((??)) - MIN(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END)) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`(MAX((??)) - MIN((??))) AS ??`, [
column_query,
column_query,
column.id,
]);
break;
case NumericalAggregations.Median:
// This is the sqlite3 port median query. Need to use native mysql median query
aggregationSql = knex.raw(
`
(
SELECT AVG(??)
FROM (
SELECT ??
FROM ??
ORDER BY ??
LIMIT 2 - (SELECT COUNT(*) FROM ??) % 2 -- Handle even/odd number of rows
OFFSET (SELECT (COUNT(*) - 1) / 2 FROM ??) -- Calculate the median offset
) AS median_subquery
) AS ??`,
[
column_query,
column_query,
baseModelSqlv2.tnPath,
column_query,
baseModelSqlv2.tnPath,
baseModelSqlv2.tnPath,
column.id,
column.id,
],
);
break;
default:
break;
}
} else if (aggType === 'boolean') {
switch (aggregation) {
case BooleanAggregations.Checked:
aggregationSql = knex.raw(
`SUM(CASE WHEN ?? = true THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
case BooleanAggregations.Unchecked:
aggregationSql = knex.raw(
`SUM(CASE WHEN ?? = false OR ?? IS NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case BooleanAggregations.PercentChecked:
aggregationSql = knex.raw(
`(SUM(CASE WHEN ?? = true THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
case BooleanAggregations.PercentUnchecked:
aggregationSql = knex.raw(
`(SUM(CASE WHEN ?? = false OR ?? IS NULL THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'date') {
switch (aggregation) {
case DateAggregations.EarliestDate:
aggregationSql = knex.raw(`MIN(??) AS ??`, [column_query, column.id]);
break;
case DateAggregations.LatestDate:
aggregationSql = knex.raw(`MAX(??) AS ??`, [column_query, column.id]);
break;
case DateAggregations.DateRange:
aggregationSql = knex.raw(
`TIMESTAMPDIFF(DAY, MIN(??), MAX(??)) AS ??`,
[column_query, column_query, column.id],
);
break;
case DateAggregations.MonthRange:
aggregationSql = knex.raw(
`PERIOD_DIFF(DATE_FORMAT(MAX(??), '%Y%m'), DATE_FORMAT(MIN(??), '%Y%m')) AS ??`,
[column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'attachment') {
switch (aggregation) {
case AttachmentAggregations.AttachmentSize:
aggregationSql = knex.raw(
`(SELECT SUM(JSON_EXTRACT(json_object, '$.size')) FROM ?? CROSS JOIN JSON_TABLE(CAST(?? AS JSON), '$[*]' COLUMNS (json_object JSON PATH '$')) AS json_array) AS ??`,
[baseModelSqlv2.tnPath, column_query, column.id],
);
break;
}
}
return aggregationSql?.toQuery();
}

398
packages/nocodb/src/db/aggregations/pg.ts

@ -0,0 +1,398 @@
import {
AttachmentAggregations,
BooleanAggregations,
CommonAggregations,
DateAggregations,
FormulaDataTypes,
NumericalAggregations,
UITypes,
} from 'nocodb-sdk';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { Knex } from 'knex';
import type { Column } from '~/models';
export function genPgAggregateQuery({
column,
baseModelSqlv2,
aggregation,
column_query,
parsedFormulaType,
aggType,
}: {
column: Column;
column_query: string;
baseModelSqlv2: BaseModelSqlv2;
aggregation: string;
parsedFormulaType?: FormulaDataTypes;
aggType:
| 'common'
| 'numerical'
| 'boolean'
| 'date'
| 'attachment'
| 'unknown';
}) {
let aggregationSql: Knex.Raw | undefined;
const { dbDriver: knex } = baseModelSqlv2;
let secondaryCondition: any = "''";
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Time,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
secondaryCondition = 'NULL';
} else if ([UITypes.Rating].includes(column.uidt)) {
secondaryCondition = 0;
}
if (aggType === 'common') {
switch (aggregation) {
case CommonAggregations.Count:
aggregationSql = knex.raw(`COUNT(*) AS ??`, [column.id]);
break;
case CommonAggregations.CountEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(*) FILTER (WHERE (??) IS NULL) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`COUNT(*) FILTER (WHERE (??) IS NULL OR (??) = ${secondaryCondition}) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountFilled:
// The condition IS NOT NULL AND (column_query) != 'NULL' is not same for the following column_query types:
// Hence we need to handle them separately.
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Time,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`COUNT(*) FILTER (WHERE (??) IS NOT NULL) AS ??`,
[column_query, column.id],
);
break;
}
// For other column_query types, the condition is IS NOT NULL AND (column_query) != 'NULL'
aggregationSql = knex.raw(
`COUNT(*) FILTER (WHERE (??) IS NOT NULL AND (??) != ${secondaryCondition}) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountUnique:
// JSON Does not support DISTINCT for json column_query type. Hence we need to cast the column_query to text.
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(DISTINCT ((??)::text)) FILTER (WHERE (??) IS NOT NULL) AS ??`,
[column_query, column_query, column.id],
);
break;
}
// The condition IS NOT NULL AND (column_query) != 'NULL' is not same for the following column_query types:
// Hence we need to handle them separately.
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Time,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`COUNT(DISTINCT (??)) FILTER (WHERE (??) IS NOT NULL) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`COUNT(DISTINCT (??)) FILTER (WHERE (??) IS NOT NULL AND (??) != ${secondaryCondition}) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) IS NULL) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) IS NULL OR (??) = ${secondaryCondition}) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentFilled:
// The condition IS NOT NULL AND (column_query) != 'NULL' is not same for the following column_query types:
// Hence we need to handle them separately.
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Time,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) IS NOT NULL) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) IS NOT NULL AND (??) != ${secondaryCondition}) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentUnique:
// JSON Does not support DISTINCT for json column_query type. Hence we need to cast the column_query to text.
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`(COUNT(DISTINCT ((??)::text)) FILTER (WHERE (??) IS NOT NULL) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
}
// The condition IS NOT NULL AND (column_query) != 'NULL' is not same for the following column_query types:
// Hence we need to handle them separately.
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Time,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(COUNT(DISTINCT (??)) FILTER (WHERE (??) IS NOT NULL) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(COUNT(DISTINCT (??)) FILTER (WHERE (??) IS NOT NULL AND (??) != ${secondaryCondition}) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.None:
break;
}
} else if (aggType === 'numerical') {
switch (aggregation) {
case NumericalAggregations.Avg:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`AVG((??)) FILTER (WHERE (??) != ??) AS ??`,
[column_query, column_query, secondaryCondition, column.id],
);
break;
}
aggregationSql = knex.raw(`AVG((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Max:
aggregationSql = knex.raw(`MAX((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Min:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`MIN((??)) FILTER (WHERE (??) != ??) AS ??`,
[column_query, column_query, secondaryCondition, column.id],
);
break;
}
aggregationSql = knex.raw(`MIN((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Sum:
aggregationSql = knex.raw(`SUM((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.StandardDeviation:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`stddev_pop((??)) FILTER (WHERE (??) != ??) AS ??`,
[column_query, column_query, secondaryCondition, column.id],
);
break;
}
aggregationSql = knex.raw(`stddev_pop((??)) AS ??`, [
column_query,
column.id,
]);
break;
case NumericalAggregations.Range:
aggregationSql = knex.raw(`MAX((??)) - MIN((??)) AS ??`, [
column_query,
column_query,
column.id,
]);
break;
case NumericalAggregations.Median:
aggregationSql = knex.raw(
`percentile_cont(0.5) within group (order by (??)) AS ??`,
[column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'boolean') {
switch (aggregation) {
case BooleanAggregations.Checked:
aggregationSql = knex.raw(`COUNT(*) FILTER (WHERE (??) = true) AS ??`, [
column_query,
column.id,
]);
break;
case BooleanAggregations.Unchecked:
aggregationSql = knex.raw(
`COUNT(*) FILTER (WHERE (??) = false OR (??) = NULL) AS ??`,
[column_query, column_query, column.id],
);
break;
case BooleanAggregations.PercentChecked:
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) = true) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
case BooleanAggregations.PercentUnchecked:
aggregationSql = knex.raw(
`(COUNT(*) FILTER (WHERE (??) = false OR (??) = NULL) * 100.0 / NULLIF(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'date') {
switch (aggregation) {
case DateAggregations.EarliestDate:
aggregationSql = knex.raw(`MIN((??)) AS ??`, [column_query, column.id]);
break;
case DateAggregations.LatestDate:
aggregationSql = knex.raw(`MAX((??)) AS ??`, [column_query, column.id]);
break;
// The Date, DateTime, CreatedTime, LastModifiedTime columns are casted to DATE.
case DateAggregations.DateRange:
aggregationSql = knex.raw(`MAX((??)::date) - MIN((??)::date) AS ??`, [
column_query,
column_query,
column.id,
]);
break;
// The Date, DateTime, CreatedTime, LastModifiedTime columns are casted to DATE.
case DateAggregations.MonthRange:
aggregationSql = knex.raw(
`DATE_PART('year', AGE(MAX((??)::date), MIN((??)::date))) * 12 +
DATE_PART('month', AGE(MAX((??)::date), MIN((??)::date))) AS ??`,
[column_query, column_query, column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'attachment') {
switch (aggregation) {
case AttachmentAggregations.AttachmentSize:
aggregationSql = knex.raw(
`(SELECT SUM((json_object ->> 'size')::int) FROM ?? CROSS JOIN LATERAL jsonb_array_elements(??::jsonb) AS json_array(json_object)) AS ??`,
[baseModelSqlv2.tnPath, column_query, column.id],
);
break;
}
}
return aggregationSql?.toQuery();
}

423
packages/nocodb/src/db/aggregations/sqlite3.ts

@ -0,0 +1,423 @@
import {
AttachmentAggregations,
BooleanAggregations,
CommonAggregations,
DateAggregations,
FormulaDataTypes,
NumericalAggregations,
UITypes,
} from 'nocodb-sdk';
import type { Column } from '~/models';
import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2';
import type { Knex } from 'knex';
export function genSqlite3AggregateQuery({
column,
baseModelSqlv2,
aggregation,
column_query,
parsedFormulaType,
aggType,
}: {
column: Column;
baseModelSqlv2: BaseModelSqlv2;
aggregation: string;
column_query: string;
parsedFormulaType?: FormulaDataTypes;
aggType:
| 'common'
| 'numerical'
| 'boolean'
| 'date'
| 'attachment'
| 'unknown';
}) {
let aggregationSql: Knex.Raw | undefined;
const { dbDriver: knex } = baseModelSqlv2;
let secondaryCondition: any = "''";
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Time,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
secondaryCondition = 'NULL';
} else if ([UITypes.Rating].includes(column.uidt)) {
secondaryCondition = 0;
}
if (aggType === 'common') {
switch (aggregation) {
case CommonAggregations.Count:
aggregationSql = knex.raw(`COUNT(*) AS ??`, [column.id]);
break;
case CommonAggregations.CountEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`SUM(CASE WHEN json_array_length(??) IS NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NULL OR (??) = ${secondaryCondition} THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountFilled:
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Time,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NOT NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`SUM(CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.CountUnique:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(DISTINCT json_extract(??, '$')) AS ??`,
[column_query, column.id],
);
break;
}
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Time,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`COUNT(DISTINCT CASE WHEN (??) IS NOT NULL THEN (??) END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`COUNT(DISTINCT CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN ?? END) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentEmpty:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`(SUM(CASE WHEN json_array_length(??) IS NULL THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NULL OR (??) = ${secondaryCondition} THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentFilled:
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Time,
UITypes.Decimal,
UITypes.Year,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.JSON,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NOT NULL THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(SUM(CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
case CommonAggregations.PercentUnique:
if ([UITypes.JSON].includes(column.uidt)) {
aggregationSql = knex.raw(
`COUNT(DISTINCT json_extract((??), '$')) * 100.0 / IFNULL(COUNT(*), 0) AS ??`,
[column_query, column.id],
);
break;
}
if (
[
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Date,
UITypes.DateTime,
UITypes.Number,
UITypes.Decimal,
UITypes.Year,
UITypes.Time,
UITypes.Currency,
UITypes.Duration,
UITypes.Percent,
UITypes.Rollup,
UITypes.Links,
UITypes.ID,
UITypes.LinkToAnotherRecord,
UITypes.Lookup,
].includes(column.uidt) ||
[FormulaDataTypes.DATE, FormulaDataTypes.NUMERIC].includes(
parsedFormulaType,
)
) {
aggregationSql = knex.raw(
`(COUNT(DISTINCT CASE WHEN (??) IS NOT NULL THEN (??) END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(
`(COUNT(DISTINCT CASE WHEN (??) IS NOT NULL AND (??) != ${secondaryCondition} THEN (??) END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
case CommonAggregations.None:
break;
}
} else if (aggType === 'numerical') {
switch (aggregation) {
case NumericalAggregations.Avg:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`AVG(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`AVG((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Max:
aggregationSql = knex.raw(`MAX((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Min:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`MIN(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END) AS ??`,
[column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`MIN((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.Sum:
aggregationSql = knex.raw(`SUM((??)) AS ??`, [column_query, column.id]);
break;
case NumericalAggregations.StandardDeviation:
aggregationSql = knex.raw(
`(
SELECT
CASE
WHEN COUNT(*) > 0 THEN
SQRT(SUM(((??) - avg_value) * ((??) - avg_value)) / COUNT(*))
ELSE
NULL
END AS ??
FROM (
SELECT
(??),
(SELECT AVG((??)) FROM ??) AS avg_value
FROM
??
)
) AS ??`,
[
column_query,
column_query,
column.id,
column_query,
column_query,
baseModelSqlv2.tnPath,
baseModelSqlv2.tnPath,
column.id,
],
);
break;
case NumericalAggregations.Range:
if (column.uidt === UITypes.Rating) {
aggregationSql = knex.raw(
`(MAX((??)) - MIN(CASE WHEN (??) != ${secondaryCondition} THEN (??) ELSE NULL END)) AS ??`,
[column_query, column_query, column_query, column.id],
);
break;
}
aggregationSql = knex.raw(`(MAX((??)) - MIN((??))) AS ??`, [
column_query,
column_query,
column.id,
]);
break;
case NumericalAggregations.Median:
aggregationSql = knex.raw(
`(
SELECT AVG((??))
FROM (
SELECT (??)
FROM ??
ORDER BY (??)
LIMIT 2 - (SELECT COUNT(*) FROM ??) % 2 -- Handle even/odd number of rows
OFFSET (SELECT (COUNT(*) - 1) / 2 FROM ??) -- Calculate the median offset
)
) AS ??`,
[
column_query,
column_query,
baseModelSqlv2.tnPath,
column_query,
baseModelSqlv2.tnPath,
baseModelSqlv2.tnPath,
column.id,
],
);
break;
default:
break;
}
} else if (aggType === 'boolean') {
switch (aggregation) {
case BooleanAggregations.Checked:
aggregationSql = knex.raw(
`SUM(CASE WHEN ?? = 1 THEN 1 ELSE 0 END) AS ??`,
[column_query, column.id],
);
break;
case BooleanAggregations.Unchecked:
aggregationSql = knex.raw(
`SUM(CASE WHEN ?? = 0 OR ?? IS NULL THEN 1 ELSE 0 END) AS ??`,
[column_query, column_query, column.id],
);
break;
case BooleanAggregations.PercentChecked:
aggregationSql = knex.raw(
`(SUM(CASE WHEN ?? = 1 THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column.id],
);
break;
case BooleanAggregations.PercentUnchecked:
aggregationSql = knex.raw(
`(SUM(CASE WHEN ?? = 0 OR ?? IS NULL THEN 1 ELSE 0 END) * 100.0 / IFNULL(COUNT(*), 0)) AS ??`,
[column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'date') {
switch (aggregation) {
case DateAggregations.EarliestDate:
aggregationSql = knex.raw(`MIN(??) AS ??`, [column_query, column.id]);
break;
case DateAggregations.LatestDate:
aggregationSql = knex.raw(`MAX(??) AS ??`, [column_query, column.id]);
break;
case DateAggregations.DateRange:
aggregationSql = knex.raw(
`CAST(JULIANDAY(MAX(??)) - JULIANDAY(MIN(??)) AS INTEGER) AS ??`,
[column_query, column_query, column.id],
);
break;
case DateAggregations.MonthRange:
aggregationSql = knex.raw(
`((strftime('%Y', MAX(??)) * 12 + strftime('%m', MAX(??))) -
(strftime('%Y', MIN(??)) * 12 + strftime('%m', MIN(??)))) AS ??`,
[column_query, column_query, column_query, column_query, column.id],
);
break;
default:
break;
}
} else if (aggType === 'attachment') {
switch (aggregation) {
case AttachmentAggregations.AttachmentSize:
aggregationSql = knex.raw(
`(SELECT SUM(CAST(json_extract(value, '$.size') AS INTEGER))
FROM ??, json_each(??)) AS ??`,
[baseModelSqlv2.tnPath, column_query, column.id],
);
break;
default:
break;
}
}
return aggregationSql?.toQuery();
}

6
packages/nocodb/src/db/sql-data-mapper/lib/BaseModel.ts

@ -1501,6 +1501,11 @@ abstract class BaseModel {
async errorDeleteb(err, data, trx?: any) {}
}
export interface XcAggregation {
field: string;
type: string;
}
export interface XcFilter {
where?: string;
filter?: string;
@ -1515,6 +1520,7 @@ export interface XcFilter {
filterArr?: Filter[];
sortArr?: Sort[];
pks?: string;
aggregation?: XcAggregation[];
}
export interface XcFilterWithAlias extends XcFilter {

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -38,6 +38,7 @@ import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links';
import * as nc_049_clear_notifications from '~/meta/migrations/v2/nc_049_clear_notifications';
import * as nc_050_tenant_isolation from '~/meta/migrations/v2/nc_050_tenant_isolation';
import * as nc_051_source_readonly_columns from '~/meta/migrations/v2/nc_051_source_readonly_columns';
import * as nc_052_field_aggregation from '~/meta/migrations/v2/nc_052_field_aggregation';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -87,6 +88,7 @@ export default class XcMigrationSourcev2 {
'nc_049_clear_notifications',
'nc_050_tenant_isolation',
'nc_051_source_readonly_columns',
'nc_052_field_aggregation',
]);
}
@ -176,6 +178,8 @@ export default class XcMigrationSourcev2 {
return nc_050_tenant_isolation;
case 'nc_051_source_readonly_columns':
return nc_051_source_readonly_columns;
case 'nc_052_field_aggregation':
return nc_052_field_aggregation;
}
}
}

16
packages/nocodb/src/meta/migrations/v2/nc_052_field_aggregation.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.GRID_VIEW_COLUMNS, (table) => {
table.string('aggregation', 30).nullable().defaultTo(null);
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.GRID_VIEW_COLUMNS, (table) => {
table.dropColumn('aggregation');
});
};
export { up, down };

3
packages/nocodb/src/models/GridViewColumn.ts

@ -22,6 +22,8 @@ export default class GridViewColumn implements GridColumnType {
group_by_order?: number;
group_by_sort?: string;
aggregation?: string;
constructor(data: GridViewColumn) {
Object.assign(this, data);
}
@ -161,6 +163,7 @@ export default class GridViewColumn implements GridColumnType {
'group_by',
'group_by_order',
'group_by_sort',
'aggregation',
]);
// set meta

22
packages/nocodb/src/models/View.ts

@ -1,4 +1,9 @@
import { isSystemColumn, UITypes, ViewTypes } from 'nocodb-sdk';
import {
CommonAggregations,
isSystemColumn,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import type { BoolType, ColumnReqType, ViewType } from 'nocodb-sdk';
import type { NcContext } from '~/interface/config';
import Model from '~/models/Model';
@ -1775,7 +1780,13 @@ export default class View implements ViewType {
'enable_scanner',
'meta',
]
: ['width', 'group_by', 'group_by_order', 'group_by_sort']),
: [
'width',
'group_by',
'group_by_order',
'group_by_sort',
'aggregation',
]),
]),
fk_view_id: view.id,
base_id: view.base_id,
@ -1843,6 +1854,8 @@ export default class View implements ViewType {
let show = 'show' in column ? column.show : true;
const aggregation = CommonAggregations.None;
if (view.type === ViewTypes.GALLERY) {
const galleryView = await GalleryView.get(context, view.id, ncMeta);
if (
@ -1900,6 +1913,11 @@ export default class View implements ViewType {
fk_view_id: view.id,
base_id: view.base_id,
source_id: view.source_id,
...(view.type === ViewTypes.GRID
? {
aggregation,
}
: {}),
});
}
}

97
packages/nocodb/src/schema/swagger-v2.json

@ -7751,6 +7751,90 @@
"description": "Create a new row for the target shared view"
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/aggregate": {
"parameters": [
{
"schema": {
"type": "string",
"example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f"
},
"name": "sharedViewUuid",
"in": "path",
"required": true,
"description": "Shared View UUID"
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "Read Shared View Aggregated Data",
"operationId": "public-data-table-aggregate",
"description": "Read aggregated data from a given table",
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"schema": {
"type": "array",
"description": "List of fields to be aggregated",
"items": {
"type": "object"
}
},
"in": "query",
"name": "aggregation",
"description": "Used for selective aggregation"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "90",
"SingleSelect": "50"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/groupby": {
"parameters": [
{
@ -14345,7 +14429,8 @@
"help": null,
"group_by": 0,
"group_by_order": null,
"group_by_sort": null
"group_by_sort": null,
"aggregation": "sum"
}
],
"title": "Grid Column Model",
@ -14425,6 +14510,11 @@
"$ref": "#/components/schemas/StringOrNull",
"description": "Group By Sort",
"example": "asc"
},
"aggregation": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Aggregation",
"example": "sum"
}
},
"x-stoplight": {
@ -14475,6 +14565,11 @@
"$ref": "#/components/schemas/StringOrNull",
"description": "Group By Sort",
"example": "asc"
},
"aggregation": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Aggregation",
"example": "sum"
}
},
"title": "Grid Column Request Model",

184
packages/nocodb/src/schema/swagger.json

@ -12389,6 +12389,90 @@
"description": "Get the table rows but exculding the current record's children and parent"
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/aggregate": {
"parameters": [
{
"schema": {
"type": "string",
"example": "24a6d0bb-e45d-4b1a-bfef-f492d870de9f"
},
"name": "sharedViewUuid",
"in": "path",
"required": true,
"description": "Shared View UUID"
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "Read Shared View Aggregated Data",
"operationId": "public-data-table-aggregate",
"description": "Read aggregated data from a given table",
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"schema": {
"type": "array",
"description": "List of fields to be aggregated",
"items": {
"type": "object"
}
},
"in": "query",
"name": "aggregation",
"description": "Used for selective aggregation"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "90",
"SingleSelect": "50"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/group/{columnId}": {
"parameters": [
{
@ -16420,6 +16504,93 @@
]
}
},
"/api/v2/tables/{tableId}/aggregate": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "tableId",
"in": "path",
"required": true,
"description": "Table ID"
},
{
"schema": {
"type": "string"
},
"required": true,
"name": "viewId",
"in": "query",
"description": "View ID is required"
},
{
"schema": {
"type": "array",
"description": "List of fields to be aggregated",
"items": {
"type": "object"
}
},
"in": "query",
"name": "aggregation",
"description": "Used for selective aggregation"
}
],
"get": {
"summary": "Read Aggregated Data",
"operationId": "db-data-table-aggregate",
"description": "Read aggregated data from a given table",
"tags": [
"DB Data Table Aggregate"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "filterArrJson",
"description": "Used for multiple filter queries"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"Id": 1,
"Title": "90",
"SingleSelect": "50"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v2/tables/{tableId}/records": {
"parameters": [
{
@ -20413,7 +20584,8 @@
"help": null,
"group_by": 0,
"group_by_order": null,
"group_by_sort": null
"group_by_sort": null,
"aggregation": null
}
],
"title": "Grid Column Model",
@ -20493,6 +20665,11 @@
"$ref": "#/components/schemas/StringOrNull",
"description": "Group By Sort",
"example": "asc"
},
"aggregation": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Aggregation Type",
"example": "sum"
}
},
"x-stoplight": {
@ -20543,6 +20720,11 @@
"$ref": "#/components/schemas/StringOrNull",
"description": "Group By Sort",
"example": "asc"
},
"aggregation": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Aggregation",
"example": "sum"
}
},
"title": "Grid Column Request Model",

40
packages/nocodb/src/services/data-table.service.ts

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { isLinksOrLTAR, RelationTypes } from 'nocodb-sdk';
import { isLinksOrLTAR, RelationTypes, ViewTypes } from 'nocodb-sdk';
import { nocoExecute } from 'nc-help';
import { validatePayload } from 'src/helpers';
import type { LinkToAnotherRecordColumn } from '~/models';
@ -65,6 +65,44 @@ export class DataTableService {
return row;
}
async dataAggregate(
context: NcContext,
param: {
baseId?: string;
modelId: string;
viewId?: string;
query: any;
},
) {
const { model, view } = await this.getModelAndView(context, param);
const source = await Source.get(context, model.source_id);
const baseModel = await Model.getBaseModelSQL(context, {
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
});
if (view.type !== ViewTypes.GRID) {
NcError.badRequest('Aggregation is only supported on grid views');
}
const listArgs: any = { ...param.query };
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.aggregation = JSON.parse(listArgs.aggregation);
} catch (e) {}
const data = await baseModel.aggregate(listArgs);
return data;
}
async dataInsert(
context: NcContext,
param: {

45
packages/nocodb/src/services/public-datas.service.ts

@ -96,6 +96,51 @@ export class PublicDatasService {
return new PagedResponseImpl(data, { ...param.query, count });
}
async dataAggregate(
context: NcContext,
param: {
sharedViewUuid: string;
password?: string;
query: any;
},
) {
const view = await View.getByUUID(context, param.sharedViewUuid);
if (!view) NcError.viewNotFound(param.sharedViewUuid);
if (view.type !== ViewTypes.GRID) {
NcError.notFound('Not found');
}
if (view.password && view.password !== param.password) {
return NcError.invalidSharedViewPassword();
}
const model = await Model.getByIdOrName(context, {
id: view?.fk_model_id,
});
const source = await Source.get(context, model.source_id);
const baseModel = await Model.getBaseModelSQL(context, {
id: model.id,
viewId: view?.id,
dbDriver: await NcConnectionMgrv2.get(source),
});
const listArgs: any = { ...param.query };
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
try {
listArgs.aggregation = JSON.parse(listArgs.aggregation);
} catch (e) {}
return await baseModel.aggregate(listArgs);
}
// todo: Handle the error case where view doesnt belong to model
async groupedDataList(
context: NcContext,

2
packages/nocodb/src/utils/acl.ts

@ -91,6 +91,7 @@ const permissionScopes = {
'indexList',
'list',
'dataCount',
'dataAggregate',
'swaggerJson',
'commentList',
'commentsCount',
@ -198,6 +199,7 @@ const rolePermissions:
indexList: true,
list: true,
dataCount: true,
dataAggregate: true,
swaggerJson: true,
nestedDataList: true,

11
tests/playwright/pages/Dashboard/Grid/AggregationBar.ts

@ -0,0 +1,11 @@
import BasePage from '../../Base';
import { GridPage } from './index';
export class AggregaionBarPage extends BasePage {
readonly parent: GridPage;
constructor(parent: GridPage) {
super(parent.rootPage);
this.parent = parent;
}
}

7
tests/playwright/pages/Dashboard/Grid/index.ts

@ -13,6 +13,7 @@ import { RowPageObject } from './Row';
import { WorkspaceMenuObject } from '../common/WorkspaceMenu';
import { GroupPageObject } from './Group';
import { ColumnHeaderPageObject } from './columnHeader';
import { AggregaionBarPage } from './AggregationBar';
export class GridPage extends BasePage {
readonly dashboard: DashboardPage;
@ -30,6 +31,7 @@ export class GridPage extends BasePage {
readonly workspaceMenu: WorkspaceMenuObject;
readonly rowPage: RowPageObject;
readonly groupPage: GroupPageObject;
readonly aggregationBar: AggregaionBarPage;
readonly btn_addNewRow: Locator;
@ -49,6 +51,7 @@ export class GridPage extends BasePage {
this.workspaceMenu = new WorkspaceMenuObject(this);
this.rowPage = new RowPageObject(this);
this.groupPage = new GroupPageObject(this);
this.aggregationBar = new AggregaionBarPage(this);
this.btn_addNewRow = this.get().locator('.nc-grid-add-new-cell');
}
@ -354,7 +357,7 @@ export class GridPage extends BasePage {
while (parseInt(recordCnt) !== count && i < 5) {
await this.get().locator(`.nc-pagination-skeleton`).waitFor({ state: 'hidden' });
records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts();
recordCnt = records[0].split(' ')[0];
recordCnt = (records[0] ?? '').split(' ')[0];
// to ensure page loading is complete
i++;
@ -402,7 +405,7 @@ export class GridPage extends BasePage {
return;
}
await expect(this.get().locator(`.nc-pagination .ant-select-selection-item`).first()).toHaveText(pageNumber);
await expect(this.get().locator(`.nc-pagination .nc-current-page`).first()).toHaveText(pageNumber);
}
async waitLoading() {

5
tests/playwright/pages/Dashboard/common/Footbar/index.ts

@ -17,7 +17,7 @@ export class FootbarPage extends BasePage {
this.parent = parent;
this.leftSidebarToggle = this.get().locator(`div.nc-sidebar-left-toggle-icon`);
this.rightSidebarToggle = this.get().locator(`div.nc-sidebar-right-toggle-icon`);
this.btn_addNewRow = this.get().getByTestId('nc-pagination-add-record');
this.btn_addNewRow = this.rootPage.getByTestId('nc-pagination-add-record');
}
get() {
@ -38,7 +38,8 @@ export class FootbarPage extends BasePage {
}
async clickAddRecordFromForm() {
await this.get().locator(`button.ant-btn`).nth(1).click();
await this.rootPage.locator('.nc-add-record-more-info').click();
await this.rootPage.locator('.ant-dropdown-content:visible').waitFor();
await this.rootPage.locator('.ant-dropdown-content:visible').locator('.nc-new-record-with-form').click();
}

Loading…
Cancel
Save