immich/server/src/bin/sync-sql.ts

213 lines
6.3 KiB
TypeScript
Raw Normal View History

#!/usr/bin/env node
import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
2025-02-11 17:15:56 -05:00
import { ClassConstructor } from 'class-transformer';
import { PostgresJSDialect } from 'kysely-postgres-js';
2025-01-09 11:15:41 -05:00
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import postgres from 'postgres';
2024-03-20 22:15:09 -05:00
import { format } from 'sql-formatter';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
import { entities } from 'src/entities';
2025-02-11 17:15:56 -05:00
import { repositories } from 'src/repositories';
2024-03-20 16:02:51 -05:00
import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
2025-01-23 08:31:30 -05:00
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService } from 'src/services/auth.service';
2024-03-20 22:15:09 -05:00
import { Logger } from 'typeorm';
export class SqlLogger implements Logger {
queries: string[] = [];
errors: Array<{ error: string | Error; query: string }> = [];
clear() {
this.queries = [];
this.errors = [];
}
logQuery(query: string) {
this.queries.push(format(query, { language: 'postgresql' }));
}
logQueryError(error: string | Error, query: string) {
this.errors.push({ error, query });
}
logQuerySlow() {}
logSchemaBuild() {}
logMigration() {}
log() {}
}
const reflector = new Reflector();
2025-02-11 17:15:56 -05:00
type Repository = ClassConstructor<any>;
type SqlGeneratorOptions = { targetDir: string };
class SqlGenerator {
private app: INestApplication | null = null;
private sqlLogger = new SqlLogger();
private results: Record<string, string[]> = {};
constructor(private options: SqlGeneratorOptions) {}
async run() {
try {
await this.setup();
2025-02-11 17:15:56 -05:00
for (const Repository of repositories) {
if (Repository === LoggingRepository) {
continue;
}
2025-02-11 17:15:56 -05:00
await this.process(Repository);
}
await this.write();
this.stats();
} finally {
await this.close();
}
}
private async setup() {
await rm(this.options.targetDir, { force: true, recursive: true });
await mkdir(this.options.targetDir);
2025-01-09 11:15:41 -05:00
process.env.DB_HOSTNAME = 'localhost';
const { database, otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({
imports: [
2025-01-09 11:15:41 -05:00
KyselyModule.forRoot({
dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }),
2025-01-09 11:15:41 -05:00
log: (event) => {
if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql);
} else if (event.level === 'error') {
this.sqlLogger.logQueryError(event.error as Error, event.query.sql);
this.sqlLogger.logQuery(event.query.sql);
2025-01-09 11:15:41 -05:00
}
},
}),
TypeOrmModule.forRoot({
2025-01-09 11:15:41 -05:00
...database.config.typeorm,
entities,
logging: ['query'],
logger: this.sqlLogger,
}),
TypeOrmModule.forFeature(entities),
OpenTelemetryModule.forRoot(otel),
],
2025-02-11 17:15:56 -05:00
providers: [...repositories, AuthService, SchedulerRegistry],
}).compile();
this.app = await moduleFixture.createNestApplication().init();
}
2025-02-11 17:15:56 -05:00
async process(Repository: Repository) {
if (!this.app) {
throw new Error('Not initialized');
}
const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`];
2025-02-11 17:15:56 -05:00
const instance = this.app.get<Repository>(Repository);
// normal repositories
data.push(...(await this.runTargets(instance, `${Repository.name}`)));
// nested repositories
if (Repository.name === AccessRepository.name) {
for (const key of Object.keys(instance)) {
const subInstance = (instance as any)[key];
data.push(...(await this.runTargets(subInstance, `${Repository.name}.${key}`)));
}
}
this.results[Repository.name] = data;
}
private async runTargets(instance: any, label: string) {
const data: string[] = [];
for (const key of this.getPropertyNames(instance)) {
const target = instance[key];
if (!(target instanceof Function)) {
continue;
}
const queries = reflector.get<GenerateSqlQueries[] | undefined>(GENERATE_SQL_KEY, target);
if (!queries) {
continue;
}
// empty decorator implies calling with no arguments
if (queries.length === 0) {
queries.push({ params: [] });
}
for (const { name, params } of queries) {
let queryLabel = `${label}.${key}`;
if (name) {
queryLabel += ` (${name})`;
}
this.sqlLogger.clear();
// errors still generate sql, which is all we care about
await target.apply(instance, params).catch((error: Error) => console.error(`${queryLabel} error: ${error}`));
if (this.sqlLogger.queries.length === 0) {
console.warn(`No queries recorded for ${queryLabel}`);
continue;
}
data.push([`-- ${queryLabel}`, ...this.sqlLogger.queries].join('\n'));
}
}
return data;
}
private async write() {
for (const [repoName, data] of Object.entries(this.results)) {
// only contains the header
if (data.length === 1) {
continue;
}
const filename = repoName.replaceAll(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', '');
const file = join(this.options.targetDir, `${filename}.sql`);
await writeFile(file, data.join('\n\n') + '\n');
}
}
private stats() {
console.log(`Wrote ${Object.keys(this.results).length} files`);
console.log(`Generated ${Object.values(this.results).flat().length} queries`);
}
private async close() {
if (this.app) {
await this.app.close();
}
}
private getPropertyNames(instance: any): string[] {
return Object.getOwnPropertyNames(Object.getPrototypeOf(instance)) as any[];
}
}
2024-03-20 22:15:09 -05:00
new SqlGenerator({ targetDir: './src/queries' })
.run()
.then(() => {
console.log('Done');
process.exit(0);
})
.catch((error) => {
console.error(error);
console.log('Something went wrong');
process.exit(1);
});