mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
1 year ago
20 changed files with 4343 additions and 89 deletions
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'; |
||||
import { DataTableController } from './data-table.controller'; |
||||
|
||||
describe('DataTableController', () => { |
||||
let controller: DataTableController; |
||||
|
||||
beforeEach(async () => { |
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
controllers: [DataTableController], |
||||
}).compile(); |
||||
|
||||
controller = module.get<DataTableController>(DataTableController); |
||||
}); |
||||
|
||||
it('should be defined', () => { |
||||
expect(controller).toBeDefined(); |
||||
}); |
||||
}); |
@ -0,0 +1,189 @@
|
||||
import { |
||||
Body, |
||||
Controller, |
||||
Delete, |
||||
Get, |
||||
HttpCode, |
||||
Param, |
||||
Patch, |
||||
Post, |
||||
Query, |
||||
Request, |
||||
Response, |
||||
UseGuards, |
||||
} from '@nestjs/common'; |
||||
import { GlobalGuard } from '../guards/global/global.guard'; |
||||
import { parseHrtimeToSeconds } from '../helpers'; |
||||
import { |
||||
Acl, |
||||
ExtractProjectIdMiddleware, |
||||
} from '../middlewares/extract-project-id/extract-project-id.middleware'; |
||||
import { DataTableService } from '../services/data-table.service'; |
||||
|
||||
@Controller() |
||||
@UseGuards(ExtractProjectIdMiddleware, GlobalGuard) |
||||
export class DataTableController { |
||||
constructor(private readonly dataTableService: DataTableService) {} |
||||
|
||||
// todo: Handle the error case where view doesnt belong to model
|
||||
@Get('/api/v1/tables/:modelId/rows') |
||||
@Acl('dataList') |
||||
async dataList( |
||||
@Request() req, |
||||
@Response() res, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
) { |
||||
const startTime = process.hrtime(); |
||||
const responseData = await this.dataTableService.dataList({ |
||||
query: req.query, |
||||
modelId: modelId, |
||||
viewId: viewId, |
||||
}); |
||||
const elapsedSeconds = parseHrtimeToSeconds(process.hrtime(startTime)); |
||||
res.setHeader('xc-db-response', elapsedSeconds); |
||||
res.json(responseData); |
||||
} |
||||
|
||||
@Get(['/api/v1/tables/:modelId/rows/count']) |
||||
@Acl('dataCount') |
||||
async dataCount( |
||||
@Request() req, |
||||
@Response() res, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
) { |
||||
const countResult = await this.dataTableService.dataCount({ |
||||
query: req.query, |
||||
modelId, |
||||
viewId, |
||||
}); |
||||
|
||||
res.json(countResult); |
||||
} |
||||
|
||||
@Post(['/api/v1/tables/:modelId/rows']) |
||||
@HttpCode(200) |
||||
@Acl('dataInsert') |
||||
async dataInsert( |
||||
@Request() req, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
@Body() body: any, |
||||
) { |
||||
return await this.dataTableService.dataInsert({ |
||||
modelId: modelId, |
||||
body: body, |
||||
viewId, |
||||
cookie: req, |
||||
}); |
||||
} |
||||
|
||||
@Patch(['/api/v1/tables/:modelId/rows']) |
||||
@Acl('dataUpdate') |
||||
async dataUpdate( |
||||
@Request() req, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
@Param('rowId') rowId: string, |
||||
) { |
||||
return await this.dataTableService.dataUpdate({ |
||||
modelId: modelId, |
||||
body: req.body, |
||||
cookie: req, |
||||
viewId, |
||||
}); |
||||
} |
||||
|
||||
@Delete(['/api/v1/tables/:modelId/rows']) |
||||
@Acl('dataDelete') |
||||
async dataDelete( |
||||
@Request() req, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
@Param('rowId') rowId: string, |
||||
) { |
||||
return await this.dataTableService.dataDelete({ |
||||
modelId: modelId, |
||||
cookie: req, |
||||
viewId, |
||||
body: req.body, |
||||
}); |
||||
} |
||||
|
||||
@Get(['/api/v1/tables/:modelId/rows/:rowId']) |
||||
@Acl('dataRead') |
||||
async dataRead( |
||||
@Request() req, |
||||
@Param('modelId') modelId: string, |
||||
@Query('viewId') viewId: string, |
||||
@Param('rowId') rowId: string, |
||||
) { |
||||
return await this.dataTableService.dataRead({ |
||||
modelId, |
||||
rowId: rowId, |
||||
query: req.query, |
||||
viewId, |
||||
}); |
||||
} |
||||
|
||||
@Get(['/api/v1/tables/:modelId/links/:columnId/rows/: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/rows/: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/rows/: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, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,39 @@
|
||||
const config = { |
||||
limitDefault: Math.max(+process.env.DB_QUERY_LIMIT_DEFAULT || 25, 1), |
||||
limitMin: Math.max(+process.env.DB_QUERY_LIMIT_MIN || 1, 1), |
||||
limitMax: Math.max(+process.env.DB_QUERY_LIMIT_MAX || 1000, 1), |
||||
}; |
||||
|
||||
export function extractLimitAndOffset( |
||||
args: { |
||||
limit?: number | string; |
||||
offset?: number | string; |
||||
l?: number | string; |
||||
o?: number | string; |
||||
} = {}, |
||||
) { |
||||
const obj: { |
||||
limit?: number; |
||||
offset?: number; |
||||
} = {}; |
||||
|
||||
// use default value if invalid limit
|
||||
// for example, if limit is not a number, it will be ignored
|
||||
// if limit is less than 1, it will be ignored
|
||||
const limit = +(args.limit || args.l); |
||||
obj.limit = Math.max( |
||||
Math.min( |
||||
limit && limit > 0 && Number.isInteger(limit) |
||||
? limit |
||||
: config.limitDefault, |
||||
config.limitMax, |
||||
), |
||||
config.limitMin, |
||||
); |
||||
|
||||
// skip any invalid offset, ignore negative and non-integer values
|
||||
const offset = +(args.offset || args.o) || 0; |
||||
obj.offset = Math.max(Number.isInteger(offset) ? offset : 0, 0); |
||||
|
||||
return obj; |
||||
} |
@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'; |
||||
import { DataTableService } from './data-table.service'; |
||||
|
||||
describe('DataTableService', () => { |
||||
let service: DataTableService; |
||||
|
||||
beforeEach(async () => { |
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
providers: [DataTableService], |
||||
}).compile(); |
||||
|
||||
service = module.get<DataTableService>(DataTableService); |
||||
}); |
||||
|
||||
it('should be defined', () => { |
||||
expect(service).toBeDefined(); |
||||
}); |
||||
}); |
@ -0,0 +1,434 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
import { RelationTypes, UITypes } from 'nocodb-sdk'; |
||||
import { nocoExecute } from 'nc-help'; |
||||
import { NcError } from '../helpers/catchError'; |
||||
import getAst from '../helpers/getAst'; |
||||
import { PagedResponseImpl } from '../helpers/PagedResponse'; |
||||
import { Base, Column, Model, View } from '../models'; |
||||
import NcConnectionMgrv2 from '../utils/common/NcConnectionMgrv2'; |
||||
import { DatasService } from './datas.service'; |
||||
import type { LinkToAnotherRecordColumn } from '../models'; |
||||
|
||||
@Injectable() |
||||
export class DataTableService { |
||||
constructor(private datasService: DatasService) {} |
||||
|
||||
async dataList(param: { |
||||
projectId?: string; |
||||
modelId: string; |
||||
query: any; |
||||
viewId?: string; |
||||
}) { |
||||
const { model, view } = await this.getModelAndView(param); |
||||
|
||||
return await this.datasService.getDataList({ |
||||
model, |
||||
view, |
||||
query: param.query, |
||||
}); |
||||
} |
||||
|
||||
async dataRead(param: { |
||||
projectId?: string; |
||||
modelId: string; |
||||
rowId: string; |
||||
viewId?: string; |
||||
query: any; |
||||
}) { |
||||
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 row = await baseModel.readByPk(param.rowId, false, param.query); |
||||
|
||||
if (!row) { |
||||
NcError.notFound('Row not found'); |
||||
} |
||||
|
||||
return row; |
||||
} |
||||
|
||||
async dataInsert(param: { |
||||
projectId?: string; |
||||
viewId?: string; |
||||
modelId: string; |
||||
body: any; |
||||
cookie: any; |
||||
}) { |
||||
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), |
||||
}); |
||||
|
||||
// if array then do bulk insert
|
||||
const result = await baseModel.bulkInsert( |
||||
Array.isArray(param.body) ? param.body : [param.body], |
||||
{ cookie: param.cookie, insertOneByOneAsFallback: true }, |
||||
); |
||||
|
||||
return Array.isArray(param.body) ? result : result[0]; |
||||
} |
||||
|
||||
async dataUpdate(param: { |
||||
projectId?: string; |
||||
modelId: string; |
||||
viewId?: string; |
||||
// rowId: string;
|
||||
body: any; |
||||
cookie: any; |
||||
}) { |
||||
const { model, view } = await this.getModelAndView(param); |
||||
|
||||
await this.checkForDuplicateRow({ rows: param.body, model }); |
||||
|
||||
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 res = await baseModel.bulkUpdate( |
||||
Array.isArray(param.body) ? param.body : [param.body], |
||||
{ cookie: param.cookie, throwExceptionIfNotExist: true }, |
||||
); |
||||
|
||||
return this.extractIdObj({ body: param.body, model }); |
||||
} |
||||
|
||||
async dataDelete(param: { |
||||
projectId?: string; |
||||
modelId: string; |
||||
viewId?: string; |
||||
// rowId: string;
|
||||
cookie: any; |
||||
body: any; |
||||
}) { |
||||
const { model, view } = await this.getModelAndView(param); |
||||
|
||||
await this.checkForDuplicateRow({ rows: param.body, model }); |
||||
|
||||
const base = await Base.get(model.base_id); |
||||
const baseModel = await Model.getBaseModelSQL({ |
||||
id: model.id, |
||||
viewId: view?.id, |
||||
dbDriver: await NcConnectionMgrv2.get(base), |
||||
}); |
||||
|
||||
await baseModel.bulkDelete( |
||||
Array.isArray(param.body) ? param.body : [param.body], |
||||
{ cookie: param.cookie, throwExceptionIfNotExist: true }, |
||||
); |
||||
|
||||
return this.extractIdObj({ body: param.body, model }); |
||||
} |
||||
|
||||
async dataCount(param: { |
||||
projectId?: string; |
||||
viewId?: string; |
||||
modelId: string; |
||||
query: any; |
||||
}) { |
||||
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 countArgs: any = { ...param.query }; |
||||
try { |
||||
countArgs.filterArr = JSON.parse(countArgs.filterArrJson); |
||||
} catch (e) {} |
||||
|
||||
const count: number = await baseModel.count(countArgs); |
||||
|
||||
return { count }; |
||||
} |
||||
|
||||
private async getModelAndView(param: { |
||||
projectId?: string; |
||||
viewId?: string; |
||||
modelId: string; |
||||
}) { |
||||
const model = await Model.get(param.modelId); |
||||
|
||||
if (!model) { |
||||
NcError.notFound(`Table with id '${param.modelId}' not found`); |
||||
} |
||||
|
||||
if (param.projectId && model.project_id !== param.projectId) { |
||||
throw new Error('Table not belong to project'); |
||||
} |
||||
|
||||
let view: View; |
||||
|
||||
if (param.viewId) { |
||||
view = await View.get(param.viewId); |
||||
if (!view || (view.fk_model_id && view.fk_model_id !== param.modelId)) { |
||||
NcError.unprocessableEntity(`View with id '${param.viewId}' not found`); |
||||
} |
||||
} |
||||
|
||||
return { model, view }; |
||||
} |
||||
|
||||
private async extractIdObj({ |
||||
model, |
||||
body, |
||||
}: { |
||||
body: Record<string, any> | Record<string, any>[]; |
||||
model: Model; |
||||
}) { |
||||
const pkColumns = await model |
||||
.getColumns() |
||||
.then((cols) => cols.filter((col) => col.pk)); |
||||
|
||||
const result = (Array.isArray(body) ? body : [body]).map((row) => { |
||||
return pkColumns.reduce((acc, col) => { |
||||
acc[col.title] = row[col.title]; |
||||
return acc; |
||||
}, {}); |
||||
}); |
||||
|
||||
return Array.isArray(body) ? result : result[0]; |
||||
} |
||||
|
||||
private async checkForDuplicateRow({ |
||||
rows, |
||||
model, |
||||
}: { |
||||
rows: any[] | any; |
||||
model: Model; |
||||
}) { |
||||
if (!rows || !Array.isArray(rows) || rows.length === 1) { |
||||
return; |
||||
} |
||||
|
||||
await model.getColumns(); |
||||
|
||||
const keys = new Set(); |
||||
|
||||
for (const row of rows) { |
||||
let pk; |
||||
// if only one primary key then extract the value
|
||||
if (model.primaryKeys.length === 1) pk = row[model.primaryKey.title]; |
||||
// if composite primary key then join the values with ___
|
||||
else pk = model.primaryKeys.map((pk) => row[pk.title]).join('___'); |
||||
// if duplicate then throw error
|
||||
if (keys.has(pk)) { |
||||
NcError.unprocessableEntity('Duplicate row with id ' + pk); |
||||
} |
||||
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), |
||||
}); |
||||
|
||||
if (!(await baseModel.exist(param.rowId))) { |
||||
NcError.notFound(`Row with id '${param.rowId}' not found`); |
||||
} |
||||
|
||||
const column = await this.getColumn(param); |
||||
|
||||
const colOptions = await column.getColOptions<LinkToAnotherRecordColumn>(); |
||||
|
||||
const relatedModel = await colOptions.getRelatedTable(); |
||||
|
||||
const { ast, dependencyFields } = await getAst({ |
||||
model: relatedModel, |
||||
query: param.query, |
||||
extractOnlyPrimaries: !(param.query?.f || param.query?.fields), |
||||
}); |
||||
|
||||
const listArgs: any = dependencyFields; |
||||
try { |
||||
listArgs.filterArr = JSON.parse(listArgs.filterArrJson); |
||||
} catch (e) {} |
||||
try { |
||||
listArgs.sortArr = JSON.parse(listArgs.sortArrJson); |
||||
} catch (e) {} |
||||
|
||||
let data: any[]; |
||||
let count: number; |
||||
if (colOptions.type === RelationTypes.MANY_TO_MANY) { |
||||
data = await baseModel.mmList( |
||||
{ |
||||
colId: column.id, |
||||
parentId: param.rowId, |
||||
}, |
||||
listArgs as any, |
||||
); |
||||
count = (await baseModel.mmListCount({ |
||||
colId: column.id, |
||||
parentId: param.rowId, |
||||
})) as number; |
||||
} else if (colOptions.type === RelationTypes.HAS_MANY) { |
||||
data = await baseModel.hmList( |
||||
{ |
||||
colId: column.id, |
||||
id: param.rowId, |
||||
}, |
||||
listArgs as any, |
||||
); |
||||
count = (await baseModel.hmListCount({ |
||||
colId: column.id, |
||||
id: param.rowId, |
||||
})) as number; |
||||
} else { |
||||
data = await baseModel.btRead( |
||||
{ |
||||
colId: column.id, |
||||
id: param.rowId, |
||||
}, |
||||
param.query as any, |
||||
); |
||||
} |
||||
|
||||
data = await nocoExecute(ast, data, {}, listArgs); |
||||
|
||||
if (colOptions.type === RelationTypes.BELONGS_TO) return data; |
||||
|
||||
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.notFound(`Column with id '${param.columnId}' 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; |
||||
}) { |
||||
this.validateIds(param.refRowIds); |
||||
|
||||
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); |
||||
|
||||
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; |
||||
}) { |
||||
this.validateIds(param.refRowIds); |
||||
|
||||
const { model, view } = await this.getModelAndView(param); |
||||
if (!model) |
||||
NcError.notFound('Table with id ' + param.modelId + ' 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; |
||||
} |
||||
|
||||
private validateIds(rowIds: any[] | any) { |
||||
if (Array.isArray(rowIds)) { |
||||
const map = new Map<string, boolean>(); |
||||
const set = new Set<string>(); |
||||
for (const rowId of rowIds) { |
||||
if (rowId === undefined || rowId === null) |
||||
NcError.unprocessableEntity('Invalid row id ' + rowId); |
||||
if (map.has(rowId)) { |
||||
set.add(rowId); |
||||
} else { |
||||
map.set(rowId, true); |
||||
} |
||||
} |
||||
|
||||
if (set.size > 0) |
||||
NcError.unprocessableEntity( |
||||
'Child record with id [' + [...set].join(', ') + '] are duplicated', |
||||
); |
||||
} else if (rowIds === undefined || rowIds === null) { |
||||
NcError.unprocessableEntity('Invalid row id ' + rowIds); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue