From d7f4d090f004c46c29cb7e05b3c155cdc124277d Mon Sep 17 00:00:00 2001 From: Alexandr Yushkov Date: Wed, 1 Nov 2023 12:15:13 +0100 Subject: [PATCH] Add a feature to copy entries to workbooks (#9) --- src/components/middlewares/decode-id.ts | 10 +- src/const/common.ts | 1 + src/controllers/entries.ts | 16 ++ src/routes.ts | 6 + .../entry/actions/copy-to-workbook.ts | 53 ++++++- .../new/entry/copy-entries-to-workbook.ts | 78 +++++++++ src/services/new/entry/index.ts | 1 + .../new/entry/utils/resolveNameCollisions.ts | 59 +++++++ src/services/new/workbook/copy-workbook.ts | 2 +- src/tests/int/common/entries/copy.test.ts | 150 ++++++++++++++++++ src/tests/int/opensource/entries/copy.test.ts | 1 + 11 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 src/services/new/entry/copy-entries-to-workbook.ts create mode 100644 src/services/new/entry/utils/resolveNameCollisions.ts create mode 100644 src/tests/int/common/entries/copy.test.ts create mode 100644 src/tests/int/opensource/entries/copy.test.ts diff --git a/src/components/middlewares/decode-id.ts b/src/components/middlewares/decode-id.ts index fbb620a9..f448255a 100644 --- a/src/components/middlewares/decode-id.ts +++ b/src/components/middlewares/decode-id.ts @@ -23,8 +23,14 @@ export const decodeId = (req: Request, _res: Response, next: NextFunction) => { } if (req.body && req.body[idVariable]) { - const encodedId = req.body[idVariable]; - req.body[idVariable] = Utils.decodeId(encodedId); + const entity = req.body[idVariable] as string | string[]; + + if (Array.isArray(entity)) { + req.body[idVariable] = entity.map((encodedId) => Utils.decodeId(encodedId)); + } else { + const encodedId = req.body[idVariable]; + req.body[idVariable] = Utils.decodeId(encodedId); + } } } } catch { diff --git a/src/const/common.ts b/src/const/common.ts index fe8d246f..20a3510c 100644 --- a/src/const/common.ts +++ b/src/const/common.ts @@ -120,6 +120,7 @@ export const ALLOWED_SCOPE_VALUES = [ export const ID_VARIABLES = [ 'ids', 'entryId', + 'entryIds', 'oldEntryId', 'revId', 'draftId', diff --git a/src/controllers/entries.ts b/src/controllers/entries.ts index e1e26a58..19d58d69 100644 --- a/src/controllers/entries.ts +++ b/src/controllers/entries.ts @@ -21,6 +21,7 @@ import { getEntryMetaPrivate, GetEntryMetaPrivateArgs, copyEntryToWorkbook, + copyEntriesToWorkbook, } from '../services/new/entry'; import { formatGetEntryResponse, @@ -241,6 +242,21 @@ export default { res.status(code).send(response); }, + copyEntriesToWorkbook: async (req: Request, res: Response) => { + const {body} = req; + + const result = await copyEntriesToWorkbook( + {ctx: req.ctx}, + { + entryIds: body.entryIds, + workbookId: body.workbookId, + }, + ); + + const {code, response} = prepareResponse({data: result}); + res.status(code).send(response); + }, + getEntries: async (req: Request, res: Response) => { const query = req.query as unknown as ST.GetEntries; diff --git a/src/routes.ts b/src/routes.ts index d3155a37..f8a16b2c 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -400,6 +400,12 @@ export function getRoutes(nodekit: NodeKit, options: GetRoutesOptions) { handler: entriesController.copyEntryToWorkbook, write: true, }), + + copyEntriesToWorkbook: makeRoute({ + route: 'POST /v2/copy-entries', + handler: entriesController.copyEntriesToWorkbook, + write: true, + }), }; } diff --git a/src/services/entry/actions/copy-to-workbook.ts b/src/services/entry/actions/copy-to-workbook.ts index 0e7c10a8..78abab82 100644 --- a/src/services/entry/actions/copy-to-workbook.ts +++ b/src/services/entry/actions/copy-to-workbook.ts @@ -2,7 +2,7 @@ import {TransactionOrKnex} from 'objection'; import {AppError} from '@gravity-ui/nodekit'; import {getId} from '../../../db'; -import {Entry} from '../../../db/models/new/entry'; +import {Entry, EntryColumn, EntryScope, EntryType} from '../../../db/models/new/entry'; import {JoinedEntryRevision} from '../../../db/presentations/joined-entry-revision'; import {WorkbookModel} from '../../../db/models/new/workbook'; import {CTX} from '../../../types/models'; @@ -15,6 +15,7 @@ import {getParentIds} from '../../new/collection/utils/get-parents'; import Link from '../../../db/models/links'; import {makeSchemaValidator} from '../../../components/validation-schema-compiler'; +import {resolveEntriesNameCollisions} from '../../new/entry/utils/resolveNameCollisions'; interface Params { entryIds: Entry['entryId'][]; @@ -23,6 +24,7 @@ interface Params { trxOverride?: TransactionOrKnex; skipLinkSync?: boolean; skipWorkbookPermissionsCheck?: boolean; + resolveNameCollisions?: boolean; } export const validateParams = makeSchemaValidator({ @@ -45,9 +47,14 @@ export const validateParams = makeSchemaValidator({ skipWorkbookPermissionsCheck: { type: 'boolean', }, + resolveNameCollisions: { + type: 'boolean', + }, }, }); +const fileConnectionTypes: string[] = [EntryType.File, EntryType.GsheetsV2]; + export const copyToWorkbook = async (ctx: CTX, params: Params) => { const { entryIds, @@ -56,6 +63,7 @@ export const copyToWorkbook = async (ctx: CTX, params: Params) => { trxOverride, skipLinkSync, skipWorkbookPermissionsCheck = false, + resolveNameCollisions, } = params; logInfo(ctx, 'COPY_ENTRY_TO_WORKBOOK_CALL', { @@ -118,6 +126,21 @@ export const copyToWorkbook = async (ctx: CTX, params: Params) => { ); } + const isFileConnection = + joinedEntryRevision.scope === EntryScope.Connection && + fileConnectionTypes.includes(joinedEntryRevision.type); + + if (isFileConnection) { + throw new AppError( + `Entry ${Utils.encodeId( + joinedEntryRevision.entryId, + )} is a file connection and cannot be copied to a workbook.`, + { + code: US_ERRORS.WORKBOOK_COPY_FILE_CONNECTION_ERROR, + }, + ); + } + if (workbookId === undefined) { workbookId = joinedEntryRevision.workbookId; } else if (joinedEntryRevision.workbookId !== workbookId) { @@ -138,6 +161,25 @@ export const copyToWorkbook = async (ctx: CTX, params: Params) => { }); } + let entryNamesOverride = new Map(); + + if (resolveNameCollisions) { + const targetWorkbookEntries = await Entry.query(Entry.replica) + .select() + .where(EntryColumn.WorkbookId, destinationWorkbookId) + .andWhere({ + [EntryColumn.TenantId]: tenantId, + [EntryColumn.IsDeleted]: false, + }) + .orderBy(EntryColumn.SortName, 'asc') + .timeout(Entry.DEFAULT_QUERY_TIMEOUT); + + entryNamesOverride = resolveEntriesNameCollisions({ + existingEntries: targetWorkbookEntries, + addingEntries: originJoinedEntryRevisions, + }); + } + const mapEntryIdsWithOldIds = new Map(); const newEntries = await Promise.all( @@ -146,9 +188,12 @@ export const copyToWorkbook = async (ctx: CTX, params: Params) => { mapEntryIdsWithOldIds.set(newEntryId, originJoinedEntryRevision.entryId); - const displayKey = `${newEntryId}/${Utils.getNameByKey({ - key: originJoinedEntryRevision.displayKey, - })}`; + const name = + entryNamesOverride?.get(originJoinedEntryRevision.entryId) ?? + Utils.getNameByKey({ + key: originJoinedEntryRevision.displayKey, + }); + const displayKey = `${newEntryId}/${name}`; const key = displayKey.toLowerCase(); const links = originJoinedEntryRevision.links as Nullable>; diff --git a/src/services/new/entry/copy-entries-to-workbook.ts b/src/services/new/entry/copy-entries-to-workbook.ts new file mode 100644 index 00000000..ceda9d88 --- /dev/null +++ b/src/services/new/entry/copy-entries-to-workbook.ts @@ -0,0 +1,78 @@ +import {ServiceArgs} from '../types'; +import {makeSchemaValidator} from '../../../components/validation-schema-compiler'; +import Utils, {logInfo, makeUserId} from '../../../utils'; +import {crossSyncCopiedJoinedEntryRevisions} from '../workbook'; +import {transaction} from 'objection'; +import {getPrimary} from '../utils'; +import {copyToWorkbook} from '../../entry/actions'; +import {JoinedEntryRevisionColumns} from '../../../db/presentations'; + +export type CopyEntriesToWorkbookParams = { + entryIds: string[]; + workbookId: string; +}; + +const validateArgs = makeSchemaValidator({ + type: 'object', + required: ['entryIds', 'workbookId'], + properties: { + entryIds: { + type: 'array', + items: { + type: 'string', + }, + }, + workbookId: { + type: 'string', + }, + }, +}); + +export const copyEntriesToWorkbook = async ( + {ctx, trx, skipValidation = false, skipCheckPermissions = false}: ServiceArgs, + args: CopyEntriesToWorkbookParams, +) => { + if (!skipValidation) { + validateArgs(args); + } + + const {entryIds, workbookId: targetWorkbookId} = args; + const {user} = ctx.get('info'); + const updatedBy = makeUserId(user.userId); + + logInfo(ctx, 'COPY_ENTRIES_TO_WORKBOOK_START', { + entryIds: entryIds.map((entryId) => Utils.encodeId(entryId)), + workbookId: Utils.encodeId(targetWorkbookId), + copiedBy: updatedBy, + }); + + await transaction(getPrimary(trx), async (transactionTrx) => { + const copiedJoinedEntryRevisions = await copyToWorkbook(ctx, { + entryIds, + destinationWorkbookId: targetWorkbookId, + trxOverride: transactionTrx, + skipLinkSync: true, + skipWorkbookPermissionsCheck: skipCheckPermissions, + resolveNameCollisions: true, + }); + + const filteredCopiedJoinedEntryRevisions = copiedJoinedEntryRevisions.filter( + (item) => item.newJoinedEntryRevision !== undefined, + ) as { + newJoinedEntryRevision: JoinedEntryRevisionColumns; + oldEntryId: string; + }[]; + + await crossSyncCopiedJoinedEntryRevisions({ + copiedJoinedEntryRevisions: filteredCopiedJoinedEntryRevisions, + ctx, + trx: transactionTrx, + }); + }); + + logInfo(ctx, 'COPY_ENTRIES_TO_WORKBOOK_FINISH'); + + return { + workbookId: targetWorkbookId, + }; +}; diff --git a/src/services/new/entry/index.ts b/src/services/new/entry/index.ts index d3c0b0ed..62c37db3 100644 --- a/src/services/new/entry/index.ts +++ b/src/services/new/entry/index.ts @@ -3,3 +3,4 @@ export * from './get-entry-meta'; export * from './get-entry-meta-private'; export * from './get-entry-by-key'; export * from './copy-entry-to-workbook'; +export * from './copy-entries-to-workbook'; diff --git a/src/services/new/entry/utils/resolveNameCollisions.ts b/src/services/new/entry/utils/resolveNameCollisions.ts new file mode 100644 index 00000000..16ec5bd6 --- /dev/null +++ b/src/services/new/entry/utils/resolveNameCollisions.ts @@ -0,0 +1,59 @@ +import Utils from '../../../../utils'; + +export const resolveEntriesNameCollisions = ({ + existingEntries, + addingEntries, +}: { + existingEntries: Array<{entryId: string; displayKey: Nullable}>; + addingEntries: Array<{entryId: string; displayKey: Nullable}>; +}) => { + const mapCollisions = new Map(); + + existingEntries.forEach(({displayKey}) => { + if (displayKey) { + const entryName = Utils.getNameByKey({key: displayKey}); + const entryNameWithoutCopy = Utils.getNameWithoutCopyNumber(entryName); + const preparedEntryName = entryNameWithoutCopy.toLowerCase(); + const copyNameNumber = Utils.getCopyNumber(entryName); + + if (copyNameNumber > 0) { + mapCollisions.set( + preparedEntryName, + Math.max(mapCollisions.get(preparedEntryName) ?? 0, copyNameNumber) + 1, + ); + } else { + mapCollisions.set( + preparedEntryName, + (mapCollisions.get(preparedEntryName) ?? 0) + 1, + ); + } + } + }); + + const addingNames = new Map(); + + addingEntries.forEach((entry) => { + let collisionCount: number; + + const {entryId, displayKey} = entry; + + const entryName = Utils.getNameByKey({key: displayKey}); + const copyNameNumber = Utils.getCopyNumber(entryName); + const entryNameWithoutCopy = Utils.getNameWithoutCopyNumber(entryName); + const preparedEntryName = entryNameWithoutCopy.toLowerCase(); + + if (copyNameNumber > 0) { + collisionCount = Math.max(mapCollisions.get(preparedEntryName) ?? 0, copyNameNumber); + } else { + collisionCount = mapCollisions.get(preparedEntryName) ?? 0; + } + + const newEntryName = Utils.setCopyNumber(entryNameWithoutCopy, collisionCount); + + mapCollisions.set(preparedEntryName, collisionCount + 1); + + addingNames.set(entryId, newEntryName); + }); + + return addingNames; +}; diff --git a/src/services/new/workbook/copy-workbook.ts b/src/services/new/workbook/copy-workbook.ts index 603d3aea..c2563cba 100644 --- a/src/services/new/workbook/copy-workbook.ts +++ b/src/services/new/workbook/copy-workbook.ts @@ -322,7 +322,7 @@ async function getUniqWorkbookTitle({ return uniqTitle; } -async function crossSyncCopiedJoinedEntryRevisions({ +export async function crossSyncCopiedJoinedEntryRevisions({ copiedJoinedEntryRevisions, ctx, trx, diff --git a/src/tests/int/common/entries/copy.test.ts b/src/tests/int/common/entries/copy.test.ts new file mode 100644 index 00000000..1b638659 --- /dev/null +++ b/src/tests/int/common/entries/copy.test.ts @@ -0,0 +1,150 @@ +import request from 'supertest'; +import usApp from '../../../..'; +import {withScopeHeaders} from '../../utils'; + +const app = usApp.express; + +describe('Copy entries', () => { + let workbookId1: string; + let workbookId2: string; + + let workbookId1EntryId1: string; + let workbookId1EntryId2: string; + + test('Create workbooks - [POST /v2/workbooks]', async () => { + const {body: workbook1Body} = await withScopeHeaders(request(app).post('/v2/workbooks')) + .send({ + title: 'Workbook1', + description: 'Description1', + }) + .expect(200); + + const {body: workbook2Body} = await withScopeHeaders(request(app).post('/v2/workbooks')) + .send({ + title: 'Workbook2', + description: 'Description2', + }) + .expect(200); + + workbookId1 = workbook1Body.workbookId; + workbookId2 = workbook2Body.workbookId; + }); + + test('Create entries - [POST /v1/entries]', async () => { + const {body: entry1Body} = await withScopeHeaders(request(app).post('/v1/entries')) + .send({ + scope: 'dataset', + type: 'graph', + meta: {}, + data: {}, + name: 'EntryName1', + workbookId: workbookId1, + }) + .expect(200); + + const {body: entry2Body} = await withScopeHeaders(request(app).post('/v1/entries')) + .send({ + scope: 'dataset', + type: 'graph', + meta: {}, + data: {}, + name: 'EntryName2', + workbookId: workbookId1, + }) + .expect(200); + + workbookId1EntryId1 = entry1Body.entryId; + workbookId1EntryId2 = entry2Body.entryId; + }); + + test('Copy entries - [POST /v2/copy-entries]', async () => { + const {body} = await withScopeHeaders(request(app).post('/v2/copy-entries')) + .send({ + entryIds: [workbookId1EntryId1, workbookId1EntryId2], + workbookId: workbookId2, + }) + .expect(200); + + expect(body.workbookId).toBe(workbookId2); + + const { + body: {entries}, + } = await withScopeHeaders(request(app).get(`/v2/workbooks/${workbookId2}/entries`)) + .send() + .expect(200); + + expect(entries).toHaveLength(2); + + const entryNames = entries.map((entry: {key: string}) => entry.key.split('/')[1]); + + expect(entryNames).toContain('EntryName1'); + expect(entryNames).toContain('EntryName2'); + }); + + test('Copy entries with duplicate names - [POST /v2/copy-entries]', async () => { + await withScopeHeaders(request(app).post('/v2/copy-entries')) + .send({ + entryIds: [workbookId1EntryId1, workbookId1EntryId2], + workbookId: workbookId2, + }) + .expect(200); + + const { + body: {entries}, + } = await withScopeHeaders(request(app).get(`/v2/workbooks/${workbookId2}/entries`)) + .send() + .expect(200); + + expect(entries).toHaveLength(4); + + const entryNames = entries.map((entry: {key: string}) => entry.key.split('/')[1]); + + expect(entryNames).toContain('EntryName1'); + expect(entryNames).toContain('EntryName2'); + expect(entryNames).toContain('EntryName1 (COPY 1)'); + expect(entryNames).toContain('EntryName2 (COPY 1)'); + }); + + test('Copy entries with incremented duplicate names - [POST /v2/copy-entries]', async () => { + const { + body: {entryId: workbookId1EntryId3}, + } = await withScopeHeaders(request(app).post('/v1/entries')) + .send({ + scope: 'dataset', + type: 'graph', + meta: {}, + data: {}, + name: 'EntryName1 (COPY 1)', + workbookId: workbookId1, + }) + .expect(200); + + await withScopeHeaders(request(app).post('/v2/copy-entries')) + .send({ + entryIds: [workbookId1EntryId3], + workbookId: workbookId2, + }) + .expect(200); + + const { + body: {entries}, + } = await withScopeHeaders(request(app).get(`/v2/workbooks/${workbookId2}/entries`)) + .send() + .expect(200); + + expect(entries).toHaveLength(5); + + const entryNames = entries.map((entry: {key: string}) => entry.key.split('/')[1]); + + expect(entryNames).toContain('EntryName1'); + expect(entryNames).toContain('EntryName2'); + expect(entryNames).toContain('EntryName1 (COPY 1)'); + expect(entryNames).toContain('EntryName2 (COPY 1)'); + expect(entryNames).toContain('EntryName1 (COPY 2)'); + }); + + test('Delete workbooks - [DELETE /v2/workbooks/:workbookId]', async () => { + await withScopeHeaders(request(app).delete(`/v2/workbooks/${workbookId1}`)).expect(200); + await withScopeHeaders(request(app).delete(`/v2/workbooks/${workbookId2}`)).expect(200); + }); +}); diff --git a/src/tests/int/opensource/entries/copy.test.ts b/src/tests/int/opensource/entries/copy.test.ts new file mode 100644 index 00000000..18b55d32 --- /dev/null +++ b/src/tests/int/opensource/entries/copy.test.ts @@ -0,0 +1 @@ +import '../../common/entries/copy.test';