From 48f7d879273e655ca7cc57c1985b964916921e5e Mon Sep 17 00:00:00 2001 From: Pujal Date: Mon, 3 Feb 2025 15:38:47 -0500 Subject: [PATCH] like named members functionality for PDSs Signed-off-by: Pujal --- .../__unit__/copy/ds/Ds.handler.unit.test.ts | 89 +++++++++++-- .../cli/src/zosfiles/copy/ds/Ds.handler.ts | 15 ++- .../methods/copy/Copy.system.test.ts | 34 +++++ .../__unit__/methods/copy/Copy.unit.test.ts | 117 +++++++++++++++++- .../src/constants/ZosFiles.messages.ts | 1 - packages/zosfiles/src/methods/copy/Copy.ts | 27 ++++ .../methods/copy/doc/ICopyDatasetOptions.ts | 6 + 7 files changed, 277 insertions(+), 12 deletions(-) diff --git a/packages/cli/__tests__/zosfiles/__unit__/copy/ds/Ds.handler.unit.test.ts b/packages/cli/__tests__/zosfiles/__unit__/copy/ds/Ds.handler.unit.test.ts index 2975ad862..a29a4bd79 100644 --- a/packages/cli/__tests__/zosfiles/__unit__/copy/ds/Ds.handler.unit.test.ts +++ b/packages/cli/__tests__/zosfiles/__unit__/copy/ds/Ds.handler.unit.test.ts @@ -50,7 +50,7 @@ describe("DsHandler", () => { }, response: { - console: { promptFn: jest.fn() } + console: { promptFn: jest.fn(), promptForLikeNamedMembers: jest.fn() } } }; @@ -68,7 +68,8 @@ describe("DsHandler", () => { "replace": commandParameters.arguments.replace, "responseTimeout": commandParameters.arguments.responseTimeout, "safeReplace": commandParameters.arguments.safeReplace, - "promptFn": expect.any(Function) + "promptFn": expect.any(Function), + "promptForLikeNamedMembers": expect.any(Function) } ); expect(response).toBe(defaultReturn); @@ -98,7 +99,7 @@ describe("DsHandler", () => { responseTimeout }, response: { - console: { promptFn: jest.fn() } + console: { promptFn: jest.fn(), promptForLikeNamedMembers: jest.fn() } } }; @@ -116,7 +117,8 @@ describe("DsHandler", () => { "replace": commandParameters.arguments.replace, "responseTimeout": commandParameters.arguments.responseTimeout, "safeReplace": commandParameters.arguments.safeReplace, - "promptFn": expect.any(Function) + "promptFn": expect.any(Function), + "promptForLikeNamedMembers": expect.any(Function) } ); expect(response).toBe(defaultReturn); @@ -162,7 +164,8 @@ describe("DsHandler", () => { "replace": commandParameters.arguments.replace, "responseTimeout": commandParameters.arguments.responseTimeout, "safeReplace": commandParameters.arguments.safeReplace, - "promptFn": expect.any(Function) + "promptFn": expect.any(Function), + "promptForLikeNamedMembers": expect.any(Function) } ); expect(response).toBe(defaultReturn); @@ -198,7 +201,7 @@ describe("DsHandler", () => { const result = await promptFn(commandParameters.arguments.toDataSetName); expect(promptMock).toHaveBeenCalledWith( - `The dataset '${toDataSetName}' exists on the target system. This copy will result in data loss.` + + `The dataset '${toDataSetName}' exists on the target system. This copy may result in data loss.` + ` Are you sure you want to continue? [y/N]: ` ); expect(result).toBe(true); @@ -234,7 +237,79 @@ describe("DsHandler", () => { const result = await promptFn(commandParameters.arguments.toDataSetName); expect(promptMock).toHaveBeenCalledWith( - `The dataset '${toDataSetName}' exists on the target system. This copy will result in data loss.` + + `The dataset '${toDataSetName}' exists on the target system. This copy may result in data loss.` + + ` Are you sure you want to continue? [y/N]: ` + ); + expect(result).toBe(false); + }); + it("should prompt the user about duplicate member names and return true when input is 'y", async () => { + const handler = new DsHandler(); + + expect(handler).toBeInstanceOf(ZosFilesBaseHandler); + const fromDataSetName = "ABCD"; + const toDataSetName = "EFGH"; + const enq = "SHR"; + const replace = false; + const safeReplace = false; + const responseTimeout: any = undefined; + + const commandParameters: any = { + arguments: { + fromDataSetName, + toDataSetName, + enq, + replace, + safeReplace, + responseTimeout + }, + response: { + console: { promptFn: jest.fn() } + } + }; + const promptMock = jest.fn(); + promptMock.mockResolvedValue("y"); + + const promptForDuplicates = (handler as any)["promptForLikeNamedMembers"]({ prompt: promptMock }); + const result = await promptForDuplicates(); + + expect(promptMock).toHaveBeenCalledWith( + `The source and target data sets have like named member names. The contents of those members will be overwritten.` + + ` Are you sure you want to continue? [y/N]: ` + ); + expect(result).toBe(true); + }); + it("should prompt the user about duplicate member names and return false when input is 'N'", async () => { + const handler = new DsHandler(); + + expect(handler).toBeInstanceOf(ZosFilesBaseHandler); + const fromDataSetName = "ABCD"; + const toDataSetName = "EFGH"; + const enq = "SHR"; + const replace = false; + const safeReplace = false; + const responseTimeout: any = undefined; + + const commandParameters: any = { + arguments: { + fromDataSetName, + toDataSetName, + enq, + replace, + safeReplace, + responseTimeout + }, + response: { + console: { promptFn: jest.fn() } + } + }; + const promptMock = jest.fn(); + promptMock.mockResolvedValue("N"); + + const promptForDuplicates = (handler as any)["promptForLikeNamedMembers"]({ prompt: promptMock }); + const result = await promptForDuplicates(); + + expect(promptMock).toHaveBeenCalledWith( + `The source and target data sets have like named member names. The contents of those members will be overwritten.` + ` Are you sure you want to continue? [y/N]: ` ); expect(result).toBe(false); diff --git a/packages/cli/src/zosfiles/copy/ds/Ds.handler.ts b/packages/cli/src/zosfiles/copy/ds/Ds.handler.ts index 40913a7d5..2f1666839 100644 --- a/packages/cli/src/zosfiles/copy/ds/Ds.handler.ts +++ b/packages/cli/src/zosfiles/copy/ds/Ds.handler.ts @@ -26,7 +26,8 @@ export default class DsHandler extends ZosFilesBaseHandler { replace: commandParameters.arguments.replace, responseTimeout: commandParameters.arguments.responseTimeout, safeReplace: commandParameters.arguments.safeReplace, - promptFn: this.promptForSafeReplace(commandParameters.response.console) + promptFn: this.promptForSafeReplace(commandParameters.response.console), + promptForLikeNamedMembers: this.promptForLikeNamedMembers(commandParameters.response.console) }; return Copy.dataSet(session, toDataSet, options); @@ -35,10 +36,20 @@ export default class DsHandler extends ZosFilesBaseHandler { private promptForSafeReplace(console: IHandlerResponseConsoleApi) { return async (targetDSN: string) => { const answer: string = await console.prompt( - `The dataset '${targetDSN}' exists on the target system. This copy will result in data loss.` + + `The dataset '${targetDSN}' exists on the target system. This copy may result in data loss.` + ` Are you sure you want to continue? [y/N]: ` ); return answer != null && (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); }; } + + private promptForLikeNamedMembers(console: IHandlerResponseConsoleApi) { + return async() => { + const answer: string = await console.prompt ( + `The source and target data sets have like named member names. The contents of those members will be overwritten.` + + ` Are you sure you want to continue? [y/N]: ` + ) + return answer != null && (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }; + } } diff --git a/packages/zosfiles/__tests__/__system__/methods/copy/Copy.system.test.ts b/packages/zosfiles/__tests__/__system__/methods/copy/Copy.system.test.ts index 859457672..ca3a78cae 100644 --- a/packages/zosfiles/__tests__/__system__/methods/copy/Copy.system.test.ts +++ b/packages/zosfiles/__tests__/__system__/methods/copy/Copy.system.test.ts @@ -598,6 +598,40 @@ describe("Copy", () => { }); }); + describe("hasLikeNamedMembers", () => { + beforeEach(async () => { + try { + await Create.dataSet(REAL_SESSION, CreateDataSetTypeEnum.DATA_SET_PARTITIONED, fromDataSetName); + await Create.dataSet(REAL_SESSION, CreateDataSetTypeEnum.DATA_SET_PARTITIONED, toDataSetName); + await Upload.fileToDataset(REAL_SESSION, fileLocation, fromDataSetName); + await Upload.fileToDataset(REAL_SESSION, fileLocation, toDataSetName); + } + catch (err) { + Imperative.console.info(`Error: ${inspect(err)}`); + } + }); + afterEach(async () => { + try { + await Delete.dataSet(REAL_SESSION, fromDataSetName); + await Delete.dataSet(REAL_SESSION, toDataSetName); + } catch (err) { + Imperative.console.info(`Error: ${inspect(err)}`); + } + }); + it("should return true if the source and target data sets have like-named members", async () => { + const response = await Copy["hasLikeNamedMembers"](REAL_SESSION, fromDataSetName, toDataSetName); + expect(response).toBe(true); + }); + + it("should return false if the source and target data sets do not have like-named members", async () => { + await Delete.dataSet(REAL_SESSION, toDataSetName); + await Create.dataSet(REAL_SESSION, CreateDataSetTypeEnum.DATA_SET_PARTITIONED, toDataSetName); + + const response = await Copy["hasLikeNamedMembers"](REAL_SESSION, fromDataSetName, toDataSetName); + expect(response).toBe(false); + }); + }); + describe("Data Set Cross LPAR", () => { describe("Common Failures", () => { it("should fail if no fromDataSet data set name is supplied", async () => { diff --git a/packages/zosfiles/__tests__/__unit__/methods/copy/Copy.unit.test.ts b/packages/zosfiles/__tests__/__unit__/methods/copy/Copy.unit.test.ts index 2ecad85e7..0772a1141 100644 --- a/packages/zosfiles/__tests__/__unit__/methods/copy/Copy.unit.test.ts +++ b/packages/zosfiles/__tests__/__unit__/methods/copy/Copy.unit.test.ts @@ -35,15 +35,18 @@ describe("Copy", () => { const toDataSetName = "USER.DATA.TO"; const toMemberName = "mem2"; const isPDSSpy = jest.spyOn(Copy as any, "isPDS"); + const hasLikeNamedMembers = jest.spyOn(Copy as any, "hasLikeNamedMembers"); let dataSetExistsSpy: jest.SpyInstance; const promptFn = jest.fn(); + const promptForLikeNamedMembers = jest.fn(); beforeEach(() => { copyPDSSpy.mockClear(); copyExpectStringSpy.mockClear().mockImplementation(async () => { return ""; }); isPDSSpy.mockClear().mockResolvedValue(false); dataSetExistsSpy = jest.spyOn(Copy as any, "dataSetExists").mockResolvedValue(true); - + hasLikeNamedMembers.mockClear().mockResolvedValue(false); + promptForLikeNamedMembers.mockClear(); }); afterAll(() => { isPDSSpy.mockRestore(); @@ -619,6 +622,48 @@ describe("Copy", () => { commandResponse: ZosFilesMessages.datasetCopiedSuccessfully.message }); }); + it("should display a prompt for like named members if there are duplicate member names and --safe-replace and --replace flags are not used", async () => { + hasLikeNamedMembers.mockResolvedValue(true); + promptForLikeNamedMembers.mockClear().mockResolvedValue(true); + + const response = await Copy.dataSet( + dummySession, + { dsn: toDataSetName }, + { "from-dataset": { dsn: fromDataSetName }, + safeReplace: false, + replace: false, + promptForLikeNamedMembers } + ); + expect(promptForLikeNamedMembers).toHaveBeenCalledWith(); + + }) + it("should not display a prompt for like named members if there are no duplicate member names", async () => { + const response = await Copy.dataSet( + dummySession, + { dsn: toDataSetName }, + { "from-dataset": { dsn: fromDataSetName }, + safeReplace: false, + replace: false, + promptForLikeNamedMembers } + ); + + expect(promptForLikeNamedMembers).not.toHaveBeenCalled(); + }); + it("should throw error if user declines to replace the dataset", async () => { + hasLikeNamedMembers.mockResolvedValue(true); + promptForLikeNamedMembers.mockClear().mockResolvedValue(false); + + await expect(Copy.dataSet( + dummySession, + { dsn: toDataSetName }, + { "from-dataset": { dsn: fromDataSetName }, + safeReplace: false, + replace: false, + promptForLikeNamedMembers } + )).rejects.toThrow(new ImperativeError({ msg: ZosFilesMessages.datasetCopiedAborted.message })); + + expect(promptForLikeNamedMembers).toHaveBeenCalled(); + }); }); it("should return early if the source and target data sets are identical", async () => { const response = await Copy.dataSet( @@ -711,7 +756,7 @@ describe("Copy", () => { }); }); - describe("Copy Partitioned Data Set", () => { + describe("Partitioned Data Set", () => { const listAllMembersSpy = jest.spyOn(List, "allMembers"); const downloadAllMembersSpy = jest.spyOn(Download, "allMembers"); const uploadSpy = jest.spyOn(Upload, "streamToDataSet"); @@ -722,6 +767,11 @@ describe("Copy", () => { const readStream = jest.spyOn(IO, "createReadStream"); const rmSync = jest.spyOn(fs, "rmSync"); const listDatasetSpy = jest.spyOn(List, "dataSet"); + const hasLikeNamedMembers = jest.spyOn(Copy as any, "hasLikeNamedMembers"); + + beforeEach(() => { + hasLikeNamedMembers.mockRestore(); + }); const dsPO = { dsname: fromDataSetName, @@ -849,6 +899,69 @@ describe("Copy", () => { commandResponse: ZosFilesMessages.datasetCopiedSuccessfully.message, }); }); + + describe("hasLikeNamedMembers", () => { + const listAllMembersSpy = jest.spyOn(List, "allMembers"); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it("should return true if the source and target have like-named members", async () => { + listAllMembersSpy.mockImplementation(async (session, dsName): Promise => { + if (dsName === fromDataSetName) { + return { + apiResponse: { + items: [ + { member: "mem1" }, + { member: "mem2" }, + ] + } + }; + } else if (dsName === toDataSetName) { + return { + apiResponse: { + items: [{ member: "mem1" }] + } + }; + } + }); + + const response = await Copy["hasLikeNamedMembers"](dummySession, fromDataSetName, toDataSetName); + expect(response).toBe(true); + expect(listAllMembersSpy).toHaveBeenCalledWith(dummySession, fromDataSetName); + expect(listAllMembersSpy).toHaveBeenCalledWith(dummySession, toDataSetName); + }); + it("should return false if the source and target do not have like-named members", async () => { + const sourceResponse = { + apiResponse: { + items: [ + { member: "mem1" }, + { member: "mem2" }, + ] + } + }; + const targetResponse = { + apiResponse: { + items: [ + { member: "mem3" }, + ] + } + }; + listAllMembersSpy.mockImplementation(async (session, dsName): Promise => { + if (dsName === fromDataSetName) { + return sourceResponse; + } else if (dsName === toDataSetName) { + return targetResponse; + } + }); + + const response = await Copy["hasLikeNamedMembers"](dummySession, fromDataSetName, toDataSetName); + + expect(response).toBe(false); + expect(listAllMembersSpy).toHaveBeenCalledWith(dummySession, fromDataSetName); + expect(listAllMembersSpy).toHaveBeenCalledWith(dummySession, toDataSetName); + }); + }); }); describe("Data Set Cross LPAR", () => { diff --git a/packages/zosfiles/src/constants/ZosFiles.messages.ts b/packages/zosfiles/src/constants/ZosFiles.messages.ts index 9c4b9d8e7..f14887729 100644 --- a/packages/zosfiles/src/constants/ZosFiles.messages.ts +++ b/packages/zosfiles/src/constants/ZosFiles.messages.ts @@ -189,7 +189,6 @@ export const ZosFilesMessages: { [key: string]: IMessageDefinition } = { message: "Member(s) downloaded successfully." }, - /** * Message indicating that the member was downloaded successfully * @type {IMessageDefinition} diff --git a/packages/zosfiles/src/methods/copy/Copy.ts b/packages/zosfiles/src/methods/copy/Copy.ts index 2894cc5c4..8ecfdd03f 100644 --- a/packages/zosfiles/src/methods/copy/Copy.ts +++ b/packages/zosfiles/src/methods/copy/Copy.ts @@ -31,6 +31,7 @@ import { ZosFilesUtils } from "../../utils/ZosFilesUtils"; import { tmpdir } from "os"; import path = require("path"); import * as util from "util"; +import { has } from "lodash"; /** * This class holds helper functions that are used to copy the contents of datasets through the * z/OSMF APIs. @@ -58,6 +59,7 @@ export class Copy { ImperativeExpect.toBeDefinedAndNonBlank(options["from-dataset"].dsn, "fromDataSetName"); ImperativeExpect.toBeDefinedAndNonBlank(toDataSetName, "toDataSetName"); const safeReplace: boolean = options.safeReplace; + const overwriteMembers: boolean = options.replace; if(options["from-dataset"].dsn === toDataSetName && toMemberName === options["from-dataset"].member) { return { @@ -84,7 +86,16 @@ export class Copy { if(!toMemberName && !options["from-dataset"].member) { const sourceIsPds = await this.isPDS(session, options["from-dataset"].dsn); const targetIsPds = await this.isPDS(session, toDataSetName); + if (sourceIsPds && targetIsPds) { + const hasLikeNamedMembers = await this.hasLikeNamedMembers(session, options["from-dataset"].dsn, toDataSetName); + if(!safeReplace && hasLikeNamedMembers && !overwriteMembers) { + const userResponse = await options.promptForLikeNamedMembers(); + + if(!userResponse) { + throw new ImperativeError({ msg: ZosFilesMessages.datasetCopiedAborted.message }); + } + } const response = await this.copyPDS(session, options["from-dataset"].dsn, toDataSetName); return { success: true, @@ -170,6 +181,22 @@ export class Copy { return dsnameIndex !== -1; } + /** + * Function that checks if source and target data sets have like-named members + */ + private static async hasLikeNamedMembers ( + session: AbstractSession, + fromPds: string, + toPds: string + ): Promise { + const sourceResponse = await List.allMembers(session, fromPds); + const sourceMemberList = sourceResponse.apiResponse.items.map((item: { member: any; }) => item.member); + const targetResponse = await List.allMembers(session, toPds); + const targetMemberList = targetResponse.apiResponse.items.map((item: { member: any; }) => item.member); + + return sourceMemberList.some((mem: any) => targetMemberList.includes(mem)); + } + /** * Copy the members of a Partitioned dataset into another Partitioned dataset * diff --git a/packages/zosfiles/src/methods/copy/doc/ICopyDatasetOptions.ts b/packages/zosfiles/src/methods/copy/doc/ICopyDatasetOptions.ts index 23d75b796..948f69bfb 100644 --- a/packages/zosfiles/src/methods/copy/doc/ICopyDatasetOptions.ts +++ b/packages/zosfiles/src/methods/copy/doc/ICopyDatasetOptions.ts @@ -47,4 +47,10 @@ export interface ICopyDatasetOptions extends IZosFilesOptions { * @returns True if target data set should be overwritten */ promptFn?: (targetDSN: string) => Promise; + + /** + * Prompt for duplicates + * @returns True if target data set members + */ + promptForLikeNamedMembers?: () => Promise; }