From 8ef95f63d18f3ee1e6f1dde9b0c2bda3d232546e Mon Sep 17 00:00:00 2001 From: Pranav C Date: Fri, 7 Apr 2023 17:18:25 +0530 Subject: [PATCH] feat: add tables apis Signed-off-by: Pranav C --- .../src/modules/tables/tables.controller.ts | 128 ++++- .../src/modules/tables/tables.service.ts | 485 +++++++++++++++++- .../nocodb-nest/src/utils/extractRolesObj.ts | 9 + 3 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 packages/nocodb-nest/src/utils/extractRolesObj.ts diff --git a/packages/nocodb-nest/src/modules/tables/tables.controller.ts b/packages/nocodb-nest/src/modules/tables/tables.controller.ts index bddde26376..bef3cc1758 100644 --- a/packages/nocodb-nest/src/modules/tables/tables.controller.ts +++ b/packages/nocodb-nest/src/modules/tables/tables.controller.ts @@ -1,7 +1,131 @@ -import { Controller } from '@nestjs/common'; +import { + Controller, + Get, + Param, + Query, + UseGuards, + Request, + Post, + Body, + Patch, + Delete, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { TableReqType } from 'nocodb-sdk'; +import extractRolesObj from 'src/utils/extractRolesObj'; +import { PagedResponseImpl } from '../../helpers/PagedResponse'; +import { + ExtractProjectIdMiddleware, + UseAclMiddleware, +} from '../../middlewares/extract-project-id/extract-project-id.middleware'; import { TablesService } from './tables.service'; -@Controller('tables') +@Controller() +@UseGuards(ExtractProjectIdMiddleware, AuthGuard('jwt')) export class TablesController { constructor(private readonly tablesService: TablesService) {} + + @Get([ + '/api/v1/db/meta/projects/:projectId/tables', + '/api/v1/db/meta/projects/:projectId/:baseId/tables', + ]) + @UseAclMiddleware({ + permissionName: 'tableList', + }) + async tableList( + @Param('projectId') projectId: string, + @Param('baseId') baseId: string, + @Query('includeM2M') includeM2M: string, + @Request() req, + ) { + return new PagedResponseImpl( + await this.tablesService.getAccessibleTables({ + projectId, + baseId, + includeM2M: includeM2M === 'true', + roles: extractRolesObj(req.user.roles), + }), + ); + } + + @Post([ + '/api/v1/db/meta/projects/:projectId/tables', + '/api/v1/db/meta/projects/:projectId/:baseId/tables', + ]) + @UseAclMiddleware({ + permissionName: 'tableCreate', + }) + async tableCreate( + @Param('projectId') projectId: string, + @Param('baseId') baseId: string, + @Body() body: TableReqType, + @Request() req, + ) { + const result = await this.tablesService.tableCreate({ + projectId: projectId, + baseId: baseId, + table: body, + user: req.user, + }); + + return result; + } + + @Get('/api/v1/db/meta/tables/:tableId') + @UseAclMiddleware({ + permissionName: 'tableGet', + }) + async tableGet(@Param('tableId') tableId: string, @Request() req) { + const table = await this.tablesService.getTableWithAccessibleViews({ + tableId: req.params.tableId, + user: req.user, + }); + + return table; + } + + @Patch('/api/v1/db/meta/tables/:tableId') + @UseAclMiddleware({ + permissionName: 'tableUpdate', + }) + async tableUpdate( + @Param('tableId') tableId: string, + @Body() body: TableReqType, + @Request() req, + ) { + await this.tablesService.tableUpdate({ + tableId: tableId, + table: body, + projectId: req.user, + }); + return { msg: 'The table has been updated successfully' }; + } + + @Delete('/api/v1/db/meta/tables/:tableId') + @UseAclMiddleware({ + permissionName: 'tableDelete', + }) + async tableDelete(@Param('tableId') tableId: string, @Request() req) { + const result = await this.tablesService.tableDelete({ + tableId: req.params.tableId, + user: (req as any).user, + req, + }); + + return result; + } + + @Post('/api/v1/db/meta/tables/:tableId/reorder') + @UseAclMiddleware({ + permissionName: 'tableReorder', + }) + async tableReorder( + @Param('tableId') tableId: string, + @Body() body: { order: number }, + ) { + return this.tablesService.reorderTable({ + tableId, + order: body.order, + }); + } } diff --git a/packages/nocodb-nest/src/modules/tables/tables.service.ts b/packages/nocodb-nest/src/modules/tables/tables.service.ts index 01548b10b7..be82fb7db8 100644 --- a/packages/nocodb-nest/src/modules/tables/tables.service.ts +++ b/packages/nocodb-nest/src/modules/tables/tables.service.ts @@ -1,4 +1,487 @@ import { Injectable } from '@nestjs/common'; +import DOMPurify from 'isomorphic-dompurify'; +import { + AuditOperationSubTypes, + AuditOperationTypes, + ColumnType, + isVirtualCol, + ModelTypes, + NormalColumnRequestType, + TableReqType, + UITypes, +} from 'nocodb-sdk'; +import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2'; +import { NcError } from '../../helpers/catchError'; +import getColumnPropsFromUIDT from '../../helpers/getColumnPropsFromUIDT'; +import getColumnUiType from '../../helpers/getColumnUiType'; +import getTableNameAlias, { + getColumnNameAlias, +} from '../../helpers/getTableName'; +import mapDefaultDisplayValue from '../../helpers/mapDefaultDisplayValue'; +import { + Audit, + Column, + LinkToAnotherRecordColumn, + Model, + ModelRoleVisibility, + Project, + User, + View, +} from '../../models'; +import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2'; +import { T } from 'nc-help'; +import { validatePayload } from 'src/helpers'; @Injectable() -export class TablesService {} +export class TablesService { + async tableUpdate(param: { + tableId: any; + table: TableReqType & { project_id?: string }; + projectId?: string; + }) { + const model = await Model.get(param.tableId); + + const project = await Project.getWithInfo( + param.table.project_id || param.projectId, + ); + const base = project.bases.find((b) => b.id === model.base_id); + + if (model.project_id !== project.id) { + NcError.badRequest('Model does not belong to project'); + } + + // if meta present update meta and return + // todo: allow user to update meta and other prop in single api call + if ('meta' in param.table) { + await Model.updateMeta(param.tableId, param.table.meta); + + return true; + } + + if (!param.table.table_name) { + NcError.badRequest( + 'Missing table name `table_name` property in request body', + ); + } + + if (base.is_meta && project.prefix) { + if (!param.table.table_name.startsWith(project.prefix)) { + param.table.table_name = `${project.prefix}${param.table.table_name}`; + } + } + + param.table.table_name = DOMPurify.sanitize(param.table.table_name); + + // validate table name + if (/^\s+|\s+$/.test(param.table.table_name)) { + NcError.badRequest( + 'Leading or trailing whitespace not allowed in table names', + ); + } + + if ( + !(await Model.checkTitleAvailable({ + table_name: param.table.table_name, + project_id: project.id, + base_id: base.id, + })) + ) { + NcError.badRequest('Duplicate table name'); + } + + if (!param.table.title) { + param.table.title = getTableNameAlias( + param.table.table_name, + project.prefix, + base, + ); + } + + if ( + !(await Model.checkAliasAvailable({ + title: param.table.title, + project_id: project.id, + base_id: base.id, + })) + ) { + NcError.badRequest('Duplicate table alias'); + } + + const sqlMgr = await ProjectMgrv2.getSqlMgr(project); + const sqlClient = await NcConnectionMgrv2.getSqlClient(base); + + let tableNameLengthLimit = 255; + const sqlClientType = sqlClient.knex.clientType(); + if (sqlClientType === 'mysql2' || sqlClientType === 'mysql') { + tableNameLengthLimit = 64; + } else if (sqlClientType === 'pg') { + tableNameLengthLimit = 63; + } else if (sqlClientType === 'mssql') { + tableNameLengthLimit = 128; + } + + if (param.table.table_name.length > tableNameLengthLimit) { + NcError.badRequest( + `Table name exceeds ${tableNameLengthLimit} characters`, + ); + } + + await Model.updateAliasAndTableName( + param.tableId, + param.table.title, + param.table.table_name, + ); + + await sqlMgr.sqlOpPlus(base, 'tableRename', { + ...param.table, + tn: param.table.table_name, + tn_old: model.table_name, + }); + + T.emit('evt', { evt_type: 'table:updated' }); + return true; + } + + reorderTable(param: { tableId: string; order: any }) { + return Model.updateOrder(param.tableId, param.order); + } + + async tableDelete(param: { tableId: string; user: User; req?: any }) { + const table = await Model.getByIdOrName({ id: param.tableId }); + await table.getColumns(); + + const relationColumns = table.columns.filter( + (c) => c.uidt === UITypes.LinkToAnotherRecord, + ); + + if (relationColumns?.length) { + const referredTables = await Promise.all( + relationColumns.map(async (c) => + c + .getColOptions() + .then((opt) => opt.getRelatedTable()) + .then(), + ), + ); + NcError.badRequest( + `Table can't be deleted since Table is being referred in following tables : ${referredTables.join( + ', ', + )}. Delete LinkToAnotherRecord columns and try again.`, + ); + } + + const project = await Project.getWithInfo(table.project_id); + const base = project.bases.find((b) => b.id === table.base_id); + const sqlMgr = await ProjectMgrv2.getSqlMgr(project); + (table as any).tn = table.table_name; + table.columns = table.columns.filter((c) => !isVirtualCol(c)); + table.columns.forEach((c) => { + (c as any).cn = c.column_name; + }); + + if (table.type === ModelTypes.TABLE) { + await sqlMgr.sqlOpPlus(base, 'tableDelete', table); + } else if (table.type === ModelTypes.VIEW) { + await sqlMgr.sqlOpPlus(base, 'viewDelete', { + ...table, + view_name: table.table_name, + }); + } + + await Audit.insert({ + project_id: project.id, + base_id: base.id, + op_type: AuditOperationTypes.TABLE, + op_sub_type: AuditOperationSubTypes.DELETED, + user: param.user?.email, + description: `Deleted ${table.type} ${table.table_name} with alias ${table.title} `, + ip: param.req?.clientIp, + }).then(() => {}); + + T.emit('evt', { evt_type: 'table:deleted' }); + + return table.delete(); + } + + async getTableWithAccessibleViews(param: { tableId: string; user: User }) { + const table = await Model.getWithInfo({ + id: param.tableId, + }); + + // todo: optimise + const viewList = ( + await this.xcVisibilityMetaGet(table.project_id, [table]) + ); + + //await View.list(param.tableId) + table.views = viewList.filter((table: any) => { + return Object.keys(param.user?.roles).some( + (role) => param.user?.roles[role] && !table.disabled[role], + ); + }); + + return table; + } + + async xcVisibilityMetaGet( + projectId, + _models: Model[] = null, + includeM2M = true, + // type: 'table' | 'tableAndViews' | 'views' = 'table' + ) { + // todo: move to + const roles = [ + 'owner', + 'creator', + 'viewer', + 'editor', + 'commenter', + 'guest', + ]; + + const defaultDisabled = roles.reduce((o, r) => ({ ...o, [r]: false }), {}); + + let models = + _models || + (await Model.list({ + project_id: projectId, + base_id: undefined, + })); + + models = includeM2M ? models : (models.filter((t) => !t.mm) as Model[]); + + const result = await models.reduce(async (_obj, model) => { + const obj = await _obj; + + const views = await model.getViews(); + for (const view of views) { + obj[view.id] = { + ptn: model.table_name, + _ptn: model.title, + ptype: model.type, + tn: view.title, + _tn: view.title, + table_meta: model.meta, + ...view, + disabled: { ...defaultDisabled }, + }; + } + + return obj; + }, Promise.resolve({})); + + const disabledList = await ModelRoleVisibility.list(projectId); + + for (const d of disabledList) { + if (result[d.fk_view_id]) + result[d.fk_view_id].disabled[d.role] = !!d.disabled; + } + + return Object.values(result); + } + + async getAccessibleTables(param: { + projectId: string; + baseId: string; + includeM2M?: boolean; + roles: Record; + }) { + const viewList = await this.xcVisibilityMetaGet(param.projectId); + + // todo: optimise + const tableViewMapping = viewList.reduce((o, view: any) => { + o[view.fk_model_id] = o[view.fk_model_id] || 0; + if ( + Object.keys(param.roles).some( + (role) => param.roles[role] && !view.disabled[role], + ) + ) { + o[view.fk_model_id]++; + } + return o; + }, {}); + + const tableList = ( + await Model.list({ + project_id: param.projectId, + base_id: param.baseId, + }) + ).filter((t) => tableViewMapping[t.id]); + + return param.includeM2M + ? tableList + : (tableList.filter((t) => !t.mm) as Model[]); + } + + async tableCreate(param: { + projectId: string; + baseId?: string; + table: TableReqType; + user: User; + req?: any; + }) { + validatePayload('swagger.json#/components/schemas/TableReq', param.table); + + const tableCreatePayLoad: Omit & { + columns: (Omit & { cn?: string })[]; + } = { + ...param.table, + }; + + const project = await Project.getWithInfo(param.projectId); + let base = project.bases[0]; + + if (param.baseId) { + base = project.bases.find((b) => b.id === param.baseId); + } + + if ( + !tableCreatePayLoad.table_name || + (project.prefix && project.prefix === tableCreatePayLoad.table_name) + ) { + NcError.badRequest( + 'Missing table name `table_name` property in request body', + ); + } + + if (base.is_meta && project.prefix) { + if (!tableCreatePayLoad.table_name.startsWith(project.prefix)) { + tableCreatePayLoad.table_name = `${project.prefix}_${tableCreatePayLoad.table_name}`; + } + } + + tableCreatePayLoad.table_name = DOMPurify.sanitize( + tableCreatePayLoad.table_name, + ); + + // validate table name + if (/^\s+|\s+$/.test(tableCreatePayLoad.table_name)) { + NcError.badRequest( + 'Leading or trailing whitespace not allowed in table names', + ); + } + + if ( + !(await Model.checkTitleAvailable({ + table_name: tableCreatePayLoad.table_name, + project_id: project.id, + base_id: base.id, + })) + ) { + NcError.badRequest('Duplicate table name'); + } + + if (!tableCreatePayLoad.title) { + tableCreatePayLoad.title = getTableNameAlias( + tableCreatePayLoad.table_name, + project.prefix, + base, + ); + } + + if ( + !(await Model.checkAliasAvailable({ + title: tableCreatePayLoad.title, + project_id: project.id, + base_id: base.id, + })) + ) { + NcError.badRequest('Duplicate table alias'); + } + + const sqlMgr = await ProjectMgrv2.getSqlMgr(project); + + const sqlClient = await NcConnectionMgrv2.getSqlClient(base); + + let tableNameLengthLimit = 255; + const sqlClientType = sqlClient.knex.clientType(); + if (sqlClientType === 'mysql2' || sqlClientType === 'mysql') { + tableNameLengthLimit = 64; + } else if (sqlClientType === 'pg') { + tableNameLengthLimit = 63; + } else if (sqlClientType === 'mssql') { + tableNameLengthLimit = 128; + } + + if (tableCreatePayLoad.table_name.length > tableNameLengthLimit) { + NcError.badRequest( + `Table name exceeds ${tableNameLengthLimit} characters`, + ); + } + + const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType); + + for (const column of param.table.columns) { + if (column.column_name.length > mxColumnLength) { + NcError.badRequest( + `Column name ${column.column_name} exceeds ${mxColumnLength} characters`, + ); + } + } + + tableCreatePayLoad.columns = await Promise.all( + param.table.columns?.map(async (c) => ({ + ...(await getColumnPropsFromUIDT(c as any, base)), + cn: c.column_name, + column_name: c.column_name, + })), + ); + await sqlMgr.sqlOpPlus(base, 'tableCreate', { + ...tableCreatePayLoad, + tn: tableCreatePayLoad.table_name, + }); + + const columns: Array< + Omit & { + cn: string; + system?: boolean; + } + > = (await sqlClient.columnList({ tn: tableCreatePayLoad.table_name })) + ?.data?.list; + + const tables = await Model.list({ + project_id: project.id, + base_id: base.id, + }); + + await Audit.insert({ + project_id: project.id, + base_id: base.id, + op_type: AuditOperationTypes.TABLE, + op_sub_type: AuditOperationSubTypes.CREATED, + user: param.user?.email, + description: `created table ${tableCreatePayLoad.table_name} with alias ${tableCreatePayLoad.title} `, + ip: param.req?.clientIp, + }).then(() => {}); + + mapDefaultDisplayValue(param.table.columns); + + T.emit('evt', { evt_type: 'table:created' }); + + // todo: type correction + const result = await Model.insert(project.id, base.id, { + ...tableCreatePayLoad, + columns: columns.map((c, i) => { + const colMetaFromReq = param.table?.columns?.find( + (c1) => c.cn === c1.column_name, + ); + return { + ...colMetaFromReq, + uidt: colMetaFromReq?.uidt || c.uidt || getColumnUiType(base, c), + ...c, + dtxp: [UITypes.MultiSelect, UITypes.SingleSelect].includes( + colMetaFromReq.uidt as any, + ) + ? colMetaFromReq.dtxp + : c.dtxp, + title: colMetaFromReq?.title || getColumnNameAlias(c.cn, base), + column_name: c.cn, + order: i + 1, + } as NormalColumnRequestType; + }), + order: +(tables?.pop()?.order ?? 0) + 1, + } as any); + + return result; + } +} diff --git a/packages/nocodb-nest/src/utils/extractRolesObj.ts b/packages/nocodb-nest/src/utils/extractRolesObj.ts new file mode 100644 index 0000000000..092b85c127 --- /dev/null +++ b/packages/nocodb-nest/src/utils/extractRolesObj.ts @@ -0,0 +1,9 @@ +export default (roles: string | string[]) => { + if (typeof roles === 'string') { + roles = roles.split(','); + } + return roles.reduce((acc, role) => { + acc[role] = true; + return acc; + }, {}); +};