chore(repo) init Enumify module + ref. AppConfigModule

This commit is contained in:
Francesco Spilla 2025-02-10 15:29:27 +01:00
parent 3042e693be
commit ef50d7075f
11 changed files with 230 additions and 17 deletions

View File

@ -4,9 +4,10 @@ import { AppService } from './app.service';
import { AppConfigModule } from './modules/config/config.module'; import { AppConfigModule } from './modules/config/config.module';
import { AppDatabaseModule } from './modules/database/database.module'; import { AppDatabaseModule } from './modules/database/database.module';
import { EnumifyModule } from './modules/enumify/enumify.module';
import { AppFileTransactionsModule } from './modules/file-transactions/file-transactions.module'; import { AppFileTransactionsModule } from './modules/file-transactions/file-transactions.module';
import { AppLoggerModule } from './modules/logger/logger.module';
import { AppHealthModule } from './modules/health/health.module'; import { AppHealthModule } from './modules/health/health.module';
import { AppLoggerModule } from './modules/logger/logger.module';
@Module({ @Module({
imports: [ imports: [
@ -15,6 +16,7 @@ import { AppHealthModule } from './modules/health/health.module';
AppDatabaseModule, AppDatabaseModule,
AppHealthModule, AppHealthModule,
AppFileTransactionsModule, AppFileTransactionsModule,
EnumifyModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@ -1,16 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { databaseConfig } from '../database/database.config';
import { keyvConfig } from '../keyv/keyv.config';
import { loggerConfig } from '../logger/logger.config';
import { localConfig } from './local.config'; import { localConfig } from './local.config';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
expandVariables: true, load: [localConfig],
load: [localConfig, loggerConfig, databaseConfig, keyvConfig],
}), }),
], ],
}) })

View File

