repo-tools: full SQL report implementation

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-12-28 16:25:48 +01:00
parent be2fa90773
commit dee5cf2f3c
4 changed files with 219 additions and 76 deletions
@@ -284,7 +284,7 @@ export async function getTsDocConfig() {
return tsdocConfigFile;
}
function logApiReportInstructions() {
export function logApiReportInstructions() {
console.log('');
console.log(
'*************************************************************************************',
@@ -0,0 +1,87 @@
/*
* Copyright 2024 The Backstage Authors
*
* 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 { SchemaInfo } from './types';
export function generateSqlReport(options: {
reportName: string;
failedDownMigration?: string;
schemaInfo: SchemaInfo;
}): string {
const { reportName, failedDownMigration, schemaInfo } = options;
const output = [
`## SQL Report file for "${reportName}"`,
'',
'> Do not edit this file. It is a report generated by `yarn build:api-reports`',
'',
];
if (failedDownMigration) {
output.push('> [!WARNING]');
output.push(`> Failed to migrate down from '${failedDownMigration}'`);
output.push('');
}
if (Object.keys(schemaInfo.sequences).length > 0) {
output.push('## Sequences');
output.push('');
for (const [sequenceName, sequenceInfo] of Object.entries(
schemaInfo.sequences,
)) {
output.push(`- \`${sequenceName}\` (${sequenceInfo.type})`);
}
output.push('');
}
for (const [tableName, tableInfo] of Object.entries(schemaInfo.tables)) {
output.push(`## Table \`${tableName}\``);
output.push('');
output.push(' | Column | Type | Nullable | Max Length | Default |');
output.push(' |--------|------|----------|------------|---------|');
for (const [columnName, columnInfo] of Object.entries(tableInfo.columns)) {
output.push(
` | \`${columnName}\` | ${columnInfo.type} | ${
columnInfo.nullable
} | ${columnInfo.maxLength ?? '-'} | ${
columnInfo.defaultValue ?? '-'
} |`,
);
}
output.push('');
if (Object.keys(tableInfo.indices).length > 0) {
output.push('### Indices');
output.push('');
for (const [indexName, indexInfo] of Object.entries(tableInfo.indices)) {
const indexType = [
indexInfo.unique && 'unique',
indexInfo.primary && 'primary',
]
.filter(Boolean)
.join(' ');
output.push(
`- \`${indexName}\` (\`${indexInfo.columns.join('`, `')}\`)${
indexType ? ` ${indexType}` : ''
}`,
);
}
output.push('');
}
}
return output.join('\n');
}
@@ -61,12 +61,7 @@ export async function getPgSchemaInfo(knex: Knex): Promise<SchemaInfo> {
name,
{
name,
columns: Object.entries(columns).map(
([columnName, columnInfo]) => ({
...columnInfo,
name: columnName,
}),
),
columns,
indices: Object.fromEntries(
indices.map(index => [index.name, index]),
),
@@ -14,12 +14,16 @@
* limitations under the License.
*/
import fs from 'fs-extra';
import fs, { readJson } from 'fs-extra';
import { relative as relativePath } from 'path';
import { paths as cliPaths } from '../../../lib/paths';
import { getPortPromise } from 'portfinder';
import { diff as justDiff } from 'just-diff';
import { SchemaInfo } from './types';
import { getPgSchemaInfo } from './getPgSchemaInfo';
import { generateSqlReport } from './generateSqlReport';
import type { Knex } from 'knex';
import { logApiReportInstructions } from '../api-extractor';
interface SqlExtractionOptions {
packageDirs: string[];
@@ -27,60 +31,19 @@ interface SqlExtractionOptions {
}
export async function runSqlExtraction(options: SqlExtractionOptions) {
for (const packageDir of options.packageDirs) {
const migrationDir = cliPaths.resolveTargetRoot(packageDir, 'migrations');
if (!(await fs.pathExists(migrationDir))) {
console.log(`No SQL migrations found in ${packageDir}`);
continue;
}
console.log(`Extracting SQL migrations from ${packageDir}`);
const migrationFiles = await fs.readdir(migrationDir, {
withFileTypes: true,
});
const migrationTargets = migrationFiles
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
if (migrationFiles.some(entry => entry.isFile())) {
migrationTargets.push('.');
}
for (const migrationTarget of migrationTargets) {
await runSingleSqlExtraction(packageDir, migrationTarget, options);
}
}
}
async function runSingleSqlExtraction(
targetDir: string,
migrationPath: string,
options: SqlExtractionOptions,
) {
const migrationDir = cliPaths.resolveTargetRoot(
targetDir,
'migrations',
migrationPath,
);
console.log(`Extracting SQL from ${migrationDir}`);
const { default: Knex } = await import('knex');
const { default: EmbeddedPostgres } = await import('embedded-postgres');
console.log(`DEBUG: knex=`, Knex);
console.log(`DEBUG: EmbeddedPostgres=`, EmbeddedPostgres);
const port = await getPortPromise();
const port = await getPortPromise({
/* startPort: 5433, stopPort: 6543 */
});
console.log(`DEBUG: port=`, port);
const pg = new EmbeddedPostgres({
databaseDir: './data/db',
const basePgOpts = {
host: 'localhost',
user: 'postgres',
password: 'password',
};
const pg = new EmbeddedPostgres({
databaseDir: './data/db',
...basePgOpts,
port,
persistent: false,
onError(_messageOrError) {
@@ -97,18 +60,75 @@ async function runSingleSqlExtraction(
// Start the server
await pg.start();
await pg.createDatabase('extractor');
let dbIndex = 1;
const knex = Knex({
client: 'pg',
connection: {
host: 'localhost',
port,
user: 'postgres',
password: 'password',
database: 'extractor',
},
});
try {
for (const packageDir of options.packageDirs) {
const migrationDir = cliPaths.resolveTargetRoot(packageDir, 'migrations');
if (!(await fs.pathExists(migrationDir))) {
console.log(`No SQL migrations found in ${packageDir}`);
continue;
}
const { name: pkgName } = await readJson(
cliPaths.resolveTargetRoot(packageDir, 'package.json'),
);
const migrationFiles = await fs.readdir(migrationDir, {
withFileTypes: true,
});
const migrationTargets = migrationFiles
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
if (migrationFiles.some(entry => entry.isFile())) {
migrationTargets.push('.');
}
for (const migrationTarget of migrationTargets) {
const database = `extractor-${dbIndex++}`;
await pg.createDatabase(database);
const knex = Knex({
client: 'pg',
connection: {
...basePgOpts,
port,
database,
},
});
await runSingleSqlExtraction(
packageDir,
migrationTarget,
pkgName,
knex,
options,
);
}
}
} finally {
// Stop the server
await pg.stop();
}
}
async function runSingleSqlExtraction(
targetDir: string,
migrationTarget: string,
pkgName: string,
knex: Knex,
options: SqlExtractionOptions,
) {
const migrationDir = cliPaths.resolveTargetRoot(
targetDir,
'migrations',
migrationTarget,
);
const reportName =
migrationTarget === '.' ? pkgName : `${pkgName}/${migrationTarget}`;
console.log(`Generating SQL report for ${reportName}`);
const migrationsListResult = await knex.migrate.list({
directory: migrationDir,
@@ -120,7 +140,6 @@ async function runSingleSqlExtraction(
const schemaInfoBeforeMigration = new Map<string, SchemaInfo>();
for (const migration of migrations) {
console.log(`DEBUG: UP ${migration}`);
const schemaInfo = await getPgSchemaInfo(knex);
schemaInfoBeforeMigration.set(migration, schemaInfo);
@@ -131,10 +150,9 @@ async function runSingleSqlExtraction(
}
const schemaInfo = await getPgSchemaInfo(knex);
console.log(`DEBUG: schemaInfo=`, JSON.stringify(schemaInfo, null, 2));
let failedDownMigration: string | undefined = undefined;
for (const migration of migrations.toReversed()) {
console.log(`DEBUG: DOWN ${migration}`);
await knex.migrate.down({
directory: migrationDir,
name: migration,
@@ -146,14 +164,57 @@ async function runSingleSqlExtraction(
}
const diff = justDiff(before, after);
console.log(`DEBUG: diff=`, diff);
if (diff.length !== 0) {
console.log(`Migration ${migration} is not reversible`);
await pg.stop();
return;
console.log(
`Migration ${migration} is not reversible: ${JSON.stringify(
diff,
null,
2,
)}`,
);
failedDownMigration = migration;
break;
}
}
// Stop the server
await pg.stop();
const report = generateSqlReport({
reportName,
failedDownMigration,
schemaInfo,
});
const reportPath = cliPaths.resolveTargetRoot(
targetDir,
`report${migrationTarget === '.' ? '' : `-${migrationTarget}`}.sql.md`,
);
const existingReport = await fs.readFile(reportPath, 'utf8').catch(error => {
if (error.code === 'ENOENT') {
return undefined;
}
throw error;
});
if (existingReport !== report) {
if (options.isLocalBuild) {
console.warn(`SQL report changed for ${targetDir}`);
await fs.writeFile(reportPath, report);
} else {
logApiReportInstructions();
if (existingReport) {
console.log('');
console.log(
`The conflicting file is ${relativePath(
cliPaths.targetRoot,
reportPath,
)}, expecting the following content:`,
);
console.log('');
console.log(report);
logApiReportInstructions();
}
throw new Error(`Report ${reportPath} is out of date`);
}
}
}