diff --git a/packages/nc-gui/components/smartsheet/grid/GroupBy.vue b/packages/nc-gui/components/smartsheet/grid/GroupBy.vue index 67919f0934..a0ae323584 100644 --- a/packages/nc-gui/components/smartsheet/grid/GroupBy.vue +++ b/packages/nc-gui/components/smartsheet/grid/GroupBy.vue @@ -1,8 +1,10 @@ +
+ +
{ 'font-weight': 500, }" > - {{ grp.key in GROUP_BY_VARS.VAR_TITLES ? GROUP_BY_VARS.VAR_TITLES[grp.key] : grp.key }} + + diff --git a/packages/nc-gui/components/smartsheet/grid/GroupByLabel.vue b/packages/nc-gui/components/smartsheet/grid/GroupByLabel.vue new file mode 100644 index 0000000000..2e9638e344 --- /dev/null +++ b/packages/nc-gui/components/smartsheet/grid/GroupByLabel.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue b/packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue index 3febd97e8e..94b17c9702 100644 --- a/packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue +++ b/packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue @@ -15,20 +15,10 @@ import { useNuxtApp, useSmartsheetStoreOrThrow, useViewColumnsOrThrow, + watch, } from '#imports' -const groupingUidt = [ - UITypes.SingleSelect, - UITypes.MultiSelect, - UITypes.Checkbox, - UITypes.Date, - UITypes.SingleLineText, - UITypes.Number, - UITypes.Rollup, - UITypes.Lookup, - UITypes.Links, - UITypes.Formula, -] +const excludedGroupingUidt = [UITypes.Attachment] const meta = inject(MetaInj, ref()) const view = inject(ActiveViewInj, ref()) @@ -62,16 +52,16 @@ const groupedByColumnIds = computed(() => groupBy.value.map((g) => g.fk_column_i const { eventBus } = useSmartsheetStoreOrThrow() const { isMobileMode } = useGlobal() -const btLookups = ref([]) +const supportedLookups = ref([]) const fieldsToGroupBy = computed(() => { const fields = meta.value?.columns || [] return fields.filter((field) => { - if (!groupingUidt.includes(field.uidt as UITypes)) return false + if (excludedGroupingUidt.includes(field.uidt as UITypes)) return false if (field.uidt === UITypes.Lookup) { - return btLookups.value.includes(field.id) + return supportedLookups.value.includes(field.id) } return true @@ -161,25 +151,18 @@ watch(open, () => { } }) -const loadBtLookups = async () => { +const loadAllowedLookups = async () => { const filteredLookupCols = [] try { for (const col of meta.value?.columns || []) { if (col.uidt !== UITypes.Lookup) continue let nextCol = col - let btLookup = true - - // check all the relation of nested lookup columns is bt or not - // include the column only if all only if all relations are bt - while (btLookup && nextCol && nextCol.uidt === UITypes.Lookup) { + // check the lookup column is supported type or not + while (nextCol && nextCol.uidt === UITypes.Lookup) { const lookupRelation = (await getMeta(nextCol.fk_model_id))?.columns?.find( (c) => c.id === (nextCol.colOptions as LookupType).fk_relation_column_id, ) - if ((lookupRelation.colOptions as LinkToAnotherRecordType).type !== RelationTypes.BELONGS_TO) { - btLookup = false - continue - } const relatedTableMeta = await getMeta((lookupRelation.colOptions as LinkToAnotherRecordType).fk_related_model_id) @@ -190,22 +173,25 @@ const loadBtLookups = async () => { // if next column is same as root lookup column then break the loop // since it's going to be a circular loop, and ignore the column if (nextCol.id === col.id) { - btLookup = false break } } - if (btLookup) filteredLookupCols.push(col.id) + if (nextCol.uidt !== UITypes.Attachment) filteredLookupCols.push(col.id) } - btLookups.value = filteredLookupCols + supportedLookups.value = filteredLookupCols } catch (e) { console.error(e) } } onMounted(async () => { - await loadBtLookups() + await loadAllowedLookups() +}) + +watch(meta, async () => { + await loadAllowedLookups() }) @@ -242,9 +228,7 @@ onMounted(async () => { , where?: Computed if (col.uidt === UITypes.Checkbox) { return value ? GROUP_BY_VARS.TRUE : GROUP_BY_VARS.FALSE } + + // convert to JSON string if non-string value + if (value && typeof value === 'object') { + value = JSON.stringify(value) + } + return value ?? GROUP_BY_VARS.NULL } @@ -144,13 +150,13 @@ export const useViewGroupBy = (view: Ref, where?: Computed const calculateNestedWhere = (nestedIn: GroupNestedIn[], existing = '') => { return nestedIn.reduce((acc, curr) => { if (curr.key === GROUP_BY_VARS.NULL) { - acc += `${acc.length ? '~and' : ''}(${curr.title},blank)` + acc += `${acc.length ? '~and' : ''}(${curr.title},gb_null)` } else if (curr.column_uidt === UITypes.Checkbox) { acc += `${acc.length ? '~and' : ''}(${curr.title},${curr.key === GROUP_BY_VARS.TRUE ? 'checked' : 'notchecked'})` } else if ([UITypes.Date, UITypes.DateTime].includes(curr.column_uidt as UITypes)) { acc += `${acc.length ? '~and' : ''}(${curr.title},eq,exactDate,${curr.key})` } else { - acc += `${acc.length ? '~and' : ''}(${curr.title},eq,${curr.key})` + acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,${curr.key})` } return acc }, existing) diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index 9302691854..35571e1cfa 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -35,7 +35,6 @@ import type { SelectOption, } from '~/models'; import type { SortType } from 'nocodb-sdk'; -import generateBTLookupSelectQuery from '~/db/generateBTLookupSelectQuery'; import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2'; import genRollupSelectv2 from '~/db/genRollupSelectv2'; import conditionV2 from '~/db/conditionV2'; @@ -60,10 +59,13 @@ import { HANDLE_WEBHOOK } from '~/services/hook-handler.service'; import { COMPARISON_OPS, COMPARISON_SUB_OPS, + GROUPBY_COMPARISON_OPS, IS_WITHIN_COMPARISON_SUB_OPS, } from '~/utils/globals'; import { extractProps } from '~/helpers/extractProps'; import { defaultLimitConfig } from '~/helpers/extractLimitAndOffset'; +import generateLookupSelectQuery from '~/db/generateLookupSelectQuery'; +import { getAliasGenerator } from '~/utils'; dayjs.extend(utc); @@ -386,6 +388,7 @@ class BaseModelSqlv2 { validateFormula: true, }); } + return data?.map((d) => { d.__proto__ = proto; return d; @@ -549,18 +552,32 @@ class BaseModelSqlv2 { const selectors = []; const groupBySelectors = []; + const getAlias = getAliasGenerator('__nc_gb'); await Promise.all( args.column_name.split(',').map(async (col) => { - const column = cols.find( - (c) => c.column_name === col || c.title === col, - ); - groupByColumns[column.id] = column; + let column = cols.find((c) => c.column_name === col || c.title === col); if (!column) { throw NcError.notFound('Column not found'); } + // 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() + .then((col) => col.getValueColumn())), + title: column.title, + }); + + groupByColumns[column.id] = column; + switch (column.uidt) { + case UITypes.Attachment: + NcError.badRequest( + 'Group by using attachment column is not supported', + ); + break; case UITypes.Links: case UITypes.Rollup: selectors.push( @@ -599,12 +616,14 @@ class BaseModelSqlv2 { } break; case UITypes.Lookup: + case UITypes.LinkToAnotherRecord: { - const _selectQb = await generateBTLookupSelectQuery({ + const _selectQb = await generateLookupSelectQuery({ baseModelSqlv2: this, column, alias: null, model: this.model, + getAlias, }); const selectQb = this.dbDriver.raw(`?? as ??`, [ @@ -695,6 +714,7 @@ class BaseModelSqlv2 { qb.groupBy(...groupBySelectors); applyPaginate(qb, rest); + return await this.execAndParse(qb); } @@ -711,18 +731,34 @@ class BaseModelSqlv2 { const selectors = []; const groupBySelectors = []; + const getAlias = getAliasGenerator('__nc_gb'); + // todo: refactor and avoid duplicate code await this.model.getColumns().then((cols) => Promise.all( args.column_name.split(',').map(async (col) => { - const column = cols.find( + let column = cols.find( (c) => c.column_name === col || c.title === col, ); if (!column) { throw NcError.notFound('Column not found'); } + // 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() + .then((col) => col.getValueColumn())), + title: column.title, + }); + switch (column.uidt) { + case UITypes.Attachment: + NcError.badRequest( + 'Group by using attachment column is not supported', + ); + break; case UITypes.Rollup: case UITypes.Links: selectors.push( @@ -764,12 +800,14 @@ class BaseModelSqlv2 { break; } case UITypes.Lookup: + case UITypes.LinkToAnotherRecord: { - const _selectQb = await generateBTLookupSelectQuery({ + const _selectQb = await generateLookupSelectQuery({ baseModelSqlv2: this, column, alias: null, model: this.model, + getAlias, }); const selectQb = this.dbDriver.raw(`?? as ??`, [ @@ -5247,7 +5285,7 @@ export function extractFilterFromXwhere( // mark `op` and `sub_op` any for being assignable to parameter of type function validateFilterComparison(uidt: UITypes, op: any, sub_op?: any) { - if (!COMPARISON_OPS.includes(op)) { + if (!COMPARISON_OPS.includes(op) && !GROUPBY_COMPARISON_OPS.includes(op)) { NcError.badRequest(`${op} is not supported.`); } diff --git a/packages/nocodb/src/db/conditionV2.ts b/packages/nocodb/src/db/conditionV2.ts index 4495be7cef..2cee6d72c6 100644 --- a/packages/nocodb/src/db/conditionV2.ts +++ b/packages/nocodb/src/db/conditionV2.ts @@ -8,11 +8,14 @@ import type Column from '~/models/Column'; import type LookupColumn from '~/models/LookupColumn'; import type RollupColumn from '~/models/RollupColumn'; import type FormulaColumn from '~/models/FormulaColumn'; +import type { BarcodeColumn, QrCodeColumn } from '~/models'; import { NcError } from '~/helpers/catchError'; import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2'; import genRollupSelectv2 from '~/db/genRollupSelectv2'; import { sanitize } from '~/helpers/sqlSanitize'; import Filter from '~/models/Filter'; +import generateLookupSelectQuery from '~/db/generateLookupSelectQuery'; +import { getAliasGenerator } from '~/utils'; // tod: tobe fixed // extend(customParseFormat); @@ -112,6 +115,43 @@ const parseConditionV2 = async ( }); }; } else { + // handle group by filter separately, + // `gb_eq` is equivalent to `eq` but for lookup it compares on aggregated value returns in group by api + // aggregated value will be either json array or `___` separated string + // `gb_null` is equivalent to `blank` but for lookup it compares on aggregated value is null + if ( + (filter.comparison_op as any) === 'gb_eq' || + (filter.comparison_op as any) === 'gb_null' + ) { + const column = await filter.getColumn(); + if ( + column.uidt === UITypes.Lookup || + column.uidt === UITypes.LinkToAnotherRecord + ) { + const model = await column.getModel(); + const lkQb = await generateLookupSelectQuery({ + baseModelSqlv2, + alias: alias, + model, + column, + getAlias: getAliasGenerator('__gb_filter_lk'), + }); + return (qb) => { + if ((filter.comparison_op as any) === 'gb_eq') + qb.where(knex.raw('?', [filter.value]), lkQb.builder); + else qb.whereNull(knex.raw(lkQb.builder).wrap('(', ')')); + }; + } else { + filter.comparison_op = + (filter.comparison_op as any) === 'gb_eq' ? 'eq' : 'blank'; + // if qrCode or Barcode replace it with value column + if ([UITypes.QrCode, UITypes.Barcode].includes(column.uidt)) + filter.fk_column_id = await column + .getColOptions() + .then((col) => col.fk_column_id); + } + } + const column = await filter.getColumn(); if (!column) { if (throwErrorIfInvalid) { diff --git a/packages/nocodb/src/db/generateBTLookupSelectQuery.ts b/packages/nocodb/src/db/generateBTLookupSelectQuery.ts deleted file mode 100644 index 62835a1a44..0000000000 --- a/packages/nocodb/src/db/generateBTLookupSelectQuery.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { RelationTypes, UITypes } from 'nocodb-sdk'; -import type LookupColumn from '../models/LookupColumn'; -import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; -import type { - Column, - FormulaColumn, - LinkToAnotherRecordColumn, - Model, - RollupColumn, -} from '~/models'; -import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2'; -import genRollupSelectv2 from '~/db/genRollupSelectv2'; -import { NcError } from '~/helpers/catchError'; - -export default async function generateBTLookupSelectQuery({ - column, - baseModelSqlv2, - alias, - model, -}: { - column: Column; - baseModelSqlv2: BaseModelSqlv2; - alias: string; - model: Model; -}): Promise { - const knex = baseModelSqlv2.dbDriver; - - const rootAlias = alias; - - { - let aliasCount = 0, - selectQb; - const alias = `__nc_lk_${aliasCount++}`; - const lookup = await column.getColOptions(); - { - const relationCol = await lookup.getRelationColumn(); - const relation = - await relationCol.getColOptions(); - - // if not belongs to then throw error as we don't support - if (relation.type !== RelationTypes.BELONGS_TO) - NcError.badRequest('HasMany/ManyToMany lookup is not supported'); - - const childColumn = await relation.getChildColumn(); - const parentColumn = await relation.getParentColumn(); - const childModel = await childColumn.getModel(); - await childModel.getColumns(); - const parentModel = await parentColumn.getModel(); - await parentModel.getColumns(); - - selectQb = knex( - `${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${alias}`, - ).where( - `${alias}.${parentColumn.column_name}`, - knex.raw(`??`, [ - `${rootAlias || baseModelSqlv2.getTnPath(childModel.table_name)}.${ - childColumn.column_name - }`, - ]), - ); - } - let lookupColumn = await lookup.getLookupColumn(); - let prevAlias = alias; - while (lookupColumn.uidt === UITypes.Lookup) { - const nestedAlias = `__nc_lk_nested_${aliasCount++}`; - const nestedLookup = await lookupColumn.getColOptions(); - const relationCol = await nestedLookup.getRelationColumn(); - const relation = - await relationCol.getColOptions(); - - // if any of the relation in nested lookup is - // not belongs to then throw error as we don't support - if (relation.type !== RelationTypes.BELONGS_TO) - NcError.badRequest('HasMany/ManyToMany lookup is not supported'); - - const childColumn = await relation.getChildColumn(); - const parentColumn = await relation.getParentColumn(); - const childModel = await childColumn.getModel(); - await childModel.getColumns(); - const parentModel = await parentColumn.getModel(); - await parentModel.getColumns(); - - selectQb.join( - `${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${nestedAlias}`, - `${nestedAlias}.${parentColumn.column_name}`, - `${prevAlias}.${childColumn.column_name}`, - ); - - lookupColumn = await nestedLookup.getLookupColumn(); - prevAlias = nestedAlias; - } - - switch (lookupColumn.uidt) { - case UITypes.Links: - case UITypes.Rollup: - { - const builder = ( - await genRollupSelectv2({ - baseModelSqlv2, - knex, - columnOptions: - (await lookupColumn.getColOptions()) as RollupColumn, - alias: prevAlias, - }) - ).builder; - selectQb.select(builder); - } - break; - case UITypes.LinkToAnotherRecord: - { - const nestedAlias = `__nc_sort${aliasCount++}`; - const relation = - await lookupColumn.getColOptions(); - if (relation.type !== 'bt') return; - - const colOptions = - (await column.getColOptions()) as LinkToAnotherRecordColumn; - const childColumn = await colOptions.getChildColumn(); - const parentColumn = await colOptions.getParentColumn(); - const childModel = await childColumn.getModel(); - await childModel.getColumns(); - const parentModel = await parentColumn.getModel(); - await parentModel.getColumns(); - - selectQb - .join( - `${baseModelSqlv2.getTnPath( - parentModel.table_name, - )} as ${nestedAlias}`, - `${nestedAlias}.${parentColumn.column_name}`, - `${prevAlias}.${childColumn.column_name}`, - ) - .select(parentModel?.displayValue?.column_name); - } - break; - case UITypes.Formula: - { - const builder = ( - await formulaQueryBuilderv2( - baseModelSqlv2, - ( - await column.getColOptions() - ).formula, - null, - model, - column, - ) - ).builder; - - selectQb.select(builder); - } - break; - default: - { - selectQb.select(`${prevAlias}.${lookupColumn.column_name}`); - } - - break; - } - - return { builder: selectQb }; - } -} diff --git a/packages/nocodb/src/db/generateLookupSelectQuery.ts b/packages/nocodb/src/db/generateLookupSelectQuery.ts new file mode 100644 index 0000000000..09187d1fdc --- /dev/null +++ b/packages/nocodb/src/db/generateLookupSelectQuery.ts @@ -0,0 +1,399 @@ +import { RelationTypes, UITypes } from 'nocodb-sdk'; +import type LookupColumn from '../models/LookupColumn'; +import type { BaseModelSqlv2 } from '~/db/BaseModelSqlv2'; +import type { + BarcodeColumn, + Column, + FormulaColumn, + LinksColumn, + LinkToAnotherRecordColumn, + QrCodeColumn, + RollupColumn, +} from '~/models'; +import { Model } from '~/models'; +import formulaQueryBuilderv2 from '~/db/formulav2/formulaQueryBuilderv2'; +import genRollupSelectv2 from '~/db/genRollupSelectv2'; +import { getAliasGenerator } from '~/utils'; +import { NcError } from '~/helpers/catchError'; + +const LOOKUP_VAL_SEPARATOR = '___'; + +export async function getDisplayValueOfRefTable( + relationCol: Column, +) { + return await relationCol + .getColOptions() + .then((colOpt) => colOpt.getRelatedTable()) + .then((model) => model.getColumns()) + .then((cols) => cols.find((col) => col.pv)); +} + +export default async function generateLookupSelectQuery({ + column, + baseModelSqlv2, + alias, + model: _model, + getAlias = getAliasGenerator('__lk_slt_'), +}: { + column: Column; + baseModelSqlv2: BaseModelSqlv2; + alias: string; + model: Model; + getAlias?: ReturnType; +}): Promise { + const knex = baseModelSqlv2.dbDriver; + + const rootAlias = alias; + + { + let selectQb; + const alias = getAlias(); + let lookupColOpt: LookupColumn; + let isBtLookup = true; + + if (column.uidt === UITypes.Lookup) { + lookupColOpt = await column.getColOptions(); + } else if (column.uidt !== UITypes.LinkToAnotherRecord) { + NcError.badRequest('Invalid column type'); + } + + await column.getColOptions(); + { + const relationCol = lookupColOpt + ? await lookupColOpt.getRelationColumn() + : column; + const relation = + await relationCol.getColOptions(); + + // if not belongs to then throw error as we don't support + if (relation.type === RelationTypes.BELONGS_TO) { + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + selectQb = knex( + `${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${alias}`, + ).where( + `${alias}.${parentColumn.column_name}`, + knex.raw(`??`, [ + `${rootAlias || baseModelSqlv2.getTnPath(childModel.table_name)}.${ + childColumn.column_name + }`, + ]), + ); + } + + // if not belongs to then throw error as we don't support + else if (relation.type === RelationTypes.HAS_MANY) { + isBtLookup = false; + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + selectQb = knex( + `${baseModelSqlv2.getTnPath(childModel.table_name)} as ${alias}`, + ).where( + `${alias}.${childColumn.column_name}`, + knex.raw(`??`, [ + `${rootAlias || baseModelSqlv2.getTnPath(parentModel.table_name)}.${ + parentColumn.column_name + }`, + ]), + ); + } + + // if not belongs to then throw error as we don't support + else if (relation.type === RelationTypes.MANY_TO_MANY) { + isBtLookup = false; + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + selectQb = knex( + `${baseModelSqlv2.getTnPath(parentModel.table_name)} as ${alias}`, + ); + + const mmTableAlias = getAlias(); + + const mmModel = await relation.getMMModel(); + const mmChildCol = await relation.getMMChildColumn(); + const mmParentCol = await relation.getMMParentColumn(); + + selectQb + .innerJoin( + baseModelSqlv2.getTnPath(mmModel.table_name, mmTableAlias), + knex.ref(`${mmTableAlias}.${mmParentCol.column_name}`), + '=', + knex.ref(`${alias}.${parentColumn.column_name}`), + ) + .where( + knex.ref(`${mmTableAlias}.${mmChildCol.column_name}`), + '=', + knex.ref( + `${ + rootAlias || baseModelSqlv2.getTnPath(childModel.table_name) + }.${childColumn.column_name}`, + ), + ); + } + } + let lookupColumn = lookupColOpt + ? await lookupColOpt.getLookupColumn() + : await getDisplayValueOfRefTable(column); + + // if lookup column is qr code or barcode extract the referencing column + if ([UITypes.QrCode, UITypes.Barcode].includes(lookupColumn.uidt)) { + lookupColumn = await lookupColumn + .getColOptions() + .then((barcode) => barcode.getValueColumn()); + } + + let prevAlias = alias; + while ( + lookupColumn.uidt === UITypes.Lookup || + lookupColumn.uidt === UITypes.LinkToAnotherRecord + ) { + const nestedAlias = getAlias(); + + let relationCol: Column; + let nestedLookupColOpt: LookupColumn; + + if (lookupColumn.uidt === UITypes.Lookup) { + nestedLookupColOpt = await lookupColumn.getColOptions(); + relationCol = await nestedLookupColOpt.getRelationColumn(); + } else { + relationCol = lookupColumn; + } + + const relation = + await relationCol.getColOptions(); + + // if any of the relation in nested lookupColOpt is + // not belongs to then throw error as we don't support + if (relation.type === RelationTypes.BELONGS_TO) { + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + selectQb.join( + `${baseModelSqlv2.getTnPath( + parentModel.table_name, + )} as ${nestedAlias}`, + `${nestedAlias}.${parentColumn.column_name}`, + `${prevAlias}.${childColumn.column_name}`, + ); + } else if (relation.type === RelationTypes.HAS_MANY) { + isBtLookup = false; + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + selectQb.join( + `${baseModelSqlv2.getTnPath( + childModel.table_name, + )} as ${nestedAlias}`, + `${nestedAlias}.${childColumn.column_name}`, + `${prevAlias}.${parentColumn.column_name}`, + ); + } else if (relation.type === RelationTypes.MANY_TO_MANY) { + isBtLookup = false; + const childColumn = await relation.getChildColumn(); + const parentColumn = await relation.getParentColumn(); + const childModel = await childColumn.getModel(); + await childModel.getColumns(); + const parentModel = await parentColumn.getModel(); + await parentModel.getColumns(); + + const mmTableAlias = getAlias(); + + const mmModel = await relation.getMMModel(); + const mmChildCol = await relation.getMMChildColumn(); + const mmParentCol = await relation.getMMParentColumn(); + + selectQb + .innerJoin( + baseModelSqlv2.getTnPath(mmModel.table_name, mmTableAlias), + knex.ref(`${mmTableAlias}.${mmChildCol.column_name}`), + '=', + knex.ref(`${prevAlias}.${childColumn.column_name}`), + ) + .innerJoin( + knex.raw('?? as ??', [ + baseModelSqlv2.getTnPath(parentModel.table_name), + nestedAlias, + ]), + knex.ref(`${mmTableAlias}.${mmParentCol.column_name}`), + '=', + knex.ref(`${nestedAlias}.${parentColumn.column_name}`), + ) + .where( + knex.ref(`${mmTableAlias}.${mmChildCol.column_name}`), + '=', + knex.ref( + `${alias || baseModelSqlv2.getTnPath(childModel.table_name)}.${ + childColumn.column_name + }`, + ), + ); + } + + if (lookupColumn.uidt === UITypes.Lookup) + lookupColumn = await nestedLookupColOpt.getLookupColumn(); + else lookupColumn = await getDisplayValueOfRefTable(relationCol); + prevAlias = nestedAlias; + } + + { + // get basemodel and model of lookup column + const model = await lookupColumn.getModel(); + const baseModelSqlv2 = await Model.getBaseModelSQL({ + model, + dbDriver: knex, + }); + + switch (lookupColumn.uidt) { + case UITypes.Attachment: + NcError.badRequest( + 'Group by using attachment column is not supported', + ); + break; + case UITypes.Links: + case UITypes.Rollup: + { + const builder = ( + await genRollupSelectv2({ + baseModelSqlv2, + knex, + columnOptions: + (await lookupColumn.getColOptions()) as RollupColumn, + alias: prevAlias, + }) + ).builder; + selectQb.select(builder); + } + break; + case UITypes.Formula: + { + const builder = ( + await formulaQueryBuilderv2( + baseModelSqlv2, + ( + await lookupColumn.getColOptions() + ).formula, + lookupColumn.title, + model, + lookupColumn, + await model.getAliasColMapping(), + prevAlias, + ) + ).builder; + + selectQb.select(builder); + } + break; + case UITypes.DateTime: + { + await baseModelSqlv2.selectObject({ + qb: selectQb, + columns: [lookupColumn], + alias: prevAlias, + }); + } + break; + default: + { + selectQb.select( + `${prevAlias}.${lookupColumn.column_name} as ${lookupColumn.title}`, + ); + } + + break; + } + } + // if all relation are belongs to then we don't need to do the aggregation + if (isBtLookup) { + return { + builder: selectQb, + }; + } + + const subQueryAlias = getAlias(); + + if (baseModelSqlv2.isPg) { + // alternate approach with array_agg + return { + builder: knex + .select(knex.raw('json_agg(??)::text', [lookupColumn.title])) + .from(selectQb.as(subQueryAlias)), + }; + /* + // alternate approach with array_agg + return { + builder: knex + .select(knex.raw('array_agg(??)', [lookupColumn.title])) + .from(selectQb), + };*/ + // alternate approach with string aggregation + // return { + // builder: knex + // .select( + // knex.raw('STRING_AGG(??::text, ?)', [ + // lookupColumn.title, + // LOOKUP_VAL_SEPARATOR, + // ]), + // ) + // .from(selectQb.as(subQueryAlias)), + // }; + } else if (baseModelSqlv2.isMySQL) { + return { + builder: knex + .select( + knex.raw('cast(JSON_ARRAYAGG(??) as NCHAR)', [lookupColumn.title]), + ) + .from(selectQb.as(subQueryAlias)), + }; + + // return { + // builder: knex + // .select( + // knex.raw('GROUP_CONCAT(?? ORDER BY ?? ASC SEPARATOR ?)', [ + // lookupColumn.title, + // lookupColumn.title, + // LOOKUP_VAL_SEPARATOR, + // ]), + // ) + // .from(selectQb.as(subQueryAlias)), + // }; + } else if (baseModelSqlv2.isSqlite) { + // ref: https://stackoverflow.com/questions/13382856/sqlite3-join-group-concat-using-distinct-with-custom-separator + // selectQb.orderBy(`${lookupColumn.title}`, 'asc'); + return { + builder: knex + .select( + knex.raw(`group_concat(??, ?)`, [ + lookupColumn.title, + LOOKUP_VAL_SEPARATOR, + ]), + ) + .from(selectQb.as(subQueryAlias)), + }; + } + + NcError.notImplemented('Database not supported Group by on Lookup'); + } +} diff --git a/packages/nocodb/src/utils/globals.ts b/packages/nocodb/src/utils/globals.ts index 786320dac7..0a31046558 100644 --- a/packages/nocodb/src/utils/globals.ts +++ b/packages/nocodb/src/utils/globals.ts @@ -171,6 +171,11 @@ export enum CacheDelDirection { CHILD_TO_PARENT = 'CHILD_TO_PARENT', } +export const GROUPBY_COMPARISON_OPS = [ + // these are used for groupby + 'gb_eq', + 'gb_null', +]; export const COMPARISON_OPS = [ 'eq', 'neq', diff --git a/packages/nocodb/tests/unit/rest/tests/groupby.test.ts b/packages/nocodb/tests/unit/rest/tests/groupby.test.ts index 8c61ece1ca..3144d43059 100644 --- a/packages/nocodb/tests/unit/rest/tests/groupby.test.ts +++ b/packages/nocodb/tests/unit/rest/tests/groupby.test.ts @@ -299,7 +299,7 @@ function groupByTests() { expect(response.body.list.length).to.equal(1); }); - it('Check One GroupBy Column with MM Lookup which is not supported', async function () { + it('Check One GroupBy Column with MM Lookup which is supported', async function () { await createLookupColumn(context, { base: sakilaProject, title: 'ActorNames', @@ -308,15 +308,17 @@ function groupByTests() { relatedTableColumnTitle: 'FirstName', }); - const res = await request(context.app) + const response = await request(context.app) .get(`/api/v1/db/data/noco/${sakilaProject.id}/${filmTable.id}/groupby`) .set('xc-auth', context.token) .query({ column_name: 'ActorNames', }) - .expect(400); + .expect(200); - assert.match(res.body.msg, /not supported/); + assert.match(response.body.list[1]['ActorNames'], /ADAM|ANNE/); + expect(+response.body.list[1]['count']).to.gt(0); + expect(response.body.list.length).to.equal(25); }); it('Check One GroupBy Column with Formula and Formula referring another formula', async function () {