diff --git a/README.md b/README.md index 24fa7e1..1c9581a 100644 --- a/README.md +++ b/README.md @@ -180,9 +180,13 @@ Description: The number of times to try to download a MySQL binary before giving Default: "" -Description: A string with MySQL queries to run before the database starts to accept connections. This option can be used for things like initialising tables without having to first connect to the database to do that. The queries in the string get executed after ```mysql-memory-server```'s queries run. Uses the ```--init-file``` MySQL server option under the hood. Learn more at the [--init-file MySQL Documentation](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_init_file) +Description: A string with SQL queries to run before the database starts to accept connections. This option can be used for things like initialising tables without having to first connect to the database to do that. Check the [Init SQL file order of operations](#init-sql-file-order-of-operations) to learn more about what SQL queries are ran and in what order before the database starts accepting connections. -The internal queries that are ran before the queries in ```initSQLString``` are creating the MySQL user with ```options.username``` username if the option's value is not ```root```, and creating a database with the ```options.dbName``` name. +- `initSQLFilePath: string` + +Default: "" + +Description: A path to a UTF-8 .sql file with SQL queries to run before the database starts to accept connections. This option is like the ```initSQLString``` option, instead taking a filepath for SQL statements to execute rather than a string of SQL statements - great for when you need to execute more than just a few SQL statements. Check the [Init SQL file order of operations](#init-sql-file-order-of-operations) to learn more about what SQL queries are ran and in what order before the database starts accepting connections. If a filepath is defined and reading the file fails, then the database creation will fail. The database creation process will not begin if ```initSQLFilePath``` is defined but the path specified does not exist. - `arch: "arm64" | "x64"` @@ -195,3 +199,11 @@ Description: The MySQL binary architecture to execute. MySQL does not offer serv Default: "FORCE" Description: This option follows the convention set out by the [MySQL Documentation](https://dev.mysql.com/doc/refman/en/plugin-loading.html). If set to "OFF", the MySQL X Plugin will not initialise. If set to "FORCE", the MySQL Server will either start up with the MySQL X Plugin guaranteed to have successfully initialised, or if initialisation fails, the server will fail to start up. + +### Init SQL file order of operations: + +There are some SQL queries executed on the database before the database is ready to be used. This is handled under the hood using the ```--init-file``` MySQL server option. Learn more at the [--init-file MySQL Documentation](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_init_file). The following is the order in which the SQL queries are executed in, ordered from first executed to last executed: + +1. ```mysql-memory-server``` internal SQL queries - The internal queries that are executed are creating the MySQL user with ```options.username``` username if the option's value is not ```root```, and creating a database with the ```options.dbName``` name. +2. The SQL queries provided to the ```initSQLString``` option +3. The SQL queries provided to the ```initSQLFilePath``` option diff --git a/src/constants.ts b/src/constants.ts index d9412a6..2424807 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,6 @@ import { InternalServerOptions, OptionTypeChecks } from "../types"; import { valid as validSemver, coerce as coerceSemver } from "semver"; +import { existsSync } from "fs"; export const DEFAULT_OPTIONS: InternalServerOptions = { version: undefined, @@ -16,7 +17,8 @@ export const DEFAULT_OPTIONS: InternalServerOptions = { downloadRetries: 10, initSQLString: '', arch: process.arch, - xEnabled: 'FORCE' + xEnabled: 'FORCE', + initSQLFilePath: '' } as const; export const DEFAULT_OPTIONS_KEYS = Object.freeze(Object.keys(DEFAULT_OPTIONS)) @@ -113,7 +115,12 @@ export const OPTION_TYPE_CHECKS: OptionTypeChecks = { check: (opt: any) => opt === undefined || pluginActivationStates.includes(opt), errorMessage: `xEnabled must be either undefined or one of the following: ${pluginActivationStates.join(', ')}`, definedType: 'boolean' - } + }, + initSQLFilePath: { + check: (opt: any) => opt === undefined || (typeof opt === 'string' && existsSync(opt)), + errorMessage: 'Option initSQLFilePath must be either undefined or a filepath string that points to a file that exists.', + definedType: 'string' + }, } as const; export const MIN_SUPPORTED_MYSQL = '5.7.19'; diff --git a/src/libraries/Executor.ts b/src/libraries/Executor.ts index 4e164fd..383bd43 100644 --- a/src/libraries/Executor.ts +++ b/src/libraries/Executor.ts @@ -358,6 +358,33 @@ class Executor { }) } + #streamAppendToFile(readPath: string, writePath: string): Promise { + return new Promise((resolve, reject) => { + const rs = fs.createReadStream(readPath, {encoding: 'utf-8'}) + const ws = fs.createWriteStream(writePath, {flags: 'a', encoding: 'utf-8'}) + + rs.on('error', (e) => { + ws.end(); + this.logger.error('Received error from streamAppendToFile read stream:', e) + reject(e) + }) + + ws.on('error', (e) => { + rs.close(); + this.logger.error('Received error from streamAppendToFile write stream:', e) + reject(e) + }) + + rs.on('end', () => { + rs.close(); + ws.close(); + resolve() + }) + + rs.pipe(ws) + }) + } + async #setupDataDirectories(options: InternalServerOptions, binary: DownloadedMySQLVersion, datadir: string, retry: boolean): Promise { const binaryFilepath = binary.path this.logger.log('Created data directory for database at:', datadir) @@ -494,14 +521,23 @@ class Executor { } if (options.initSQLString.length > 0) { - initText += `\n${options.initSQLString}` + initText += `\n${options.initSQLString}\n` } this.logger.log('Writing init file') - await fsPromises.writeFile(`${this.databasePath}/init.sql`, initText, {encoding: 'utf8'}) + const initFilePath = `${this.databasePath}/init.sql` + await fsPromises.writeFile(initFilePath, initText, {encoding: 'utf8'}) this.logger.log('Finished writing init file') + + if (options.initSQLFilePath) { + this.logger.log('Appending init.sql file with the contents of the file at path provided by options.initSQLFilePath.') + + await this.#streamAppendToFile(options.initSQLFilePath, initFilePath) + + this.logger.log('Successfully appended init.sql file with the contents of the file at path provided by options.initSQLFilePath.') + } } async startMySQL(options: InternalServerOptions, installedMySQLBinary: DownloadedMySQLVersion): Promise { diff --git a/tests/versions.test.ts b/tests/versions.test.ts index e6ed27e..7a75da1 100644 --- a/tests/versions.test.ts +++ b/tests/versions.test.ts @@ -8,6 +8,7 @@ import { DOWNLOADABLE_MYSQL_VERSIONS } from '../src/constants'; import fs from 'fs' import fsPromises from 'fs/promises' import os from 'os' +import { randomUUID } from 'crypto'; const usernames = ['root', 'dbuser'] @@ -18,6 +19,9 @@ const arch = process.arch === 'x64' || (process.platform === 'win32' && process. const versionRequirement = process.env.VERSION_REQUIREMENT || '>0.0.0' console.log('Running versions test with versionRequirement:', versionRequirement) +const initSQLFilePath = `${os.tmpdir()}/mysqlmsn-init-file-${randomUUID()}` +fs.writeFileSync(initSQLFilePath, 'CREATE DATABASE initfromsqlfilepath;', 'utf-8') + for (const version of DOWNLOADABLE_MYSQL_VERSIONS.filter(v => satisfies(v, versionRequirement))) { try { getBinaryURL(version, arch) @@ -35,7 +39,8 @@ for (const version of DOWNLOADABLE_MYSQL_VERSIONS.filter(v => satisfies(v, versi logLevel: 'LOG', initSQLString: 'CREATE DATABASE mytestdb;', arch, - xEnabled: process.env.X_OFF === 'true' ? 'OFF' : 'FORCE' + xEnabled: process.env.X_OFF === 'true' ? 'OFF' : 'FORCE', + initSQLFilePath } const db = await createDB(options) @@ -49,6 +54,9 @@ for (const version of DOWNLOADABLE_MYSQL_VERSIONS.filter(v => satisfies(v, versi //If this does not fail, it means initSQLString works as expected and the database was successfully created. await connection.query('USE mytestdb;') + + //If this does not fail, it means initSQLFilePath works as expected and the database was successfully created. + await connection.query('USE initfromsqlfilepath;') await connection.end(); await db.stop(); diff --git a/types/index.ts b/types/index.ts index 2bf8f74..00181fb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -19,7 +19,8 @@ export type ServerOptions = { downloadRetries?: number | undefined, initSQLString?: string | undefined, arch?: "arm64" | "x64" | undefined, - xEnabled?: PluginActivationState | undefined + xEnabled?: PluginActivationState | undefined, + initSQLFilePath?: string | undefined } export type InternalServerOptions = { @@ -37,7 +38,8 @@ export type InternalServerOptions = { downloadRetries: number, initSQLString: string, arch: string, - xEnabled: PluginActivationState + xEnabled: PluginActivationState, + initSQLFilePath: string } export type ExecuteFileReturn = {