diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index 5988754b66..6a6b5ced21 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -866,6 +866,34 @@ }, "required": ["options", "step"] }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "runSql", + "description": "The step identifier." + }, + "sql": { + "$ref": "#/definitions/FileReference", + "description": "The SQL to run. Each non-empty line must contain a valid SQL query." + } + }, + "required": ["sql", "step"] + }, { "type": "object", "additionalProperties": false, diff --git a/packages/playground/blueprints/src/lib/steps/handlers.ts b/packages/playground/blueprints/src/lib/steps/handlers.ts index 5881adf3da..0d37c1cb45 100644 --- a/packages/playground/blueprints/src/lib/steps/handlers.ts +++ b/packages/playground/blueprints/src/lib/steps/handlers.ts @@ -3,6 +3,7 @@ export { activateTheme } from './activate-theme'; export { applyWordPressPatches } from './apply-wordpress-patches'; export { runPHP } from './run-php'; export { runPHPWithOptions } from './run-php-with-options'; +export { runSql } from './run-sql'; export { setPhpIniEntry } from './set-php-ini-entry'; export { request } from './request'; export { cp } from './cp'; diff --git a/packages/playground/blueprints/src/lib/steps/index.ts b/packages/playground/blueprints/src/lib/steps/index.ts index d77ae7c02b..341a7eba57 100644 --- a/packages/playground/blueprints/src/lib/steps/index.ts +++ b/packages/playground/blueprints/src/lib/steps/index.ts @@ -15,6 +15,7 @@ import { SetSiteOptionsStep, UpdateUserMetaStep } from './site-data'; import { RmStep } from './rm'; import { CpStep } from './cp'; import { RmdirStep } from './rmdir'; +import { RunSqlStep } from './run-sql'; import { MkdirStep } from './mkdir'; import { MvStep } from './mv'; import { SetPhpIniEntryStep } from './set-php-ini-entry'; @@ -58,6 +59,7 @@ export type GenericStep = | RunPHPStep | RunPHPWithOptionsStep | RunWpInstallationWizardStep + | RunSqlStep | SetPhpIniEntryStep | SetSiteOptionsStep | UnzipStep @@ -85,6 +87,7 @@ export type { RunPHPStep, RunPHPWithOptionsStep, RunWpInstallationWizardStep, + RunSqlStep, WordPressInstallationOptions, SetPhpIniEntryStep, SetSiteOptionsStep, diff --git a/packages/playground/blueprints/src/lib/steps/run-sql.spec.ts b/packages/playground/blueprints/src/lib/steps/run-sql.spec.ts new file mode 100644 index 0000000000..50bf3eb668 --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/run-sql.spec.ts @@ -0,0 +1,99 @@ +import { NodePHP } from '@php-wasm/node'; +import { phpVars } from '@php-wasm/util'; +import { runSql } from './run-sql'; + +const phpVersion = '8.0'; +describe('Blueprint step runSql', () => { + let php: NodePHP; + + beforeEach(async () => { + php = await NodePHP.load(phpVersion, { + requestHandler: { + documentRoot: '/wordpress', + }, + }); + }); + + it('should split and "run" sql queries', async () => { + const docroot = '/wordpress'; + const sqlFilename = `/tmp/${crypto.randomUUID()}.sql`; + const resFilename = `/tmp/${crypto.randomUUID()}.json`; + const js = phpVars({ docroot, sqlFilename, resFilename }); + await php.mkdir(docroot); + + // Create an object that will log all function calls + await php.writeFile( + `${docroot}/wp-load.php`, + ` 'CALL', + 'function' => $function, + 'args' => $args, + ]; + + file_put_contents(${js.resFilename}, json_encode($entry) . "\n", FILE_APPEND); + } + } + + global $wpdb; + $wpdb = new MockLogger(); + file_put_contents(${js.resFilename}, ''); + ` + ); + + // Test a single query + const mockFileSingle = { + name: 'single-query.sql', + async arrayBuffer() { + return new TextEncoder().encode('SELECT * FROM wp_users;') + .buffer; + }, + type: 'text/plain', + } as any; + + await runSql(php, { sql: mockFileSingle }); + + const singleQueryResult = await php.readFileAsText(resFilename); + const singleQueryExpect = `{"type":"CALL","function":"query","args":["SELECT * FROM wp_users;"]}\n`; + expect(singleQueryResult).toBe(singleQueryExpect); + + // Test a multiple queries + const mockFileMultiple = { + name: 'multiple-queries.sql', + async arrayBuffer() { + return new TextEncoder().encode( + `SELECT * FROM wp_users;\nSELECT * FROM wp_posts;\n` + ).buffer; + }, + type: 'text/plain', + } as any; + + await runSql(php, { sql: mockFileMultiple }); + + const multiQueryResult = await php.readFileAsText(resFilename); + const multiQueryExpect = `{"type":"CALL","function":"query","args":["SELECT * FROM wp_users;\\n"]}\n{"type":"CALL","function":"query","args":["SELECT * FROM wp_posts;\\n"]}\n`; + expect(multiQueryResult).toBe(multiQueryExpect); + + // Ensure it works the same if the last query is missing a trailing newline + const mockFileNoTrailingSpace = { + name: 'no-trailing-newline.sql', + async arrayBuffer() { + return new TextEncoder().encode( + `SELECT * FROM wp_users;\nSELECT * FROM wp_posts;` + ).buffer; + }, + type: 'text/plain', + } as any; + + await runSql(php, { sql: mockFileNoTrailingSpace }); + const noTrailingNewlineQueryResult = await php.readFileAsText( + resFilename + ); + const noTrailingNewlineQueryExpect = `{"type":"CALL","function":"query","args":["SELECT * FROM wp_users;\\n"]}\n{"type":"CALL","function":"query","args":["SELECT * FROM wp_posts;"]}\n`; + expect(noTrailingNewlineQueryResult).toBe(noTrailingNewlineQueryExpect); + }); +}); diff --git a/packages/playground/blueprints/src/lib/steps/run-sql.ts b/packages/playground/blueprints/src/lib/steps/run-sql.ts new file mode 100644 index 0000000000..7d9c6d2531 --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/run-sql.ts @@ -0,0 +1,82 @@ +import { StepHandler } from '.'; +import { rm } from './rm'; +import { phpVars } from '@php-wasm/util'; + +/** + * @inheritDoc runSql + * @hasRunnableExample + * @example + * + * + * { + * "step": "runSql", + * "sql": { + * "resource": "literal", + * "name": "schema.sql", + * "contents": "DELETE FROM wp_posts" + * }, + * } + * + */ +export interface RunSqlStep { + /** + * The step identifier. + */ + step: 'runSql'; + /** + * The SQL to run. Each non-empty line must contain a valid SQL query. + */ + sql: ResourceType; +} + +/** + * Run one or more SQL queries. + * + * This step will treat each non-empty line in the input SQL as a query and + * try to execute it using `$wpdb`. Queries spanning multiple lines are not + * yet supported. + */ +export const runSql: StepHandler> = async ( + playground, + { sql }, + progress? +) => { + progress?.tracker.setCaption(`Executing SQL Queries`); + + const sqlFilename = `/tmp/${crypto.randomUUID()}.sql`; + + await playground.writeFile( + sqlFilename, + new Uint8Array(await sql.arrayBuffer()) + ); + + const docroot = await playground.documentRoot; + + const js = phpVars({ docroot, sqlFilename }); + + const runPhp = await playground.run({ + code: `query($buffer); + $buffer = ''; + } + `, + }); + + await rm(playground, { path: sqlFilename }); + + return runPhp; +};