Skip to content

Commit f7bfe35

Browse files
committed
feat: data patch generator
1 parent 0b283f7 commit f7bfe35

File tree

9 files changed

+403
-0
lines changed

9 files changed

+403
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
66

77
## [Unreleased]
88
- Added: Generator command for a ViewModel class
9+
- Added: Generator command for data patches
910
- Added: Jump-to-definition for magento modules (in module.xml and routes.xml)
1011
- Fixed: Method plugin hover messages are now grouped and include a link to di.xml
1112

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@
195195
"title": "Generate Sample config.xml",
196196
"category": "Magento Toolbox"
197197
},
198+
{
199+
"command": "magento-toolbox.generateDataPatch",
200+
"title": "Generate Data Patch",
201+
"category": "Magento Toolbox"
202+
},
198203
{
199204
"command": "magento-toolbox.jumpToModule",
200205
"title": "Jump to Module",
@@ -328,6 +333,10 @@
328333
{
329334
"command": "magento-toolbox.generateConfigXmlFile",
330335
"when": "resourcePath =~ /app\\/code\\/.+\\/.+/i"
336+
},
337+
{
338+
"command": "magento-toolbox.generateDataPatch",
339+
"when": "resourcePath =~ /app\\/code\\/.+\\/.+/i"
331340
}
332341
]
333342
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Command } from 'command/Command';
2+
import DataPatchWizard, { DataPatchWizardData } from 'wizard/DataPatchWizard';
3+
import WizzardClosedError from 'webview/error/WizzardClosedError';
4+
import FileGeneratorManager from 'generator/FileGeneratorManager';
5+
import Common from 'util/Common';
6+
import { Uri, window } from 'vscode';
7+
import DataPatchGenerator from 'generator/dataPatch/DataPatchGenerator';
8+
import IndexManager from 'indexer/IndexManager';
9+
import ModuleIndexer from 'indexer/module/ModuleIndexer';
10+
11+
export default class GenerateDataPatchCommand extends Command {
12+
constructor() {
13+
super('magento-toolbox.generateDataPatch');
14+
}
15+
16+
public async execute(uri?: Uri): Promise<void> {
17+
const moduleIndex = IndexManager.getIndexData(ModuleIndexer.KEY);
18+
let contextModule: string | undefined;
19+
20+
const contextUri = uri || window.activeTextEditor?.document.uri;
21+
22+
if (moduleIndex && contextUri) {
23+
const module = moduleIndex.getModuleByUri(contextUri);
24+
25+
if (module) {
26+
contextModule = module.name;
27+
}
28+
}
29+
30+
const dataPatchWizard = new DataPatchWizard();
31+
32+
let data: DataPatchWizardData;
33+
34+
try {
35+
data = await dataPatchWizard.show(contextModule);
36+
} catch (error) {
37+
if (error instanceof WizzardClosedError) {
38+
return;
39+
}
40+
41+
throw error;
42+
}
43+
44+
const manager = new FileGeneratorManager([new DataPatchGenerator(data)]);
45+
46+
const workspaceFolder = Common.getActiveWorkspaceFolder();
47+
48+
if (!workspaceFolder) {
49+
window.showErrorMessage('No active workspace folder');
50+
return;
51+
}
52+
53+
await manager.generate(workspaceFolder.uri);
54+
await manager.writeFiles();
55+
await manager.refreshIndex(workspaceFolder);
56+
manager.openAllFiles();
57+
}
58+
}

