Browse Source

feat: groupby bulk apis (#9040)

* feat: bulk groupby api

* feat: bulk dataList api

* feat: bulk dataList api

* feat: bulk data load

* feat: bulk data load

* fix: load Data

* feat: custom pagination limit

* feat: public bulkapis(wip)

* fix: bug fixes

* fix: ui bug fixes

* fix: issues with strings

* fix: case when alias is empty string

* fix: cleanup

* fix: datetime

* fix: mysql group apis failing

* fix: sqlite3 groupby fixes

* fix: grouoby tests

* fix: grouoby tests

* fix: invalid page size fix: issue with bulkAggregation fix: aggregation duplicate query

* fix: duplicate api call

* fix: duplicate aggregation api call

* fix: large data-api calls on reload

* fix: tests

* fix: update bulkapis to use post

* fix: page size not updating
pull/9071/head
Anbarasu 4 months ago committed by GitHub
parent
commit
7e750a92be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 29
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  2. 19
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  3. 9
      packages/nc-gui/components/smartsheet/grid/PaginationV2.vue
  4. 69
      packages/nc-gui/composables/useSharedView.ts
  5. 405
      packages/nc-gui/composables/useViewGroupBy.ts
  6. 3
      packages/nc-gui/store/views.ts
  7. 32
      packages/nocodb/src/controllers/data-table.controller.ts
  8. 32
      packages/nocodb/src/controllers/public-datas.controller.ts
  9. 701
      packages/nocodb/src/db/BaseModelSqlv2.ts
  10. 1
      packages/nocodb/src/db/sql-data-mapper/lib/BaseModel.ts
  11. 2
      packages/nocodb/src/helpers/extractLimitAndOffset.ts
  12. 293
      packages/nocodb/src/schema/swagger.json
  13. 104
      packages/nocodb/src/services/data-table.service.ts
  14. 139
      packages/nocodb/src/services/public-datas.service.ts
  15. 6
      tests/playwright/pages/Dashboard/Grid/Group.ts
  16. 4
      tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts

29
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -10,7 +10,13 @@ import type { Group } from '~/lib/types'
const props = defineProps<{ const props = defineProps<{
group: Group group: Group
loadGroups: (params?: any, group?: Group) => Promise<void> loadGroups: (
params?: any,
group?: Group,
options?: {
triggerChildOnly?: boolean
},
) => Promise<void>
loadGroupData: (group: Group, force?: boolean, params?: any) => Promise<void> loadGroupData: (group: Group, force?: boolean, params?: any) => Promise<void>
loadGroupPage: (group: Group, p: number) => Promise<void> loadGroupPage: (group: Group, p: number) => Promise<void>
groupWrapperChangePage: (page: number, groupWrapper?: Group) => Promise<void> groupWrapperChangePage: (page: number, groupWrapper?: Group) => Promise<void>
@ -42,6 +48,8 @@ const meta = inject(MetaInj, ref())
const fields = inject(FieldsInj, ref()) const fields = inject(FieldsInj, ref())
const { gridViewPageSize } = useGlobal()
const scrollLeft = toRef(props, 'scrollLeft') const scrollLeft = toRef(props, 'scrollLeft')
const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore()) const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
@ -133,9 +141,13 @@ const findAndLoadSubGroup = (key: any) => {
const grp = vGroup.value.children.find((g) => `${g.key}` === k) const grp = vGroup.value.children.find((g) => `${g.key}` === k)
if (grp) { if (grp) {
if (grp.nested) { if (grp.nested) {
if (!grp.children?.length) props.loadGroups({}, grp) if (!grp.children[0].children?.length) {
props.loadGroups({}, grp, {
triggerChildOnly: true,
})
}
} else { } else {
if (!grp.rows?.length || grp.count !== grp.rows?.length) _loadGroupData(grp) if (!grp.rows?.length) _loadGroupData(grp)
} }
} }
} }
@ -146,7 +158,6 @@ const findAndLoadSubGroup = (key: any) => {
const reloadViewDataHandler = (params: void | { shouldShowLoading?: boolean | undefined; offset?: number | undefined }) => { const reloadViewDataHandler = (params: void | { shouldShowLoading?: boolean | undefined; offset?: number | undefined }) => {
if (vGroup.value.nested) { if (vGroup.value.nested) {
props.loadGroups({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, vGroup.value) props.loadGroups({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, vGroup.value)
props.loadGroupAggregation(vGroup.value)
} else { } else {
_loadGroupData(vGroup.value, true, { _loadGroupData(vGroup.value, true, {
...(params?.offset !== undefined ? { offset: params.offset } : {}), ...(params?.offset !== undefined ? { offset: params.offset } : {}),
@ -174,8 +185,13 @@ watch([() => vGroup.value.key], async (n, o) => {
}) })
onMounted(async () => { onMounted(async () => {
if (vGroup.value.root === true) { if (vGroup.value.root === true && !vGroup.value?.children?.length) {
await props.loadGroups({}, vGroup.value) await props.loadGroups(
{
limit: gridViewPageSize.value,
},
vGroup.value,
)
} }
}) })
@ -581,6 +597,7 @@ const bgColor = computed(() => {
<LazySmartsheetGridPaginationV2 <LazySmartsheetGridPaginationV2
v-if="vGroup.root" v-if="vGroup.root"
v-model:pagination-data="vGroup.paginationData" v-model:pagination-data="vGroup.paginationData"
:show-size-changer="true"
:scroll-left="_scrollLeft" :scroll-left="_scrollLeft"
custom-label="groups" custom-label="groups"
:depth="maxDepth" :depth="maxDepth"

19
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -6,7 +6,7 @@ import { NavigateDir } from '~/lib/enums'
const props = defineProps<{ const props = defineProps<{
group: Group group: Group
loadGroups: (params?: any, group?: Group) => Promise<void> loadGroups: (params?: any, group?: Group, options?: { triggerChildOnly: boolean }) => Promise<void>
loadGroupData: (group: Group, force?: boolean, params?: any) => Promise<void> loadGroupData: (group: Group, force?: boolean, params?: any) => Promise<void>
loadGroupPage: (group: Group, p: number) => Promise<void> loadGroupPage: (group: Group, p: number) => Promise<void>
groupWrapperChangePage: (page: number, groupWrapper?: Group) => Promise<void> groupWrapperChangePage: (page: number, groupWrapper?: Group) => Promise<void>
@ -42,8 +42,6 @@ const isPublic = inject(IsPublicInj, ref(false))
const skipRowRemovalOnCancel = ref(false) const skipRowRemovalOnCancel = ref(false)
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus } = useSmartsheetStoreOrThrow()
const route = router.currentRoute const route = router.currentRoute
@ -166,13 +164,6 @@ const reloadTableData = async (params: void | { shouldShowLoading?: boolean | un
}) })
} }
onBeforeUnmount(async () => {
// reset hooks
reloadViewDataHook?.off(reloadTableData)
})
reloadViewDataHook?.on(reloadTableData)
provide(IsGroupByInj, ref(true)) provide(IsGroupByInj, ref(true))
const pagination = computed(() => { const pagination = computed(() => {
@ -273,14 +264,8 @@ async function deleteSelectedRowsWrapper() {
await deleteSelectedRows() await deleteSelectedRows()
// reload table data // reload table data
await reloadTableData({ shouldShowLoading: true }) await reloadTableData({ shouldShowLoading: false })
} }
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.GROUP_BY_RELOAD || event === SmartsheetStoreEvents.DATA_RELOAD) {
reloadViewDataHook?.trigger()
}
})
</script> </script>
<template> <template>

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

@ -23,13 +23,10 @@ const showSizeChanger = toRef(props, 'showSizeChanger')
const vPaginationData = useVModel(props, 'paginationData', emits) const vPaginationData = useVModel(props, 'paginationData', emits)
const { loadViewAggregate, updateAggregate, getAggregations, visibleFieldsComputed, displayFieldComputed } = const { updateAggregate, getAggregations, visibleFieldsComputed, displayFieldComputed } = useViewAggregateOrThrow()
useViewAggregateOrThrow()
const scrollLeft = toRef(props, 'scrollLeft') const scrollLeft = toRef(props, 'scrollLeft')
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const containerElement = ref() const containerElement = ref()
watch( watch(
@ -44,10 +41,6 @@ watch(
}, },
) )
reloadViewDataHook?.on(async () => {
await loadViewAggregate()
})
const count = computed(() => vPaginationData.value?.totalRows ?? Infinity) const count = computed(() => vPaginationData.value?.totalRows ?? Infinity)
const page = computed({ const page = computed({

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

@ -233,25 +233,70 @@ export function useSharedView() {
) )
} }
const fetchBulkAggregatedData = async (param: { const fetchBulkAggregatedData = async (
aggregation?: Array<{ param: {
field: string aggregation?: Array<{
type: string field: string
}> type: string
aggregateFilterList: Array<{ }>
filtersArr?: FilterType[]
where?: string
},
bulkFilterList: Array<{
where: string where: string
alias: string alias: string
}> }>,
filtersArr?: FilterType[] ) => {
where?: string
}) => {
if (!sharedView.value) return {} if (!sharedView.value) return {}
return await $api.public.dataTableBulkAggregate( return await $api.public.dataTableBulkAggregate(
sharedView.value.uuid!, sharedView.value.uuid!,
bulkFilterList,
{
...param,
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
} as any,
{
headers: {
'xc-password': password.value,
},
},
)
}
const fetchBulkListData = async (
param: {
where?: string
},
bulkFilterList: Array<{
where: string
alias: string
}>,
) => {
if (!sharedView.value) return {}
return await $api.public.dataTableBulkDataList(sharedView.value.uuid!, bulkFilterList, {
...param,
} as any)
}
const fetchBulkGroupData = async (
param: {
filtersArr?: FilterType[]
where?: string
},
bulkFilterList: Array<{
where: string
alias: string
}>,
) => {
if (!sharedView.value) return {}
return await $api.public.dataTableBulkGroup(
sharedView.value.uuid!,
bulkFilterList,
{ {
...param, ...param,
aggregateFilterList: JSON.stringify(param.aggregateFilterList),
filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value), filterArrJson: JSON.stringify(param.filtersArr ?? nestedFilters.value),
} as any, } as any,
{ {
@ -367,6 +412,8 @@ export function useSharedView() {
fetchAggregatedData, fetchAggregatedData,
fetchBulkAggregatedData, fetchBulkAggregatedData,
fetchSharedViewAttachment, fetchSharedViewAttachment,
fetchBulkGroupData,
fetchBulkListData,
paginationData, paginationData,
sorts, sorts,
exportFile, exportFile,

405
packages/nc-gui/composables/useViewGroupBy.ts

@ -29,7 +29,7 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
const { base } = storeToRefs(useBase()) const { base } = storeToRefs(useBase())
const { sharedView, fetchSharedViewData, fetchBulkAggregatedData } = useSharedView() const { sharedView, fetchSharedViewData, fetchBulkAggregatedData, fetchBulkListData, fetchBulkGroupData } = useSharedView()
const { gridViewCols } = useViewColumnsOrThrow() const { gridViewCols } = useViewColumnsOrThrow()
@ -64,7 +64,7 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const groupByGroupLimit = computed(() => { const groupByGroupLimit = computed(() => {
return appInfo.value.defaultGroupByLimit?.limitGroup || 10 return appInfo.value.defaultGroupByLimit?.limitGroup || 25
}) })
const groupByRecordLimit = computed(() => { const groupByRecordLimit = computed(() => {
@ -229,7 +229,104 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
} }
} }
async function loadGroups(params: any = {}, group?: Group) { const processGroupData = async (response: any, group?: Group) => {
group = group || rootGroup.value
const groupby = groupBy.value[group.nestedIn.length]
if (!groupby) return group
const tempList: Group[] = response.list.reduce((acc: Group[], curr: Record<string, any>) => {
const keyExists = acc.find(
(a) => a.key === valueToTitle(curr[groupby.column.column_name!] ?? curr[groupby.column.title!], groupby.column),
)
if (keyExists) {
keyExists.count += +curr.count
keyExists.paginationData = {
page: 1,
pageSize: group.paginationData.pageSize || groupByGroupLimit.value,
totalRows: keyExists.count,
}
return acc
}
if (groupby.column.title && groupby.column.uidt) {
acc.push({
key: valueToTitle(curr[groupby.column.title!], groupby.column),
column: groupby.column,
count: +curr.count,
color: findKeyColor(curr[groupby.column.title!], groupby.column),
nestedIn: [
...group!.nestedIn,
{
title: groupby.column.title,
column_name: groupby.column.title!,
key: valueToTitle(curr[groupby.column.title!], groupby.column),
column_uidt: groupby.column.uidt,
},
],
aggregations: curr.aggregations ?? {},
paginationData: {
page: 1,
pageSize:
group!.nestedIn.length < groupBy.value.length - 1
? group.paginationData.pageSize || groupByGroupLimit.value
: groupByRecordLimit.value,
totalRows: +curr.count,
},
nested: group!.nestedIn.length < groupBy.value.length - 1,
})
}
return acc
}, [])
if (!group.children) group.children = []
for (const temp of tempList) {
const keyExists = group.children?.find((a) => a.key === temp.key)
if (keyExists) {
temp.paginationData = {
page: keyExists.paginationData.page || temp.paginationData.page,
pageSize: keyExists.paginationData.pageSize || temp.paginationData.pageSize,
totalRows: temp.count,
}
temp.color = keyExists.color
// update group
Object.assign(keyExists, temp)
continue
}
group.children.push(temp)
}
group.children = group.children.filter((c) => tempList.find((t) => t.key === c.key))
if (group.count <= (group.paginationData.pageSize ?? groupByGroupLimit.value)) {
group.children.sort((a, b) => {
const orderA = tempList.findIndex((t) => t.key === a.key)
const orderB = tempList.findIndex((t) => t.key === b.key)
return orderA - orderB
})
}
group.paginationData = response.pageInfo
// to cater the case like when querying with a non-zero offset
// the result page may point to the target page where the actual returned data don't display on
const expectedPage = Math.max(1, Math.ceil(group.paginationData.totalRows! / group.paginationData.pageSize!))
if (expectedPage < group.paginationData.page!) {
await groupWrapperChangePage(expectedPage, group)
}
return group
}
async function loadGroups(
params: any = {},
group?: Group,
options?: {
triggerChildOnly: boolean
},
) {
try { try {
group = group || rootGroup.value group = group || rootGroup.value
if (!base?.value?.id || !view.value?.id || !view.value?.fk_model_id || !group) return if (!base?.value?.id || !view.value?.id || !view.value?.fk_model_id || !group) return
@ -259,141 +356,78 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
group.displayValueProp = (relatedTableMeta.columns?.find((c) => c.pv) || relatedTableMeta.columns?.[0])?.title || '' group.displayValueProp = (relatedTableMeta.columns?.find((c) => c.pv) || relatedTableMeta.columns?.[0])?.title || ''
} }
const response = !isPublic if (!options?.triggerChildOnly) {
? await api.dbViewRow.groupBy('noco', base.value.id, view.value.fk_model_id, view.value.id, { const response = !isPublic
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value), ? await api.dbViewRow.groupBy('noco', base.value.id, view.value.fk_model_id, view.value.id, {
limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: `${nestedWhere}`,
sort: `${getSortParams(groupby.sort)}${groupby.column.title}`,
column_name: groupby.column.title,
} as any)
: await api.public.dataGroupBy(
sharedView.value!.uuid!,
{
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value), offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
limit: group.paginationData.pageSize ?? groupByGroupLimit.value, limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params, ...params,
where: nestedWhere, ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: `${nestedWhere}`,
sort: `${getSortParams(groupby.sort)}${groupby.column.title}`, sort: `${getSortParams(groupby.sort)}${groupby.column.title}`,
column_name: groupby.column.title, column_name: groupby.column.title,
sortsArr: sorts.value, } as any)
filtersArr: nestedFilters.value, : await api.public.dataGroupBy(
}, sharedView.value!.uuid!,
{ {
headers: { offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
'xc-password': sharedViewPassword.value, limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params,
where: nestedWhere,
sort: `${getSortParams(groupby.sort)}${groupby.column.title}`,
column_name: groupby.column.title,
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
}, },
},
)
const tempList: Group[] = response.list.reduce((acc: Group[], curr: Record<string, any>) => {
const keyExists = acc.find(
(a) => a.key === valueToTitle(curr[groupby.column.column_name!] ?? curr[groupby.column.title!], groupby.column),
)
if (keyExists) {
keyExists.count += +curr.count
keyExists.paginationData = { page: 1, pageSize: groupByGroupLimit.value, totalRows: keyExists.count }
return acc
}
if (groupby.column.title && groupby.column.uidt) {
acc.push({
key: valueToTitle(curr[groupby.column.title!], groupby.column),
column: groupby.column,
count: +curr.count,
color: findKeyColor(curr[groupby.column.title!], groupby.column),
nestedIn: [
...group!.nestedIn,
{ {
title: groupby.column.title, headers: {
column_name: groupby.column.title!, 'xc-password': sharedViewPassword.value,
key: valueToTitle(curr[groupby.column.title!], groupby.column), },
column_uidt: groupby.column.uidt,
}, },
], )
aggregations: curr.aggregations ?? {},
paginationData: {
page: 1,
pageSize: group!.nestedIn.length < groupBy.value.length - 1 ? groupByGroupLimit.value : groupByRecordLimit.value,
totalRows: +curr.count,
},
nested: group!.nestedIn.length < groupBy.value.length - 1,
})
}
return acc
}, [])
if (!group.children) group.children = []
for (const temp of tempList) {
const keyExists = group.children?.find((a) => a.key === temp.key)
if (keyExists) {
temp.paginationData = {
page: keyExists.paginationData.page || temp.paginationData.page,
pageSize: keyExists.paginationData.pageSize || temp.paginationData.pageSize,
totalRows: temp.count,
}
temp.color = keyExists.color
// update group
Object.assign(keyExists, temp)
continue
}
group.children.push(temp)
}
group.children = group.children.filter((c) => tempList.find((t) => t.key === c.key))
if (group.count <= (group.paginationData.pageSize ?? groupByGroupLimit.value)) {
group.children.sort((a, b) => {
const orderA = tempList.findIndex((t) => t.key === a.key)
const orderB = tempList.findIndex((t) => t.key === b.key)
return orderA - orderB
})
}
group.paginationData = response.pageInfo
// to cater the case like when querying with a non-zero offset group = await processGroupData(response, group)
// the result page may point to the target page where the actual returned data don't display on
const expectedPage = Math.max(1, Math.ceil(group.paginationData.totalRows! / group.paginationData.pageSize!))
if (expectedPage < group.paginationData.page!) {
await groupWrapperChangePage(expectedPage, group)
} }
if (appInfo.value.ee) { if (appInfo.value.ee) {
const aggregationMap = new Map<string, string>() const aggregationMap = new Map<string, string>()
const aggregationParams = (group.children ?? []).map((child) => { const aggregationParams = (group.children ?? []).map((child) => {
try { let key = child.key
const key = JSON.parse(child.key)
if (!key?.length || key.startsWith(' ') || key.endsWith(' ')) {
key = Math.random().toString(36).substring(7)
aggregationMap.set(key, child.key)
}
try {
key = JSON.parse(key)
if (typeof key === 'object') { if (typeof key === 'object') {
const newKey = Math.random().toString(36).substring(7) key = Math.random().toString(36).substring(7)
aggregationMap.set(newKey, child.key) aggregationMap.set(key, child.key)
return { return {
where: calculateNestedWhere(child.nestedIn, where?.value), where: calculateNestedWhere(child.nestedIn, where?.value),
alias: newKey, alias: key,
} }
} }
} catch (e) {} } catch (e) {}
return { return {
where: calculateNestedWhere(child.nestedIn, where?.value), where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key, alias: key,
} }
}) })
const aggResponse = !isPublic const aggResponse = !isPublic
? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(meta.value!.id, { ? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(
viewId: view.value!.id, meta.value!.id,
aggregateFilterList: aggregationParams, {
}) viewId: view.value!.id,
: await fetchBulkAggregatedData({ },
aggregateFilterList: aggregationParams, aggregationParams,
}) )
: await fetchBulkAggregatedData({}, aggregationParams)
Object.entries(aggResponse).forEach(([key, value]) => { Object.entries(aggResponse).forEach(([key, value]) => {
const child = (group?.children ?? []).find((c) => c.key.toString() === key.toString()) const child = (group?.children ?? []).find((c) => c.key.toString() === key.toString())
@ -401,16 +435,134 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
Object.assign(child.aggregations, value) Object.assign(child.aggregations, value)
} else { } else {
const originalKey = aggregationMap.get(key) const originalKey = aggregationMap.get(key)
if (originalKey) { const child = (group?.children ?? []).find((c) => c.key.toString() === originalKey.toString())
if (child) {
Object.assign(child.aggregations, value)
}
}
})
}
if (group?.children && group.nestedIn.length === groupBy.value.length - 1) {
const aliasMap = new Map<string, string>()
const childViewFilters = group?.children?.map((childGroup) => {
let key = childGroup.key
if (!key?.length || key.startsWith(' ') || key.endsWith(' ')) {
key = Math.random().toString(36).substring(7)
aliasMap.set(key, childGroup.key)
}
try {
key = JSON.parse(key)
if (typeof key === 'object') {
key = Math.random().toString(36).substring(7)
aliasMap.set(key, childGroup.key)
}
} catch (e) {}
return {
alias: key,
where: calculateNestedWhere(childGroup.nestedIn, where?.value),
offset:
((childGroup.paginationData.page ?? 0) - 1) * (childGroup.paginationData.pageSize ?? groupByRecordLimit.value),
limit: childGroup.paginationData.pageSize ?? groupByRecordLimit.value,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
}
})
if (childViewFilters.length > 0) {
const bulkData = !isPublic
? await api.dbDataTableBulkList.dbDataTableBulkList(
meta.value.id,
{
viewId: view.value.id,
},
childViewFilters,
{},
)
: await fetchBulkListData({}, childViewFilters)
Object.entries(bulkData).forEach(([key, value]: { key: string; value: any }) => {
const child = (group?.children ?? []).find((c) => c.key.toString() === key.toString())
if (child) {
child.count = value.pageInfo.totalRows ?? 0
child.rows = formatData(value.list)
child.paginationData = value.pageInfo
} else {
const originalKey = aliasMap.get(key)
const child = (group?.children ?? []).find((c) => c.key.toString() === originalKey.toString()) const child = (group?.children ?? []).find((c) => c.key.toString() === originalKey.toString())
if (child) { if (child) {
Object.assign(child.aggregations, value) child.count = value.pageInfo.totalRows ?? 0
child.rows = formatData(value.list)
child.paginationData = value.pageInfo
} }
} }
})
}
}
if (group?.children && group.nestedIn.length < groupBy.value.length - 1) {
const aliasMap = new Map<string, string>()
const childGroupFilters = group?.children?.map((childGroup) => {
const childGroupBy = groupBy.value[childGroup.nestedIn.length]
const childNestedWhere = calculateNestedWhere(childGroup.nestedIn, where?.value)
let key = childGroup.key
if (!key?.length || key.startsWith(' ') || key.endsWith(' ')) {
key = Math.random().toString(36).substring(7)
aliasMap.set(key, childGroup.key)
}
try {
key = JSON.parse(key)
if (typeof key === 'object') {
key = Math.random().toString(36).substring(7)
aliasMap.set(key, childGroup.key)
}
} catch (e) {}
return {
alias: key,
offset:
((childGroup.paginationData.page ?? 0) - 1) * (childGroup.paginationData.pageSize ?? groupByGroupLimit.value),
limit: childGroup.paginationData.pageSize ?? groupByGroupLimit.value,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where: `${childNestedWhere}`,
sort: `${getSortParams(childGroupBy.sort)}${childGroupBy.column.title}`,
column_name: childGroupBy.column.title,
} }
}) })
if (childGroupFilters.length > 0) {
const bulkGroupData = !isPublic
? await api.dbDataTableBulkGroupList.dbDataTableBulkGroupList(
meta.value.id,
{
viewId: view.value.id,
},
childGroupFilters,
)
: await fetchBulkGroupData({}, childGroupFilters)
for (const [key, value] of Object.entries(bulkGroupData)) {
let child = (group?.children ?? []).find((c) => c.key.toString() === key.toString())
if (!child) {
const originalKey = aliasMap.get(key)
child = (group?.children ?? []).find((c) => c.key.toString() === originalKey.toString())!
}
Object.assign(child, await processGroupData(value, child))
}
}
} }
} catch (e) { } catch (e) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
@ -467,34 +619,39 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
const aggregationMap = new Map<string, string>() const aggregationMap = new Map<string, string>()
const aggregationParams = (group.children ?? []).map((child) => { const aggregationParams = (group.children ?? []).map((child) => {
let key = child.key
if (!key?.length || key.startsWith(' ') || key.endsWith(' ')) {
key = Math.random().toString(36).substring(7)
aggregationMap.set(key, child.key)
}
try { try {
const key = JSON.parse(child.key) key = JSON.parse(child.key)
if (typeof key === 'object') { if (typeof key === 'object') {
const newKey = Math.random().toString(36).substring(7) key = Math.random().toString(36).substring(7)
aggregationMap.set(newKey, child.key) aggregationMap.set(key, child.key)
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: newKey,
}
} }
} catch (e) {} } catch (e) {}
return { return {
where: calculateNestedWhere(child.nestedIn, where?.value), where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key, alias: key,
} }
}) })
const response = !isPublic const response = !isPublic
? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(meta.value!.id, { ? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(meta.value!.id, {
viewId: view.value!.id, viewId: view.value!.id,
aggregateFilterList: aggregationParams,
...(filteredFields ? { aggregation: filteredFields } : {}),
})
: await fetchBulkAggregatedData({
aggregateFilterList: aggregationParams,
...(filteredFields ? { aggregation: filteredFields } : {}), ...(filteredFields ? { aggregation: filteredFields } : {}),
aggregationParams,
}) })
: await fetchBulkAggregatedData(
{
...(filteredFields ? { aggregation: filteredFields } : {}),
},
aggregationParams,
)
Object.entries(response).forEach(([key, value]) => { Object.entries(response).forEach(([key, value]) => {
const child = (group.children ?? []).find((c) => c.key.toString() === key.toString()) const child = (group.children ?? []).find((c) => c.key.toString() === key.toString())

3
packages/nc-gui/store/views.ts

@ -120,7 +120,8 @@ export const useViewsStore = defineStore('viewsStore', () => {
const isActiveViewLocked = computed(() => activeView.value?.lock_type === 'locked') const isActiveViewLocked = computed(() => activeView.value?.lock_type === 'locked')
// Used for Grid View Pagination // Used for Grid View Pagination
const isPaginationLoading = ref(true) // TODO: Disable by default when group by is enabled
const isPaginationLoading = ref(false)
const preFillFormSearchParams = ref('') const preFillFormSearchParams = ref('')

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

@ -132,6 +132,38 @@ export class DataTableController {
}); });
} }
@Post(['/api/v2/tables/:modelId/bulk/group'])
@Acl('dataGroupBy')
async bulkGroupBy(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('modelId') modelId: string,
@Query('viewId') viewId: string,
) {
return await this.dataTableService.bulkGroupBy(context, {
query: req.query,
modelId,
viewId,
body: req.body,
});
}
@Post(['/api/v2/tables/:modelId/bulk/datalist'])
@Acl('dataList')
async bulkDataList(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('modelId') modelId: string,
@Query('viewId') viewId: string,
) {
return await this.dataTableService.bulkDataList(context, {
query: req.query,
modelId,
viewId,
body: req.body,
});
}
@Get(['/api/v2/tables/:modelId/records/:rowId']) @Get(['/api/v2/tables/:modelId/records/:rowId'])
@Acl('dataRead') @Acl('dataRead')
async dataRead( async dataRead(

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

@ -235,4 +235,36 @@ export class PublicDatasController {
urlOrPath, urlOrPath,
}); });
} }
@Post(['/api/v2/public/shared-view/:sharedViewUuid/bulk/dataList'])
async bulkDataList(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('sharedViewUuid') sharedViewUuid: string,
) {
const response = await this.publicDatasService.bulkDataList(context, {
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid,
body: req.body,
});
return response;
}
@Post(['/api/v2/public/shared-view/:sharedViewUuid/bulk/group'])
async bulkGroupBy(
@TenantContext() context: NcContext,
@Req() req: NcRequest,
@Param('sharedViewUuid') sharedViewUuid: string,
) {
const response = await this.publicDatasService.bulkGroupBy(context, {
query: req.query,
password: req.headers?.['xc-password'] as string,
sharedViewUuid,
body: req.body,
});
return response;
}
} }

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

@ -759,19 +759,703 @@ class BaseModelSqlv2 {
return await this.execAndParse(qb); return await this.execAndParse(qb);
} }
async bulkGroupByCount(
args: {
filterArr?: Filter[];
},
bulkFilterList: {
alias: string;
where?: string;
sort: string;
column_name: string;
filterArr?: Filter[];
}[],
view: View,
) {
try {
const columns = await this.model.getColumns(this.context);
const aliasColObjMap = await this.model.getAliasColObjMap(
this.context,
columns,
);
const selectors = [] as Array<Knex.Raw>;
const viewFilterList = await Filter.rootFilterList(this.context, {
viewId: this.viewId,
});
if (!bulkFilterList?.length) {
return NcError.badRequest('bulkFilterList is required');
}
for (const f of bulkFilterList) {
const { where, ...rest } = this._getListArgs(f);
const groupBySelectors = [];
const groupByColumns: Record<string, Column> = {};
const getAlias = getAliasGenerator('__nc_gb');
const groupFilter = extractFilterFromXwhere(f.where, aliasColObjMap);
const tQb = this.dbDriver(this.tnPath);
const colSelectors = [];
await Promise.all(
rest.column_name.split(',').map(async (col) => {
let column = columns.find(
(c) => c.column_name === col || c.title === col,
);
// if qrCode or Barcode replace it with value column nd keep the alias
if ([UITypes.QrCode, UITypes.Barcode].includes(column.uidt)) {
column = new Column({
...(await column
.getColOptions<BarcodeColumn | QrCodeColumn>(this.context)
.then((col) => col.getValueColumn(this.context))),
title: column.title,
id: column.id,
});
}
groupByColumns[column.id] = column;
switch (column.uidt) {
case UITypes.Attachment:
throw NcError.badRequest(
'Group by using attachment column is not supported',
);
case UITypes.Links:
case UITypes.Rollup:
colSelectors.push(
(
await genRollupSelectv2({
baseModelSqlv2: this,
knex: this.dbDriver,
columnOptions: (await column.getColOptions(
this.context,
)) as RollupColumn,
})
).builder.as(column.id),
);
groupBySelectors.push(column.id);
break;
case UITypes.Formula: {
let selectQb;
try {
const _selectQb = await this.getSelectQueryBuilderForFormula(
column,
);
selectQb = this.dbDriver.raw(`?? as ??`, [
_selectQb.builder,
column.id,
]);
} catch (e) {
console.log(e);
selectQb = this.dbDriver.raw(`'ERR' as ??`, [column.id]);
}
colSelectors.push(selectQb);
groupBySelectors.push(column.id);
break;
}
case UITypes.Lookup:
case UITypes.LinkToAnotherRecord: {
const _selectQb = await generateLookupSelectQuery({
baseModelSqlv2: this,
column,
alias: null,
model: this.model,
getAlias,
});
const selectQb = this.dbDriver.raw(`?? as ??`, [
this.dbDriver.raw(_selectQb.builder).wrap('(', ')'),
column.id,
]);
colSelectors.push(selectQb);
groupBySelectors.push(column.id);
break;
}
case UITypes.DateTime:
case UITypes.CreatedTime:
case UITypes.LastModifiedTime:
{
const columnName = await getColumnName(
this.context,
column,
columns,
);
// ignore seconds part in datetime and group
if (this.dbDriver.clientType() === 'pg') {
colSelectors.push(
this.dbDriver.raw(
"date_trunc('minute', ??) + interval '0 seconds' as ??",
[columnName, column.id],
),
);
} else if (
this.dbDriver.clientType() === 'mysql' ||
this.dbDriver.clientType() === 'mysql2'
) {
colSelectors.push(
// this.dbDriver.raw('??::date as ??', [columnName, column.id]),
this.dbDriver.raw(
"DATE_SUB(CONVERT_TZ(??, @@GLOBAL.time_zone, '+00:00'), INTERVAL SECOND(??) SECOND) as ??",
[columnName, columnName, column.id],
),
);
} else if (this.dbDriver.clientType() === 'sqlite3') {
colSelectors.push(
this.dbDriver.raw(
`strftime ('%Y-%m-%d %H:%M:00',:column:) ||
(
CASE WHEN substr(:column:, 20, 1) = '+' THEN
printf ('+%s:',
substr(:column:, 21, 2)) || printf ('%s',
substr(:column:, 24, 2))
WHEN substr(:column:, 20, 1) = '-' THEN
printf ('-%s:',
substr(:column:, 21, 2)) || printf ('%s',
substr(:column:, 24, 2))
ELSE
'+00:00'
END) AS :id:`,
{
column: columnName,
id: column.id,
},
),
);
} else {
colSelectors.push(
this.dbDriver.raw('DATE(??) as ??', [
columnName,
column.id,
]),
);
}
groupBySelectors.push(column.id);
}
break;
default: {
const columnName = await getColumnName(
this.context,
column,
columns,
);
colSelectors.push(
this.dbDriver.raw('?? as ??', [columnName, column.id]),
);
groupBySelectors.push(column.id);
break;
}
}
}),
);
// get aggregated count of each group
tQb.count(`${this.model.primaryKey?.column_name || '*'} as count`);
tQb.select(...colSelectors);
if (+rest?.shuffle) {
await this.shuffle({ qb: tQb });
}
await conditionV2(
this,
[
...(this.viewId
? [
new Filter({
children: viewFilterList || [],
is_group: true,
}),
]
: []),
new Filter({
children: rest.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: extractFilterFromXwhere(where, aliasColObjMap),
is_group: true,
logical_op: 'and',
}),
new Filter({
children: groupFilter,
is_group: true,
logical_op: 'and',
}),
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
],
tQb,
);
tQb.groupBy(...groupBySelectors);
const count = this.dbDriver
.count('*', { as: 'count' })
.from(tQb.as('groupby'));
let subQuery;
switch (this.dbDriver.client.config.client) {
case 'pg':
subQuery = this.dbDriver
.select(
this.dbDriver.raw(`json_build_object('count', "count") as ??`, [
getAlias(),
]),
)
.from(count.as(getAlias()));
selectors.push(
this.dbDriver.raw(`(??) as "??"`, [
subQuery,
this.dbDriver.raw(f.alias),
]),
);
break;
case 'mysql2':
subQuery = this.dbDriver
.select(this.dbDriver.raw(`JSON_OBJECT('count', \`count\`)`))
.from(count.as(getAlias()));
selectors.push(
this.dbDriver.raw(`(??) as ??`, [subQuery, `${f.alias}`]),
);
break;
case 'sqlite3':
subQuery = this.dbDriver
.select(
this.dbDriver.raw(`json_object('count', "count") as ??`, [
f.alias,
]),
)
.from(count.as(getAlias()));
selectors.push(
this.dbDriver.raw(`(??) as ??`, [subQuery, `${f.alias}`]),
);
break;
default:
NcError.notImplemented(
'This database does not support bulk groupBy count',
);
}
}
const qb = this.dbDriver(this.tnPath);
qb.select(...selectors).limit(1);
const data = await this.execAndParse(qb, null, {
raw: true,
first: true,
});
return data;
} catch (e) {
console.log(e);
}
}
async bulkGroupBy(
args: {
filterArr?: Filter[];
},
bulkFilterList: {
alias: string;
where?: string;
column_name: string;
limit?;
offset?;
sort?: string;
filterArr?: Filter[];
sortArr?: Sort[];
}[],
view: View,
) {
const columns = await this.model.getColumns(this.context);
const aliasColObjMap = await this.model.getAliasColObjMap(
this.context,
columns,
);
const selectors = [] as Array<Knex.Raw>;
const viewFilterList = await Filter.rootFilterList(this.context, {
viewId: this.viewId,
});
try {
if (!bulkFilterList?.length) {
return NcError.badRequest('bulkFilterList is required');
}
for (const f of bulkFilterList) {
const { where, ...rest } = this._getListArgs(f);
const groupBySelectors = [];
const groupByColumns: Record<string, Column> = {};
const getAlias = getAliasGenerator('__nc_gb');
const groupFilter = extractFilterFromXwhere(f?.where, aliasColObjMap);
let groupSort = extractSortsObject(rest?.sort, aliasColObjMap);
const tQb = this.dbDriver(this.tnPath);
const colSelectors = [];
const colIds = rest.column_name
.split(',')
.map((col) => {
const column = columns.find(
(c) => c.column_name === col || c.title === col,
);
if (!column) {
throw NcError.fieldNotFound(col);
}
return column?.id;
})
.join('_');
await Promise.all(
rest.column_name.split(',').map(async (col) => {
let column = columns.find(
(c) => c.column_name === col || c.title === col,
);
// if qrCode or Barcode replace it with value column nd keep the alias
if ([UITypes.QrCode, UITypes.Barcode].includes(column.uidt)) {
column = new Column({
...(await column
.getColOptions<BarcodeColumn | QrCodeColumn>(this.context)
.then((col) => col.getValueColumn(this.context))),
title: column.title,
id: column.id,
});
}
groupByColumns[column.id] = column;
switch (column.uidt) {
case UITypes.Attachment:
throw NcError.badRequest(
'Group by using attachment column is not supported',
);
case UITypes.Links:
case UITypes.Rollup:
colSelectors.push(
(
await genRollupSelectv2({
baseModelSqlv2: this,
knex: this.dbDriver,
columnOptions: (await column.getColOptions(
this.context,
)) as RollupColumn,
})
).builder.as(column.id),
);
groupBySelectors.push(column.id);
break;
case UITypes.Formula: {
let selectQb;
try {
const _selectQb = await this.getSelectQueryBuilderForFormula(
column,
);
selectQb = this.dbDriver.raw(`?? as ??`, [
_selectQb.builder,
column.id,
]);
} catch (e) {
console.log(e);
selectQb = this.dbDriver.raw(`'ERR' as ??`, [column.id]);
}
colSelectors.push(selectQb);
groupBySelectors.push(column.id);
break;
}
case UITypes.Lookup:
case UITypes.LinkToAnotherRecord: {
const _selectQb = await generateLookupSelectQuery({
baseModelSqlv2: this,
column,
alias: null,
model: this.model,
getAlias,
});
const selectQb = this.dbDriver.raw(`?? as ??`, [
this.dbDriver.raw(_selectQb.builder).wrap('(', ')'),
column.id,
]);
colSelectors.push(selectQb);
groupBySelectors.push(column.id);
break;
}
case UITypes.DateTime:
case UITypes.CreatedTime:
case UITypes.LastModifiedTime:
{
const columnName = await getColumnName(
this.context,
column,
columns,
);
// ignore seconds part in datetime and group
if (this.dbDriver.clientType() === 'pg') {
colSelectors.push(
this.dbDriver.raw(
"date_trunc('minute', ??) + interval '0 seconds' as ??",
[columnName, column.id],
),
);
} else if (
this.dbDriver.clientType() === 'mysql' ||
this.dbDriver.clientType() === 'mysql2'
) {
colSelectors.push(
// this.dbDriver.raw('??::date as ??', [columnName, column.id]),
this.dbDriver.raw(
"DATE_SUB(CONVERT_TZ(??, @@GLOBAL.time_zone, '+00:00'), INTERVAL SECOND(??) SECOND) as ??",
[columnName, columnName, column.id],
),
);
} else if (this.dbDriver.clientType() === 'sqlite3') {
colSelectors.push(
this.dbDriver.raw(
`strftime ('%Y-%m-%d %H:%M:00',:column:) ||
(
CASE WHEN substr(:column:, 20, 1) = '+' THEN
printf ('+%s:',
substr(:column:, 21, 2)) || printf ('%s',
substr(:column:, 24, 2))
WHEN substr(:column:, 20, 1) = '-' THEN
printf ('-%s:',
substr(:column:, 21, 2)) || printf ('%s',
substr(:column:, 24, 2))
ELSE
'+00:00'
END) AS :id:`,
{
column: columnName,
id: column.id,
},
),
);
} else {
colSelectors.push(
this.dbDriver.raw('DATE(??) as ??', [
columnName,
column.id,
]),
);
}
groupBySelectors.push(column.id);
}
break;
default: {
const columnName = await getColumnName(
this.context,
column,
columns,
);
colSelectors.push(
this.dbDriver.raw('?? as ??', [columnName, column.id]),
);
groupBySelectors.push(column.id);
break;
}
}
}),
);
// get aggregated count of each group
tQb.count(`${this.model.primaryKey?.column_name || '*'} as count`);
tQb.select(...colSelectors);
if (+rest?.shuffle) {
await this.shuffle({ qb: tQb });
}
await conditionV2(
this,
[
...(this.viewId
? [
new Filter({
children: viewFilterList || [],
is_group: true,
}),
]
: []),
new Filter({
children: rest.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: extractFilterFromXwhere(where, aliasColObjMap),
is_group: true,
logical_op: 'and',
}),
new Filter({
children: groupFilter,
is_group: true,
logical_op: 'and',
}),
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
],
tQb,
);
if (!groupSort) {
if (rest.sortArr?.length) {
groupSort = rest.sortArr;
} else if (this.viewId) {
groupSort = await Sort.list(this.context, { viewId: this.viewId });
}
}
for (const sort of groupSort || []) {
if (!groupByColumns[sort.fk_column_id]) {
continue;
}
const column = groupByColumns[sort.fk_column_id];
if (
[UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(
column.uidt as UITypes,
)
) {
const columnName = await getColumnName(
this.context,
column,
columns,
);
const baseUsers = await BaseUser.getUsersList(this.context, {
base_id: column.base_id,
});
// create nested replace statement for each user
const finalStatement = baseUsers.reduce((acc, user) => {
const qb = this.dbDriver.raw(`REPLACE(${acc}, ?, ?)`, [
user.id,
user.display_name || user.email,
]);
return qb.toQuery();
}, this.dbDriver.raw(`??`, [columnName]).toQuery());
if (!['asc', 'desc'].includes(sort.direction)) {
tQb.orderBy(
'count',
sort.direction === 'count-desc' ? 'desc' : 'asc',
sort.direction === 'count-desc' ? 'LAST' : 'FIRST',
);
} else {
tQb.orderBy(
sanitize(this.dbDriver.raw(finalStatement)),
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
}
} else {
if (!['asc', 'desc'].includes(sort.direction)) {
tQb.orderBy(
'count',
sort.direction === 'count-desc' ? 'desc' : 'asc',
sort.direction === 'count-desc' ? 'LAST' : 'FIRST',
);
} else {
tQb.orderBy(
column.id,
sort.direction,
sort.direction === 'desc' ? 'LAST' : 'FIRST',
);
}
}
tQb.groupBy(...groupBySelectors);
applyPaginate(tQb, rest);
}
let subQuery;
switch (this.dbDriver.client.config.client) {
case 'pg':
subQuery = this.dbDriver
.select(
this.dbDriver.raw(
`json_agg(json_build_object('count', "count", '${rest.column_name}', "${colIds}")) as ??`,
[getAlias()],
),
)
.from(tQb.as(getAlias()));
selectors.push(
this.dbDriver.raw(`(??) as "??"`, [
subQuery,
this.dbDriver.raw(f.alias),
]),
);
break;
case 'mysql2':
subQuery = this.dbDriver
.select(
this.dbDriver.raw(
`JSON_ARRAYAGG(JSON_OBJECT('count', \`count\`, '${rest.column_name}', \`${colIds}\`))`,
),
)
.from(this.dbDriver.raw(`(??) as ??`, [tQb, getAlias()]));
selectors.push(
this.dbDriver.raw(`(??) as ??`, [subQuery, f.alias]),
);
break;
case 'sqlite3':
subQuery = this.dbDriver
.select(
this.dbDriver.raw(
`json_group_array(json_object('count', "count", '${rest.column_name}', "${colIds}")) as ??`,
[f.alias],
),
)
.from(tQb.as(getAlias()));
selectors.push(
this.dbDriver.raw(`(??) as ??`, [subQuery, f.alias]),
);
break;
default:
NcError.notImplemented(
'This database does not support bulk groupBy',
);
}
}
const qb = this.dbDriver(this.tnPath);
qb.select(...selectors).limit(1);
const data = await this.execAndParse(qb, null, {
raw: true,
first: true,
});
return data;
} catch (err) {
logger.log(err);
return [];
}
}
async bulkAggregate( async bulkAggregate(
args: { args: {
aggregateFilterList: Array<{
alias: string;
where?: string;
}>;
filterArr?: Filter[]; filterArr?: Filter[];
}, },
bulkFilterList: Array<{
alias: string;
where?: string;
}>,
view: View, view: View,
) { ) {
try { try {
if (!args.aggregateFilterList?.length) { if (!bulkFilterList?.length) {
return NcError.badRequest('aggregateFilterList is required'); return NcError.badRequest('bulkFilterList is required');
} }
const { where, aggregation } = this._getListArgs(args as any); const { where, aggregation } = this._getListArgs(args as any);
@ -841,8 +1525,8 @@ class BaseModelSqlv2 {
}); });
const selectors = [] as Array<Knex.Raw>; const selectors = [] as Array<Knex.Raw>;
// Generate a knex raw query for each filter in the aggregateFilterList // Generate a knex raw query for each filter in the bulkFilterList
for (const f of args.aggregateFilterList) { for (const f of bulkFilterList) {
const tQb = this.dbDriver(this.tnPath); const tQb = this.dbDriver(this.tnPath);
const aggFilter = extractFilterFromXwhere(f.where, aliasColObjMap); const aggFilter = extractFilterFromXwhere(f.where, aliasColObjMap);
@ -3331,6 +4015,7 @@ class BaseModelSqlv2 {
obj.sort = args.sort || args.s; obj.sort = args.sort || args.s;
obj.pks = args.pks; obj.pks = args.pks;
obj.aggregation = args.aggregation || []; obj.aggregation = args.aggregation || [];
obj.column_name = args.column_name;
return obj; return obj;
} }

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

@ -1521,6 +1521,7 @@ export interface XcFilter {
sortArr?: Sort[]; sortArr?: Sort[];
pks?: string; pks?: string;
aggregation?: XcAggregation[]; aggregation?: XcAggregation[];
column_name?: string;
} }
export interface XcFilterWithAlias extends XcFilter { export interface XcFilterWithAlias extends XcFilter {

2
packages/nocodb/src/helpers/extractLimitAndOffset.ts

@ -5,7 +5,7 @@ export const defaultLimitConfig = {
}; };
export const defaultGroupByLimitConfig = { export const defaultGroupByLimitConfig = {
limitGroup: Math.max(+process.env.DB_QUERY_LIMIT_GROUP_BY_GROUP || 10, 1), limitGroup: Math.max(+process.env.DB_QUERY_LIMIT_GROUP_BY_GROUP || 25, 1),
limitRecord: Math.max(+process.env.DB_QUERY_LIMIT_GROUP_BY_RECORD || 10, 1), limitRecord: Math.max(+process.env.DB_QUERY_LIMIT_GROUP_BY_RECORD || 10, 1),
}; };

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

@ -11360,6 +11360,133 @@
] ]
} }
}, },
"/api/v2/tables/{tableId}/bulk/dataList": {
"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"
}
],
"post": {
"summary": "Read Bulk Data",
"operationId": "db-data-table-bulk-list",
"description": "Read bulk data from a given table with given filters",
"tags": [
"DB Data Table Bulk List"
],
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/tables/{tableId}/bulk/group": {
"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"
}
],
"post": {
"summary": "Read Bulk Group Data",
"operationId": "db-data-table-bulk-group-list",
"description": "Read bulk group data from a given table with given filters",
"tags": [
"DB Data Table Bulk Group List"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
},
"/api/v1/db/data/bulk/{orgs}/{baseName}/{tableName}": { "/api/v1/db/data/bulk/{orgs}/{baseName}/{tableName}": {
"parameters": [ "parameters": [
{ {
@ -12389,6 +12516,172 @@
"description": "Get the table rows but exculding the current record's children and parent" "description": "Get the table rows but exculding the current record's children and parent"
} }
}, },
"/api/v2/public/shared-view/{sharedViewUuid}/bulk/dataList": {
"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"
}
],
"post": {
"summary": "Read Shared View Bulk Data List",
"operationId": "public-data-table-bulk-data-list",
"description": "Read bulk data from a given table with provided filters",
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"alias": {
"Id": 1,
"Title": "90",
"SingleSelect": "50"
},
"alias2": {
"Id": 2,
"Title": "50",
"SingleSelect": "30"
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/bulk/group": {
"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"
}
],
"post": {
"summary": "Read Shared View Bulk Group Data",
"operationId": "public-data-table-bulk-group",
"description": "Read bulk group data from a given table with provided filters",
"tags": [
"Public"
],
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "where",
"description": "Extra filtering"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object"
},
"examples": {
"Example 1": {
"value": {
"alias": {
"Id": 1,
"Title": "90",
"SingleSelect": "50"
},
"alias2": {
"Id": 2,
"Title": "50",
"SingleSelect": "30"
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
}
}
},
"/api/v2/public/shared-view/{sharedViewUuid}/aggregate": { "/api/v2/public/shared-view/{sharedViewUuid}/aggregate": {
"parameters": [ "parameters": [
{ {

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

@ -755,4 +755,108 @@ export class DataTableService {
}, {} as Record<string, any>), }, {} as Record<string, any>),
); );
} }
async bulkDataList(
context: NcContext,
param: {
baseId?: string;
modelId: string;
viewId?: string;
query: any;
body: any;
},
) {
const { model, view } = await this.getModelAndView(context, param);
let bulkFilterList = param.body;
try {
bulkFilterList = JSON.parse(bulkFilterList);
} catch (e) {}
if (!bulkFilterList?.length) {
NcError.badRequest('Invalid bulkFilterList');
}
const dataListResults = await bulkFilterList.reduce(
async (accPromise, dF: any) => {
const acc = await accPromise;
const result = await this.datasService.dataList(context, {
query: {
...dF,
},
model,
view,
});
acc[dF.alias] = result;
return acc;
},
Promise.resolve({}),
);
return dataListResults;
}
async bulkGroupBy(
context: NcContext,
param: {
baseId?: string;
modelId: string;
viewId?: string;
query: any;
body: 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),
});
let bulkFilterList = param.body;
const listArgs: any = { ...param.query };
try {
bulkFilterList = JSON.parse(bulkFilterList);
} catch (e) {}
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJSON);
} catch (e) {}
if (!bulkFilterList?.length) {
NcError.badRequest('Invalid bulkFilterList');
}
const [data, count] = await Promise.all([
baseModel.bulkGroupBy(listArgs, bulkFilterList, view),
baseModel.bulkGroupByCount(listArgs, bulkFilterList, view),
]);
bulkFilterList.forEach((dF: any) => {
// sqlite3 returns data as string. Hence needs to be converted to json object
let parsedData = data[dF.alias];
if (typeof parsedData === 'string') {
parsedData = JSON.parse(parsedData);
}
let parsedCount = count[dF.alias];
if (typeof parsedCount === 'string') {
parsedCount = JSON.parse(parsedCount);
}
data[dF.alias] = new PagedResponseImpl(parsedData, {
...dF,
count: parsedCount?.count,
});
});
return data;
}
} }

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

