Merge pull request #5625 from backstage/freben/mysql-prep

Prep work for mysql support in backend-common
This commit is contained in:
Fredrik Adelöw
2021-05-12 10:00:18 +02:00
committed by GitHub
8 changed files with 416 additions and 6 deletions
+3 -1
View File
@@ -64,7 +64,8 @@
"stoppable": "^1.1.0",
"tar": "^6.0.5",
"unzipper": "^0.10.11",
"winston": "^3.2.1"
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"peerDependencies": {
"pg-connection-string": "^2.3.0"
@@ -96,6 +97,7 @@
"jest": "^26.0.1",
"mock-fs": "^4.13.0",
"msw": "^0.21.2",
"mysql2": "^2.2.5",
"recursive-readdir": "^2.2.2",
"supertest": "^6.1.3"
},
@@ -46,11 +46,11 @@ describe('database connection', () => {
).toBeTruthy();
});
it('tries to create a mysql connection as a passthrough', () => {
it('returns a mysql connection', () => {
expect(() =>
createDatabaseClient(
new ConfigReader({
client: 'mysql',
client: 'mysql2',
connection: {
host: '127.0.0.1',
user: 'foo',
@@ -59,7 +59,7 @@ describe('database connection', () => {
},
}),
),
).toThrowError(/Cannot find module 'mysql'/);
).toBeTruthy();
});
it('accepts overrides', () => {
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import knexFactory, { Knex } from 'knex';
import { Config } from '@backstage/config';
import knexFactory, { Knex } from 'knex';
import { mergeDatabaseConfig } from './config';
import { createMysqlDatabaseClient, ensureMysqlDatabaseExists } from './mysql';
import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres';
import { createSqliteDatabaseClient } from './sqlite3';
@@ -36,6 +37,8 @@ export function createDatabaseClient(
if (client === 'pg') {
return createPgDatabaseClient(dbConfig, overrides);
} else if (client === 'mysql' || client === 'mysql2') {
return createMysqlDatabaseClient(dbConfig, overrides);
} else if (client === 'sqlite3') {
return createSqliteDatabaseClient(dbConfig, overrides);
}
@@ -60,6 +63,8 @@ export async function ensureDatabaseExists(
if (client === 'pg') {
return ensurePgDatabaseExists(dbConfig, ...databases);
} else if (client === 'mysql' || client === 'mysql2') {
return ensureMysqlDatabaseExists(dbConfig, ...databases);
}
return undefined;
@@ -0,0 +1,188 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, ConfigReader } from '@backstage/config';
import {
buildMysqlDatabaseConfig,
createMysqlDatabaseClient,
getMysqlConnectionConfig,
parseMysqlConnectionString,
} from './mysql';
describe('mysql', () => {
const createMockConnection = () => ({
host: 'acme',
user: 'foo',
password: 'bar',
database: 'foodb',
});
const createMockConnectionString = () => 'mysql://foo:bar@acme:3306/foodb';
const createConfig = (connection: any): Config =>
new ConfigReader({ client: 'mysql2', connection });
describe('buildMysqlDatabaseConfig', () => {
it('builds a mysql config', () => {
const mockConnection = createMockConnection();
expect(buildMysqlDatabaseConfig(createConfig(mockConnection))).toEqual({
client: 'mysql2',
connection: mockConnection,
useNullAsDefault: true,
});
});
it('builds a connection string config', () => {
const mockConnectionString = createMockConnectionString();
expect(
buildMysqlDatabaseConfig(createConfig(mockConnectionString)),
).toEqual({
client: 'mysql2',
connection: mockConnectionString,
useNullAsDefault: true,
});
});
it('overrides the database name', () => {
const mockConnection = createMockConnection();
expect(
buildMysqlDatabaseConfig(createConfig(mockConnection), {
connection: { database: 'other_db' },
}),
).toEqual({
client: 'mysql2',
connection: {
...mockConnection,
database: 'other_db',
},
useNullAsDefault: true,
});
});
it('adds additional config settings', () => {
const mockConnection = createMockConnection();
expect(
buildMysqlDatabaseConfig(createConfig(mockConnection), {
connection: { database: 'other_db' },
pool: { min: 0, max: 7 },
debug: true,
}),
).toEqual({
client: 'mysql2',
connection: {
...mockConnection,
database: 'other_db',
},
useNullAsDefault: true,
pool: { min: 0, max: 7 },
debug: true,
});
});
it('overrides the database from connection string', () => {
const mockConnectionString = createMockConnectionString();
const mockConnection = createMockConnection();
expect(
buildMysqlDatabaseConfig(createConfig(mockConnectionString), {
connection: { database: 'other_db' },
}),
).toEqual({
client: 'mysql2',
connection: {
...mockConnection,
port: 3306,
database: 'other_db',
},
useNullAsDefault: true,
});
});
});
describe('getMysqlConnectionConfig', () => {
it('returns the connection object back', () => {
const mockConnection = createMockConnection();
const config = createConfig(mockConnection);
expect(getMysqlConnectionConfig(config)).toEqual(mockConnection);
});
it('does not parse the connection string', () => {
const mockConnection = createMockConnection();
const config = createConfig(mockConnection);
expect(getMysqlConnectionConfig(config, true)).toEqual(mockConnection);
});
it('automatically parses the connection string', () => {
const mockConnection = createMockConnection();
const mockConnectionString = createMockConnectionString();
const config = createConfig(mockConnectionString);
expect(getMysqlConnectionConfig(config)).toEqual({
...mockConnection,
port: 3306,
});
});
it('parses the connection string', () => {
const mockConnection = createMockConnection();
const mockConnectionString = createMockConnectionString();
const config = createConfig(mockConnectionString);
expect(getMysqlConnectionConfig(config, true)).toEqual({
...mockConnection,
port: 3306,
});
});
});
describe('createMysqlDatabaseClient', () => {
it('creates a mysql knex instance', () => {
expect(
createMysqlDatabaseClient(
createConfig({
host: 'acme',
user: 'foo',
password: 'bar',
database: 'foodb',
}),
),
).toBeTruthy();
});
});
describe('parseMysqlConnectionString', () => {
it('parses a connection string uri', () => {
expect(
parseMysqlConnectionString(
'mysql://u:pass@foobar:3307/dbname?ssl=require',
),
).toEqual({
host: 'foobar',
user: 'u',
password: 'pass',
port: 3307,
database: 'dbname',
ssl: 'require',
});
});
});
});
@@ -0,0 +1,161 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import knexFactory, { Knex } from 'knex';
import { mergeDatabaseConfig } from './config';
import yn from 'yn';
/**
* Creates a knex mysql database connection
*
* @param dbConfig The database config
* @param overrides Additional options to merge with the config
*/
export function createMysqlDatabaseClient(
dbConfig: Config,
overrides?: Knex.Config,
) {
const knexConfig = buildMysqlDatabaseConfig(dbConfig, overrides);
const database = knexFactory(knexConfig);
return database;
}
/**
* Builds a knex mysql database connection
*
* @param dbConfig The database config
* @param overrides Additional options to merge with the config
*/
export function buildMysqlDatabaseConfig(
dbConfig: Config,
overrides?: Knex.Config,
) {
return mergeDatabaseConfig(
dbConfig.get(),
{
connection: getMysqlConnectionConfig(dbConfig, !!overrides),
useNullAsDefault: true,
},
overrides,
);
}
/**
* Gets the mysql connection config
*
* @param dbConfig The database config
* @param parseConnectionString Flag to explicitly control connection string parsing
*/
export function getMysqlConnectionConfig(
dbConfig: Config,
parseConnectionString?: boolean,
): Knex.MySqlConnectionConfig | string {
const connection = dbConfig.get('connection') as any;
const isConnectionString =
typeof connection === 'string' || connection instanceof String;
const autoParse = typeof parseConnectionString !== 'boolean';
const shouldParseConnectionString = autoParse
? isConnectionString
: parseConnectionString && isConnectionString;
return shouldParseConnectionString
? parseMysqlConnectionString(connection as string)
: connection;
}
/**
* Parses a mysql connection string.
*
* e.g. mysql://examplename:somepassword@examplehost:3306/dbname
* @param connectionString The mysql connection string
*/
export function parseMysqlConnectionString(
connectionString: string,
): Knex.MySqlConnectionConfig {
try {
const {
protocol,
username,
password,
port,
hostname,
pathname,
searchParams,
} = new URL(connectionString);
if (protocol !== 'mysql:') {
throw new Error(`Unknown protocol ${protocol}`);
} else if (!username || !password) {
throw new Error(`Missing username/password`);
} else if (!pathname.match(/^\/[^/]+$/)) {
throw new Error(`Expected single path segment`);
}
const result: Knex.MySqlConnectionConfig = {
user: username,
password,
host: hostname,
port: Number(port || 3306),
database: decodeURIComponent(pathname.substr(1)),
};
const ssl = searchParams.get('ssl');
if (ssl) {
result.ssl = ssl;
}
const debug = searchParams.get('debug');
if (debug) {
result.debug = yn(debug);
}
return result;
} catch (e) {
throw new InputError(
`Error while parsing MySQL connection string, ${e}`,
e,
);
}
}
/**
* Creates the missing mysql database if it does not exist
*
* @param dbConfig The database config
* @param databases The names of the databases to create
*/
export async function ensureMysqlDatabaseExists(
dbConfig: Config,
...databases: Array<string>
) {
const admin = createMysqlDatabaseClient(dbConfig, {
connection: {
database: (null as unknown) as string,
},
});
try {
const ensureDatabase = async (database: string) => {
await admin.raw(`CREATE DATABASE IF NOT EXISTS ??`, [database]);
};
await Promise.all(databases.map(ensureDatabase));
} finally {
await admin.destroy();
}
}