src/command/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export { default as GenerateExtensionAttributesXmlFileCommand } from './Generate
2727
export { default as GenerateSystemXmlFileCommand } from './GenerateSystemXmlFileCommand';
2828
export { default as GenerateConfigXmlFileCommand } from './GenerateConfigXmlFileCommand';
2929
export { default as JumpToModuleCommand } from './JumpToModuleCommand';
30+
export { default as GenerateDataPatchCommand } from './GenerateDataPatchCommand';
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import FileHeader from 'common/php/FileHeader';
2+
import PhpNamespace from 'common/PhpNamespace';
3+
import GeneratedFile from 'generator/GeneratedFile';
4+
import FileGenerator from 'generator/FileGenerator';
5+
import { PhpFile, PsrPrinter } from 'node-php-generator';
6+
import { Uri } from 'vscode';
7+
import { DataPatchWizardData } from 'wizard/DataPatchWizard';
8+
import Magento from 'util/Magento';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
export default class DataPatchGenerator extends FileGenerator {
13+
private static readonly DATA_PATCH_INTERFACE =
14+
'Magento\\Framework\\Setup\\Patch\\DataPatchInterface';
15+
private static readonly PATCH_REVERTABLE_INTERFACE =
16+
'Magento\\Framework\\Setup\\Patch\\PatchRevertableInterface';
17+
18+
public constructor(protected data: DataPatchWizardData) {
19+
super();
20+
}
21+
22+
public async generate(workspaceUri: Uri): Promise<GeneratedFile> {
23+
const [vendor, module] = this.data.module.split('_');
24+
const setupDir = 'Setup/Patch/Data';
25+
const namespaceParts = [vendor, module, 'Setup', 'Patch', 'Data'];
26+
const moduleDirectory = Magento.getModuleDirectory(vendor, module, workspaceUri);
27+
28+
// Create setup directory if it doesn't exist
29+
const setupDirPath = path.join(moduleDirectory.fsPath, setupDir);
30+
if (!fs.existsSync(setupDirPath)) {
31+
fs.mkdirSync(setupDirPath, { recursive: true });
32+
}
33+
34+
const phpFile = new PhpFile();
35+
phpFile.setStrictTypes(true);
36+
37+
const header = FileHeader.getHeader(this.data.module);
38+
39+
if (header) {
40+
phpFile.addComment(header);
41+
}
42+
43+
const namespace = phpFile.addNamespace(PhpNamespace.fromParts(namespaceParts).toString());
44+
namespace.addUse(DataPatchGenerator.DATA_PATCH_INTERFACE);
45+
46+
if (this.data.revertable) {
47+
namespace.addUse(DataPatchGenerator.PATCH_REVERTABLE_INTERFACE);
48+
}
49+
50+
const patchClass = namespace.addClass(this.data.className);
51+
patchClass.addImplement(DataPatchGenerator.DATA_PATCH_INTERFACE);
52+
53+
if (this.data.revertable) {
54+
patchClass.addImplement(DataPatchGenerator.PATCH_REVERTABLE_INTERFACE);
55+
}
56+
57+
// Add apply method
58+
const applyMethod = patchClass.addMethod('apply');
59+
applyMethod.addComment('@inheritdoc');
60+
applyMethod.setReturnType('self');
61+
applyMethod.setBody('// TODO: Implement apply() method.\n\nreturn $this;');
62+
63+
// Add revert method if revertable
64+
if (this.data.revertable) {
65+
const revertMethod = patchClass.addMethod('revert');
66+
revertMethod.addComment('@inheritdoc');
67+
revertMethod.setReturnType('void');
68+
revertMethod.setBody('// TODO: Implement revert() method.');
69+
}
70+
71+
// Add getAliases method
72+
const getAliasesMethod = patchClass.addMethod('getAliases');
73+
getAliasesMethod.addComment('@inheritdoc');
74+
getAliasesMethod.setReturnType('array');
75+
getAliasesMethod.setBody('return [];');
76+
77+
// Add getDependencies method
78+
const getDependenciesMethod = patchClass.addMethod('getDependencies');
79+
getDependenciesMethod.setStatic(true);
80+
getDependenciesMethod.addComment('@inheritdoc');
81+
getDependenciesMethod.setReturnType('array');
82+
getDependenciesMethod.setBody('return [];');
83+
84+
const printer = new PsrPrinter();
85+
86+
return new GeneratedFile(
87+
Uri.joinPath(moduleDirectory, setupDir, `${this.data.className}.php`),
88+
printer.printFile(phpFile)
89+
);
90+
}
91+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { DataPatchWizardData } from 'wizard/DataPatchWizard';
2+
import * as assert from 'assert';
3+
import { Uri } from 'vscode';
4+
import DataPatchGenerator from 'generator/dataPatch/DataPatchGenerator';
5+
import { describe, it, before, afterEach } from 'mocha';
6+
import { setup } from 'test/setup';
7+
import { getReferenceFile, getTestWorkspaceUri } from 'test/util';
8+
import FileHeader from 'common/php/FileHeader';
9+
import sinon from 'sinon';
10+
11+
describe('DataPatchGenerator Tests', () => {
12+
const basePatchWizardData: DataPatchWizardData = {
13+
module: 'Foo_Bar',
14+
className: 'TestDataPatch',
15+
revertable: false,
16+
};
17+
18+
const revertablePatchWizardData: DataPatchWizardData = {
19+
module: 'Foo_Bar',
20+
className: 'TestRevertableDataPatch',
21+
revertable: true,
22+
};
23+
24+
before(async () => {
25+
await setup();
26+
});
27+
28+
afterEach(() => {
29+
sinon.restore();
30+
});
31+
32+
it('should generate a standard data patch file', async () => {
33+
// Mock the FileHeader.getHeader method to return a consistent header
34+
sinon.stub(FileHeader, 'getHeader').returns('Foo_Bar');
35+
// Create the generator with test data
36+
const generator = new DataPatchGenerator(basePatchWizardData);
37+
38+
// Use a test workspace URI
39+
const workspaceUri = getTestWorkspaceUri();
40+
41+
// Generate the file
42+
const generatedFile = await generator.generate(workspaceUri);
43+
44+
// Get the reference file content
45+
const referenceContent = getReferenceFile('generator/dataPatch/patch.php');
46+
47+
// Compare the generated content with reference
48+
assert.strictEqual(generatedFile.content, referenceContent);
49+
});
50+
51+
it('should generate a revertable data patch file', async () => {
52+
// Mock the FileHeader.getHeader method to return a consistent header
53+
sinon.stub(FileHeader, 'getHeader').returns('Foo_Bar');
54+
55+
// Create the generator with test data
56+
const generator = new DataPatchGenerator(revertablePatchWizardData);
57+
58+
// Use a test workspace URI
59+
const workspaceUri = getTestWorkspaceUri();
60+
61+
// Generate the file
62+
const generatedFile = await generator.generate(workspaceUri);
63+
64+
// Get the reference file content
65+
const referenceContent = getReferenceFile('generator/dataPatch/patchRevertable.php');
66+
67+
// Compare the generated content with reference
68+
assert.strictEqual(generatedFile.content, referenceContent);
69+
});
70+
71+
it('should generate file in correct location', async () => {
72+
// Create the generator with test data
73+
const generator = new DataPatchGenerator(basePatchWizardData);
74+
75+
// Use a test workspace URI
76+
const workspaceUri = getTestWorkspaceUri();
77+
78+
// Generate the file
79+
const generatedFile = await generator.generate(workspaceUri);
80+
81+
// Expected path
82+
const expectedPath = Uri.joinPath(
83+
workspaceUri,
84+
'app/code/Foo/Bar/Setup/Patch/Data/TestDataPatch.php'
85+
).fsPath;
86+
87+
assert.strictEqual(generatedFile.uri.fsPath, expectedPath);
88+
});
89+
});

src/wizard/DataPatchWizard.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import IndexManager from 'indexer/IndexManager';
2+
import ModuleIndexer from 'indexer/module/ModuleIndexer';
3+
import { GeneratorWizard } from 'webview/GeneratorWizard';
4+
import { WizardFieldBuilder } from 'webview/WizardFieldBuilder';
5+
import { WizardFormBuilder } from 'webview/WizardFormBuilder';
6+
import { WizardTabBuilder } from 'webview/WizardTabBuilder';
7+
import Validation from 'common/Validation';
8+
9+
export interface DataPatchWizardData {
10+
module: string;
11+
className: string;
12+
revertable: boolean;
13+
}
14+
15+
export default class DataPatchWizard extends GeneratorWizard {
16+
public async show(contextModule?: string): Promise<DataPatchWizardData> {
17+
const moduleIndexData = IndexManager.getIndexData(ModuleIndexer.KEY);
18+
19+
if (!moduleIndexData) {
20+
throw new Error('Module index data not found');
21+
}
22+
23+
const modules = moduleIndexData.getModuleOptions(module => module.location === 'app');
24+
25+
const builder = new WizardFormBuilder();
26+
27+
builder.setTitle('Generate a new Data Patch');
28+
builder.setDescription('Generates a new Data Patch for a module.');
29+
30+
const tab = new WizardTabBuilder();
31+
tab.setId('dataPatch');
32+
tab.setTitle('Data Patch');
33+
34+
tab.addField(
35+
WizardFieldBuilder.select('module', 'Module')
36+
.setDescription(['Module where data patch will be generated in'])
37+
.setOptions(modules)
38+
.setInitialValue(contextModule || modules[0].value)
39+
.build()
40+
);
41+
42+
tab.addField(
43+
WizardFieldBuilder.text('className', 'Class Name')
44+
.setDescription(['The class name for the data patch'])
45+
.setPlaceholder('YourPatchName')
46+
.build()
47+
);
48+
49+
tab.addField(
50+
WizardFieldBuilder.checkbox('revertable', 'Revertable').setInitialValue(false).build()
51+
);
52+
53+
builder.addTab(tab.build());
54+
55+
builder.addValidation('module', 'required');
56+
builder.addValidation('className', [
57+
'required',
58+
`regex:/${Validation.CLASS_NAME_REGEX.source}/`,
59+
]);
60+
61+
const data = await this.openWizard<DataPatchWizardData>(builder.build());
62+
63+
return data;
64+
}
65+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/**
4+
* Foo_Bar
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace Foo\Bar\Setup\Patch\Data;
10+
11+
use Magento\Framework\Setup\Patch\DataPatchInterface;
12+
13+
class TestDataPatch implements DataPatchInterface
14+
{
15+
/**
16+
* @inheritdoc
17+
*/
18+
public function apply(): self
19+
{
20+
// TODO: Implement apply() method.
21+
22+
return $this;
23+
}
24+
25+
/**
26+
* @inheritdoc
27+
*/
28+
public function getAliases(): array
29+
{
30+
return [];
31+
}
32+
33+
/**
34+
* @inheritdoc
35+
*/
36+
public static function getDependencies(): array
37+
{
38+
return [];
39+
}
40+
}

0 commit comments

Comments
 (0)