@ -18,6 +18,7 @@ import { mimeIcons } from '~/utils/mimeTypes';
import { utf8ify } from '~/helpers/stringHelpers'; import { utf8ify } from '~/helpers/stringHelpers';
import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2'; import { replaceDynamicFieldWithValue } from '~/db/BaseModelSqlv2';
import { Filter } from '~/models'; import { Filter } from '~/models';
import { DatasService } from '~/services/datas.service';
// todo: move to utils // todo: move to utils
export function sanitizeUrlPath(paths) { export function sanitizeUrlPath(paths) {
@ -26,6 +27,7 @@ export function sanitizeUrlPath(paths) {
@Injectable() @Injectable()
export class PublicDatasService { export class PublicDatasService {
constructor(protected datasService: DatasService) {}
async dataList( async dataList(
context: NcContext, context: NcContext,
param: { param: {
@ -785,4 +787,141 @@ export class PublicDatasService {
return row; return row;
} }
async bulkDataList(
context: NcContext,
param: {
sharedViewUuid: string;
password?: string;
query: any;
body?: 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 listArgs: any = { ...param.query };
let bulkFilterList = param.body;
try {
bulkFilterList = JSON.parse(bulkFilterList);
} catch (e) {}
try {
listArgs.sortArr = JSON.parse(listArgs.sortArrJson);
} catch (e) {}
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
if (!bulkFilterList?.length) {
NcError.badRequest('Invalid bulkFilterList');
}
const dataListResults = await bulkFilterList.reduce(
async (accPromise, dF: any) => {
const acc = await accPromise;
const result = await this.datasService.dataList(context, {
query: {
...dF,
},
model,
view,
});
acc[dF.alias] = result;
return acc;
},
Promise.resolve({}),
);
return dataListResults;
}
async bulkGroupBy(
context: NcContext,
param: {
sharedViewUuid: string;
password?: string;
query: any;
body: any;
},
) {
const view = await View.getByUUID(context, param.sharedViewUuid);
if (!view) NcError.viewNotFound(param.sharedViewUuid);
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 };
let bulkFilterList = param.body;
try {
bulkFilterList = JSON.parse(bulkFilterList);
} catch (e) {}
try {
listArgs.filterArr = JSON.parse(listArgs.filterArrJson);
} catch (e) {}
if (!bulkFilterList?.length) {
NcError.badRequest('Invalid bulkFilterList');
}
const [data, count] = await Promise.all([
baseModel.bulkGroupBy(listArgs, bulkFilterList, view),
baseModel.bulkGroupByCount(listArgs, bulkFilterList, view),
]);
bulkFilterList.forEach((dF: any) => {
// sqlite3 returns data as string. Hence needs to be converted to json object
let parsedData = data[dF.alias];
if (typeof parsedData === 'string') {
parsedData = JSON.parse(parsedData);
}
let parsedCount = count[dF.alias];
if (typeof parsedCount === 'string') {
parsedCount = JSON.parse(parsedCount);
}
data[dF.alias] = new PagedResponseImpl(parsedData, {
...dF,
count: parsedCount?.count,
});
});
return data;
}
} }

6
tests/playwright/pages/Dashboard/Grid/Group.ts

@ -137,7 +137,7 @@ export class GroupPageObject extends BasePage {
await this._fillRow({ indexMap, index, columnHeader, value: rowValue }); await this._fillRow({ indexMap, index, columnHeader, value: rowValue });
await this.dashboard.waitForLoaderToDisappear(); // await this.dashboard.waitForLoaderToDisappear();
} }
async deleteRow({ title, indexMap, rowIndex = 0 }: { title: string; indexMap: number[]; rowIndex?: number }) { async deleteRow({ title, indexMap, rowIndex = 0 }: { title: string; indexMap: number[]; rowIndex?: number }) {
@ -153,7 +153,7 @@ export class GroupPageObject extends BasePage {
.waitFor({ state: 'hidden' }); .waitFor({ state: 'hidden' });
await this.rootPage.waitForTimeout(300); await this.rootPage.waitForTimeout(300);
await this.dashboard.waitForLoaderToDisappear(); // await this.dashboard.waitForLoaderToDisappear();
} }
async editRow({ async editRow({
@ -169,7 +169,7 @@ export class GroupPageObject extends BasePage {
}) { }) {
await this._fillRow({ indexMap, index: rowIndex, columnHeader, value }); await this._fillRow({ indexMap, index: rowIndex, columnHeader, value });
await this.dashboard.waitForLoaderToDisappear(); // await this.dashboard.waitForLoaderToDisappear();
} }
private async _fillRow({ private async _fillRow({

4
tests/playwright/pages/Dashboard/common/Toolbar/Groupby.ts

@ -62,7 +62,7 @@ export class ToolbarGroupByPage extends BasePage {
.nth(ascending ? 0 : 1) .nth(ascending ? 0 : 1)
.click(); .click();
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); // await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close group-by menu // close group-by menu
await this.toolbar.clickGroupBy(); await this.toolbar.clickGroupBy();
await this.toolbar.parent.waitLoading(); await this.toolbar.parent.waitLoading();
@ -133,7 +133,7 @@ export class ToolbarGroupByPage extends BasePage {
}); });
} }
await this.toolbar.parent.dashboard.waitForLoaderToDisappear(); // await this.toolbar.parent.dashboard.waitForLoaderToDisappear();
// close group-by menu // close group-by menu
await this.toolbar.clickGroupBy(); await this.toolbar.clickGroupBy();

Loading…
Cancel
Save