diff --git a/packages/nocodb/src/controllers/data-table.controller.ts b/packages/nocodb/src/controllers/data-table.controller.ts index 808650b20b..a67526c6f9 100644 --- a/packages/nocodb/src/controllers/data-table.controller.ts +++ b/packages/nocodb/src/controllers/data-table.controller.ts @@ -126,4 +126,67 @@ export class DataTableController { viewId, }); } + + + @Get(['/api/v1/tables/:modelId/links/:columnId/row/:rowId']) + @Acl('nestedDataList') + async nestedDataList( + @Request() req, + @Param('modelId') modelId: string, + @Query('viewId') viewId: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + ) { + return await this.dataTableService.nestedDataList({ + modelId, + rowId: rowId, + query: req.query, + viewId, + columnId, + }); + } + + + @Post(['/api/v1/tables/:modelId/links/:columnId/row/:rowId']) + @Acl('nestedDataLink') + async nestedLink( + @Request() req, + @Param('modelId') modelId: string, + @Query('viewId') viewId: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + @Body() refRowIds: string | string[] | number | number[], + ) { + return await this.dataTableService.nestedLink({ + modelId, + rowId: rowId, + query: req.query, + viewId, + columnId, + refRowIds, + cookie: req, + }); + } + + + @Delete(['/api/v1/tables/:modelId/links/:columnId/row/:rowId']) + @Acl('nestedDataUnlink') + async nestedUnlink( + @Request() req, + @Param('modelId') modelId: string, + @Query('viewId') viewId: string, + @Param('columnId') columnId: string, + @Param('rowId') rowId: string, + @Body() refRowIds: string | string[] | number | number[], + ) { + return await this.dataTableService.nestedUnlink({ + modelId, + rowId: rowId, + query: req.query, + viewId, + columnId, + refRowIds, + cookie: req, + }); + } } diff --git a/packages/nocodb/src/db/BaseModelSqlv2.ts b/packages/nocodb/src/db/BaseModelSqlv2.ts index d67613ca4c..f161de2d7c 100644 --- a/packages/nocodb/src/db/BaseModelSqlv2.ts +++ b/packages/nocodb/src/db/BaseModelSqlv2.ts @@ -3483,6 +3483,385 @@ class BaseModelSqlv2 { } return data; } + + async addLinks({ + cookie, + childIds, + colId, + rowId, + }: { + cookie: any; + childIds: (string | number)[]; + colId: string; + rowId: string; + }) { + const columns = await this.model.getColumns(); + const column = columns.find((c) => c.id === colId); + + if (!column || column.uidt !== UITypes.LinkToAnotherRecord) + NcError.notFound('Column not found'); + + const row = await this.dbDriver(this.tnPath) + .where(await this._wherePk(rowId)) + .first(); + + // validate rowId + if (!row) { + NcError.notFound('Row not found'); + } + + const colOptions = await column.getColOptions(); + + const childColumn = await colOptions.getChildColumn(); + const parentColumn = await colOptions.getParentColumn(); + const parentTable = await parentColumn.getModel(); + const childTable = await childColumn.getModel(); + await childTable.getColumns(); + await parentTable.getColumns(); + + const childTn = this.getTnPath(childTable); + const parentTn = this.getTnPath(parentTable); + + switch (colOptions.type) { + case RelationTypes.MANY_TO_MANY: + { + const vChildCol = await colOptions.getMMChildColumn(); + const vParentCol = await colOptions.getMMParentColumn(); + const vTable = await colOptions.getMMModel(); + + const vTn = this.getTnPath(vTable); + + let insertData: Record[]; + + // validate Ids + { + const childRowsQb = this.dbDriver(parentTn) + .select(parentColumn.column_name) + .select(`${vTable.table_name}.${vChildCol.column_name}`) + .leftJoin(vTn, (qb) => { + qb.on( + `${vTable.table_name}.${vParentCol.column_name}`, + `${parentTable.table_name}.${parentColumn.column_name}`, + ).andOn( + `${vTable.table_name}.${vChildCol.column_name}`, + row[childColumn.column_name], + ); + }); + // .where(_wherePk(parentTable.primaryKeys, childId)) + + if (parentTable.primaryKeys.length > 1) { + childRowsQb.where((qb) => { + for (const childId of childIds) { + qb.orWhere(_wherePk(parentTable.primaryKeys, childId)); + } + }); + } else { + childRowsQb.whereIn(parentTable.primaryKey.column_name, childIds); + } + + if (parentTable.primaryKey.column_name !== parentColumn.column_name) + childRowsQb.select(parentTable.primaryKey.column_name); + + const childRows = await childRowsQb; + + if (childRows.length !== childIds.length) { + const missingIds = childIds.filter( + (id) => + !childRows.find((r) => r[parentColumn.column_name] === id), + ); + + NcError.notFound( + `Child record with id ${missingIds.join(', ')} not found`, + ); + } + + insertData = childRows + // skip existing links + .filter((childRow) => !childRow[vChildCol.column_name]) + // generate insert data for new links + .map((childRow) => ({ + [vParentCol.column_name]: childRow[parentColumn.column_name], + [vChildCol.column_name]: row[childColumn.column_name], + })); + + // if no new links, return true + if (!insertData.length) return true; + } + + // if (this.isSnowflake) { + // const parentPK = this.dbDriver(parentTn) + // .select(parentColumn.column_name) + // // .where(_wherePk(parentTable.primaryKeys, childId)) + // .whereIn(parentTable.primaryKey.column_name, childIds) + // .first(); + // + // const childPK = this.dbDriver(childTn) + // .select(childColumn.column_name) + // .where(_wherePk(childTable.primaryKeys, rowId)) + // .first(); + // + // await this.dbDriver.raw( + // `INSERT INTO ?? (??, ??) SELECT (${parentPK.toQuery()}), (${childPK.toQuery()})`, + // [vTn, vParentCol.column_name, vChildCol.column_name], + // ); + // } else { + // await this.dbDriver(vTn).insert({ + // [vParentCol.column_name]: this.dbDriver(parentTn) + // .select(parentColumn.column_name) + // // .where(_wherePk(parentTable.primaryKeys, childId)) + // .where(parentTable.primaryKey.column_name, childIds) + // .first(), + // [vChildCol.column_name]: this.dbDriver(childTn) + // .select(childColumn.column_name) + // .where(_wherePk(childTable.primaryKeys, rowId)) + // .first(), + // }); + + // todo: use bulk insert + await this.dbDriver(vTn).insert(insertData); + // } + } + break; + case RelationTypes.HAS_MANY: + { + // validate Ids + { + const childRowsQb = this.dbDriver(childTn).select( + childTable.primaryKey.column_name, + ); + + if (childTable.primaryKeys.length > 1) { + childRowsQb.where((qb) => { + for (const childId of childIds) { + qb.orWhere(_wherePk(childTable.primaryKeys, childId)); + } + }); + } else { + childRowsQb.whereIn(parentTable.primaryKey.column_name, childIds); + } + + const childRows = await childRowsQb; + + if (childRows.length !== childIds.length) { + const missingIds = childIds.filter( + (id) => + !childRows.find((r) => r[parentColumn.column_name] === id), + ); + + NcError.notFound( + `Child record with id ${missingIds.join(', ')} not found`, + ); + } + } + + await this.dbDriver(childTn) + .update({ + [childColumn.column_name]: this.dbDriver.from( + this.dbDriver(parentTn) + .select(parentColumn.column_name) + .where(_wherePk(parentTable.primaryKeys, rowId)) + .first() + .as('___cn_alias'), + ), + }) + // .where(_wherePk(childTable.primaryKeys, childId)); + .whereIn(childTable.primaryKey.column_name, childIds); + } + break; + case RelationTypes.BELONGS_TO: + { + // validate Ids + { + const childRowsQb = this.dbDriver(parentTn) + .select(parentTable.primaryKey.column_name) + .where(_wherePk(parentTable.primaryKeys, childIds[0])) + .first(); + + const childRow = await childRowsQb; + + if (!childRow) { + NcError.notFound(`Child record with id ${childIds[0]} not found`); + } + } + + await this.dbDriver(childTn) + .update({ + [childColumn.column_name]: this.dbDriver.from( + this.dbDriver(parentTn) + .select(parentColumn.column_name) + .where(_wherePk(parentTable.primaryKeys, childIds[0])) + // .whereIn(parentTable.primaryKey.column_name, childIds) + .first() + .as('___cn_alias'), + ), + }) + .where(_wherePk(childTable.primaryKeys, rowId)); + } + break; + } + + // const response = await this.readByPk(rowId); + // await this.afterInsert(response, this.dbDriver, cookie); + // await this.afterAddChild(rowId, childId, cookie); + } + + async removeLinks({ + cookie, + childIds, + colId, + rowId, + }: { + cookie: any; + childIds: (string | number)[]; + colId: string; + rowId: string; + }) { + const columns = await this.model.getColumns(); + const column = columns.find((c) => c.id === colId); + + if (!column || column.uidt !== UITypes.LinkToAnotherRecord) + NcError.notFound('Column not found'); + + const row = await this.dbDriver(this.tnPath) + .where(await this._wherePk(rowId)) + .first(); + + // validate rowId + if (!row) { + NcError.notFound('Row not found'); + } + + const colOptions = await column.getColOptions(); + + const childColumn = await colOptions.getChildColumn(); + const parentColumn = await colOptions.getParentColumn(); + const parentTable = await parentColumn.getModel(); + const childTable = await childColumn.getModel(); + await childTable.getColumns(); + await parentTable.getColumns(); + + const childTn = this.getTnPath(childTable); + const parentTn = this.getTnPath(parentTable); + + const prevData = await this.readByPk(rowId); + + switch (colOptions.type) { + case RelationTypes.MANY_TO_MANY: + { + const vChildCol = await colOptions.getMMChildColumn(); + const vParentCol = await colOptions.getMMParentColumn(); + const vTable = await colOptions.getMMModel(); + + // validate Ids + { + const childRowsQb = this.dbDriver(parentTn) + .select(parentColumn.column_name) + // .where(_wherePk(parentTable.primaryKeys, childId)) + .whereIn(parentTable.primaryKey.column_name, childIds); + + if (parentTable.primaryKey.column_name !== parentColumn.column_name) + childRowsQb.select(parentTable.primaryKey.column_name); + + const childRows = await childRowsQb; + + if (childRows.length !== childIds.length) { + const missingIds = childIds.filter( + (id) => + !childRows.find((r) => r[parentColumn.column_name] === id), + ); + + NcError.notFound( + `Child record with id ${missingIds.join(', ')} not found`, + ); + } + } + + const vTn = this.getTnPath(vTable); + + await this.dbDriver(vTn) + .where({ + [vChildCol.column_name]: this.dbDriver(childTn) + .select(childColumn.column_name) + .where(_wherePk(childTable.primaryKeys, rowId)) + .first(), + }) + .whereIn( + [vParentCol.column_name], + this.dbDriver(parentTn) + .select(parentColumn.column_name) + .whereIn(parentTable.primaryKey.column_name, childIds), + ) + .delete(); + } + break; + case RelationTypes.HAS_MANY: + { + // validate Ids + { + const childRowsQb = this.dbDriver(childTn) + .select(childTable.primaryKey.column_name) + .whereIn(childTable.primaryKey.column_name, childIds); + + const childRows = await childRowsQb; + + if (childRows.length !== childIds.length) { + const missingIds = childIds.filter( + (id) => + !childRows.find((r) => r[parentColumn.column_name] === id), + ); + + NcError.notFound( + `Child record with id ${missingIds.join(', ')} not found`, + ); + } + } + + await this.dbDriver(childTn) + // .where({ + // [childColumn.cn]: this.dbDriver(parentTable.tn) + // .select(parentColumn.cn) + // .where(parentTable.primaryKey.cn, rowId) + // .first() + // }) + // .where(_wherePk(childTable.primaryKeys, childId)) + .whereIn(childTable.primaryKey.column_name, childIds) + .update({ [childColumn.column_name]: null }); + } + break; + case RelationTypes.BELONGS_TO: + { + // validate Ids + { + const childRowsQb = this.dbDriver(parentTn) + .select(parentTable.primaryKey.column_name) + .where(_wherePk(parentTable.primaryKeys, childIds[0])) + .first(); + + const childRow = await childRowsQb; + + if (!childRow) { + NcError.notFound(`Child record with id ${childIds[0]} not found`); + } + } + + await this.dbDriver(childTn) + // .where({ + // [childColumn.cn]: this.dbDriver(parentTable.tn) + // .select(parentColumn.cn) + // .where(parentTable.primaryKey.cn, childId) + // .first() + // }) + // .where(_wherePk(childTable.primaryKeys, rowId)) + .where(childTable.primaryKey.column_name, rowId) + .update({ [childColumn.column_name]: null }); + } + break; + } + + // const newData = await this.readByPk(rowId); + // await this.afterUpdate(prevData, newData, this.dbDriver, cookie); + // await this.afterRemoveChild(rowId, childIds, cookie); + } } function extractSortsObject( diff --git a/packages/nocodb/src/modules/datas/datas.module.ts b/packages/nocodb/src/modules/datas/datas.module.ts index 3d72f2b1b4..9ee4adeecf 100644 --- a/packages/nocodb/src/modules/datas/datas.module.ts +++ b/packages/nocodb/src/modules/datas/datas.module.ts @@ -29,7 +29,7 @@ import { PublicDatasService } from '../../services/public-datas.service'; }), ], controllers: [ - process.env.NC_WORKER_CONTAINER !== 'true' + ...(process.env.NC_WORKER_CONTAINER !== 'true' ? [ DataTableController, DatasController, diff --git a/packages/nocodb/src/services/data-table.service.ts b/packages/nocodb/src/services/data-table.service.ts index b630300c93..e301ed1267 100644 --- a/packages/nocodb/src/services/data-table.service.ts +++ b/packages/nocodb/src/services/data-table.service.ts @@ -1,8 +1,16 @@ import { Injectable } from '@nestjs/common'; +import { RelationTypes, UITypes } from 'nocodb-sdk'; import { NcError } from '../helpers/catchError'; -import { Base, Model, View } from '../models'; +import { PagedResponseImpl } from '../helpers/PagedResponse'; +import { Base, Column, Model, View } from '../models'; +import { + getColumnByIdOrName, + getViewAndModelByAliasOrId, + PathParams, +} from '../modules/datas/helpers'; import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2'; import { DatasService } from './datas.service'; +import type { LinkToAnotherRecordColumn } from '../models'; @Injectable() export class DataTableService { @@ -231,4 +239,137 @@ export class DataTableService { keys.add(pk); } } + + async nestedDataList(param: { + viewId: string; + modelId: string; + query: any; + rowId: string | string[] | number | number[]; + columnId: string; + }) { + const { model, view } = await this.getModelAndView(param); + const base = await Base.get(model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + const column = await this.getColumn(param); + + const colOptions = await column.getColOptions(); + + let data: any[]; + let count: number; + if (colOptions.type === RelationTypes.MANY_TO_MANY) { + data = await baseModel.mmList( + { + colId: column.id, + parentId: param.rowId, + }, + param.query as any, + ); + count = (await baseModel.mmListCount({ + colId: column.id, + parentId: param.rowId, + })) as number; + } else { + data = await baseModel.hmList( + { + colId: column.id, + id: param.rowId, + }, + param.query as any, + ); + count = (await baseModel.hmListCount({ + colId: column.id, + id: param.rowId, + })) as number; + } + return new PagedResponseImpl(data, { + count, + ...param.query, + }); + } + + private async getColumn(param: { modelId: string; columnId: string }) { + const column = await Column.get({ colId: param.columnId }); + + if (!column) NcError.badRequest('Column not found'); + + if (column.fk_model_id !== param.modelId) + NcError.badRequest('Column not belong to model'); + + if (column.uidt !== UITypes.LinkToAnotherRecord) + NcError.badRequest('Column is not LTAR'); + return column; + } + + async nestedLink(param: { + cookie: any; + viewId: string; + modelId: string; + columnId: string; + query: any; + refRowIds: string | string[] | number | number[]; + rowId: string; + }) { + const { model, view } = await this.getModelAndView(param); + if (!model) NcError.notFound('Table not found'); + + const base = await Base.get(model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const column = await this.getColumn(param); + + await baseModel.addLinks({ + colId: column.id, + childIds: Array.isArray(param.refRowIds) + ? param.refRowIds + : [param.refRowIds], + rowId: param.rowId, + cookie: param.cookie, + }); + + return true; + } + + async nestedUnlink(param: { + cookie: any; + viewId: string; + modelId: string; + columnId: string; + query: any; + refRowIds: string | string[] | number | number[]; + rowId: string; + }) { + const { model, view } = await this.getModelAndView(param); + if (!model) NcError.notFound('Table not found'); + + const base = await Base.get(model.base_id); + + const baseModel = await Model.getBaseModelSQL({ + id: model.id, + viewId: view?.id, + dbDriver: await NcConnectionMgrv2.get(base), + }); + + const column = await this.getColumn(param); + + await baseModel.removeLinks({ + colId: column.id, + childIds: Array.isArray(param.refRowIds) + ? param.refRowIds + : [param.refRowIds], + rowId: param.rowId, + cookie: param.cookie, + }); + + return true; + } }