@ -1,24 +1,24 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { APP_DATASOURCES } from './database.constants';
import { DatabaseConfig, databaseConfig } from './database.config'; import { DatabaseConfig, databaseConfig } from './database.config';
import { APP_DATASOURCES } from './database.constants';
import { typeormTransactionalDataSourceFactory } from './utils/typeorm-data-source-factory'; import { typeormTransactionalDataSourceFactory } from './utils/typeorm-data-source-factory';
import { typeormModuleOptionsFactory } from './utils/typeorm-module-options-factory';
import { typeormEntitiesFromImport } from './utils/typeorm-import-entities'; import { typeormEntitiesFromImport } from './utils/typeorm-import-entities';
import { typeormModuleOptionsFactory } from './utils/typeorm-module-options-factory';
import { ConfigModule } from '@nestjs/config';
const dataSources: DataSource[] = []; const dataSources: DataSource[] = [];
const typeormModules = [ const typeormModules = [
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
dataSourceFactory: typeormTransactionalDataSourceFactory(dataSources), imports: databaseConfig.asProvider().imports,
dataSourceFactory: typeormTransactionalDataSourceFactory(),
useFactory: async (dbConfig: DatabaseConfig) => { useFactory: async (dbConfig: DatabaseConfig) => {
// const entities = await import('./path/to/entities'); // const entities = await import('./path/to/entities');
const entities = {}; const entities = {};
const config = await typeormModuleOptionsFactory(dbConfig, [ const config = await typeormModuleOptionsFactory(dbConfig, [typeormEntitiesFromImport(entities)]);
typeormEntitiesFromImport(entities),
]);
return config; return config;
}, },
inject: [databaseConfig.KEY], inject: [databaseConfig.KEY],
@ -32,7 +32,7 @@ const dataSourcesProvider = {
@Global() @Global()
@Module({ @Module({
imports: [...typeormModules], imports: [ConfigModule.forFeature(databaseConfig), ...typeormModules],
providers: [dataSourcesProvider], providers: [dataSourcesProvider],
exports: [...typeormModules, dataSourcesProvider], exports: [...typeormModules, dataSourcesProvider],
}) })

View File

@ -1,5 +1,7 @@
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
export const dataSources: DataSource[] = [];
export const typeormDataSourceFactory = (dataSources: DataSource[]) => async (options?: DataSourceOptions) => { export const typeormDataSourceFactory = (dataSources: DataSource[]) => async (options?: DataSourceOptions) => {
const dataSource = await new DataSource(options!).initialize(); const dataSource = await new DataSource(options!).initialize();
dataSources.push(dataSource); dataSources.push(dataSource);
@ -7,7 +9,7 @@ export const typeormDataSourceFactory = (dataSources: DataSource[]) => async (op
}; };
export const typeormTransactionalDataSourceFactory = export const typeormTransactionalDataSourceFactory =
(dataSources: DataSource[], name?: string) => async (options?: DataSourceOptions) => { (name?: string) => async (options?: DataSourceOptions) => {
const tt = await import('typeorm-transactional'); const tt = await import('typeorm-transactional');
const dataSource = tt.addTransactionalDataSource({ name: name, dataSource: new DataSource(options!) }); const dataSource = tt.addTransactionalDataSource({ name: name, dataSource: new DataSource(options!) });
dataSources.push(dataSource); dataSources.push(dataSource);

View File

@ -0,0 +1,105 @@
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { differenceBy, get } from 'lodash';
import { DataSource, QueryFailedError } from 'typeorm';
import { APP_DATASOURCES } from '../database/database.constants';
import { Enumify, registeredEnums } from './enumify';
import { EnumifyConfig, enumifyConfig } from './enumify.config';
@Injectable()
export class EnumifyUpdateFromDatabaseService implements OnApplicationBootstrap {
constructor(
@Inject(APP_DATASOURCES) private readonly datasources: DataSource[],
@Inject(enumifyConfig.KEY) private readonly config: EnumifyConfig
) {}
async onApplicationBootstrap() {
try {
const enumUpdateFromDbOnStart = this.config.shouldUpdateEnumFromDbOnStartup;
if (!enumUpdateFromDbOnStart) return;
const errors = [];
for (const dataSource of this.datasources) {
const logging = dataSource.options.logging;
dataSource.setOptions({ logging: false });
for (const _registeredEnum of get(registeredEnums, dataSource.name, [])) {
const registeredEnum = _registeredEnum as typeof Enumify;
const repo = dataSource.getRepository(registeredEnum);
const enumValues = registeredEnum.enumValues;
let dbValues: Enumify[];
try {
dbValues = await repo.find();
} catch (err) {
if (
err instanceof QueryFailedError &&
err.message === `Error: Invalid object name '${repo.metadata.tableName}'.`
) {
errors.push(
`[${dataSource.name}] ${registeredEnum.name}: Table present in code but missing on database: ${enumValues}`
);
}
if (err instanceof QueryFailedError && err.message.startsWith('Error: Invalid column name')) {
errors.push(`[${dataSource.name}] ${registeredEnum.name}: [${repo.metadata.tableName}] ${err.message}`);
}
continue;
}
const differenceByDbValues = differenceBy(dbValues, enumValues, (x) => (x.id, x.nome));
const differenceByEnumValues = differenceBy(enumValues, dbValues, (x) => (x.id, x.nome));
if (differenceByDbValues.length > 0) {
errors.push(
`[${dataSource.name}] ${
registeredEnum.name
}: Values present on database but missing in code: ${differenceBy(
dbValues,
enumValues,
(x) => (x.id, x.nome)
)}`
);
}
if (differenceByEnumValues.length > 0) {
errors.push(
`[${dataSource.name}] ${
registeredEnum.name
}: Values present in code but missing in database: ${differenceBy(
enumValues,
dbValues,
(x) => (x.id, x.nome)
)}`
);
}
for (const dbValue of dbValues) {
const valueOfByName = registeredEnum.fromKey(dbValue.nome!);
const keyOfByCode = registeredEnum.fromValue(dbValue.id!);
if (valueOfByName != null && dbValue.id != valueOfByName?.id) {
errors.push(
`[${dataSource.name}] ${registeredEnum.name}: Different values between database (${dbValue.id}, ${dbValue.nome}) and code (${valueOfByName.id}, ${valueOfByName.nome})`
);
} else if (keyOfByCode != null && dbValue.nome != keyOfByCode?.nome) {
errors.push(
`[${dataSource.name}] ${registeredEnum.name}: Different values between database (${dbValue.id}, ${dbValue.nome}) and code (${keyOfByCode.id}, ${keyOfByCode.nome})`
);
} else if (valueOfByName != null || keyOfByCode != null) {
const enumValue = (valueOfByName ?? keyOfByCode)!;
Object.assign(enumValue, dbValue);
}
}
}
dataSource.setOptions({ logging: logging });
}
if (errors.length > 0) {
throw new Error(errors.join('\n\t* '));
}
} catch (err) {
if (err instanceof Error) {
console.warn(err.message, EnumifyUpdateFromDatabaseService.name);
} else {
throw err;
}
}
}
}

View File

@ -0,0 +1,17 @@
import coerceRecordTypes from '../config/utils/coerce-record-types';
import { registerAs } from '@nestjs/config';
import { z } from 'zod';
export const enumifySchema = z.object({
shouldUpdateEnumFromDbOnStartup: z.boolean().default(false),
});
export type EnumifyConfig = z.infer<typeof enumifySchema>;
export const enumifyConfig = registerAs('enumify', () => {
const env = coerceRecordTypes(process.env);
const config: EnumifyConfig = enumifySchema.strict().parse({
shouldUpdateEnumFromDbOnStartup: env['ENUM_UPDATE_FROM_DB_ON_START']
});
return config;
});

View File

@ -0,0 +1,12 @@
import { Module, Global } from '@nestjs/common';
import { EnumifyUpdateFromDatabaseService } from './enumify-update-from-database.service';
import { ConfigModule } from '@nestjs/config';
import { enumifyConfig } from './enumify.config';
@Global()
@Module({
imports: [ConfigModule.forFeature(enumifyConfig)],
providers: [EnumifyUpdateFromDatabaseService],
exports: [EnumifyUpdateFromDatabaseService],
})
export class EnumifyModule {}

View File

@ -0,0 +1,74 @@
// Based on https://github.com/rauschma/enumify
// Original license: MIT License - Copyright (c) 2020 Axel Rauschmayer
export const registeredEnums: Record<string, typeof Enumify[]> = {};
export function Enum(schema: string) {
return function (target: typeof Enumify) {
registeredEnums[schema] ??= [];
registeredEnums[schema].push(target);
};
}
export class Enumify {
static enumKeys: Array<string>;
static enumValues: Array<Enumify>;
public readonly id?: number;
public readonly nome?: string;
static closeEnum() {
const enumKeys: Array<string> = [];
const enumValues: Array<Enumify> = [];
for (const [key, value] of Object.entries(this)) {
value.nome ??= key;
value.id ??= enumValues.length;
if (value.id == null || value.id === '') {
throw new Error(`${this.name}.id`);
}
if (value.nome == null || value.nome === '') {
throw new Error(`${this.name}.nome`);
}
enumKeys.push(value.nome);
enumValues.push(value);
}
this.enumKeys = enumKeys;
this.enumValues = enumValues;
}
static fromKey(key: string): undefined | Enumify {
if (this.enumKeys == undefined) {
throw new Error(`Did you forget to call static _ = ${this.name}.closeEnum() after setup?`);
}
const index = this.enumKeys.findIndex((enumKey) => enumKey.toUpperCase() === key.toString().toUpperCase());
if (index >= 0) {
return this.enumValues[index];
}
return undefined;
}
static fromValue(value: number): undefined | Enumify {
if (this.enumValues == undefined) {
throw new Error(`Did you forget to call static _ = ${this.name}.closeEnum() after setup?`);
}
const index = this.enumValues.map((x) => x.id).indexOf(value);
if (index >= 0) {
const key = this.enumKeys[index];
return this.fromKey(key);
}
return undefined;
}
protected constructor(id?: number, nome?: string) {
this.id = id;
this.nome = nome;
}
toString() {
return `(${this.id}, ${this.nome})`;
}
}

View File

@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { FileTransactionsService } from './file-transactions.service';
import { KeyvModule } from '../keyv/keyv.module'; import { KeyvModule } from '../keyv/keyv.module';
import { FileTransactionsService } from './file-transactions.service';
@Global() @Global()
@Module({ @Module({

View File

@ -1,8 +1,10 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { keyvConfig } from './keyv.config';
import { KeyvService } from './keyv.service'; import { KeyvService } from './keyv.service';
@Module({ @Module({
imports: [], imports: [ConfigModule.forFeature(keyvConfig)],
providers: [KeyvService], providers: [KeyvService],
exports: [KeyvService], exports: [KeyvService],
}) })

View File

@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
import { NestApplication } from '@nestjs/core'; import { NestApplication } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { LoggerConfig, loggerConfig } from './logger.config'; import { LoggerConfig, loggerConfig } from './logger.config';
import { ConfigModule } from '@nestjs/config';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forFeature(loggerConfig),
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
imports: loggerConfig.asProvider().imports,
useFactory: (loggerConfig: LoggerConfig) => ({ useFactory: (loggerConfig: LoggerConfig) => ({
pinoHttp: { pinoHttp: {
autoLogging: true, autoLogging: true,