diff --git a/apps/ebitemp-api/src/app/app.module.ts b/apps/ebitemp-api/src/app/app.module.ts index e765a5f..e35b0c2 100644 --- a/apps/ebitemp-api/src/app/app.module.ts +++ b/apps/ebitemp-api/src/app/app.module.ts @@ -4,9 +4,10 @@ import { AppService } from './app.service'; import { AppConfigModule } from './modules/config/config.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 { AppLoggerModule } from './modules/logger/logger.module'; import { AppHealthModule } from './modules/health/health.module'; +import { AppLoggerModule } from './modules/logger/logger.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { AppHealthModule } from './modules/health/health.module'; AppDatabaseModule, AppHealthModule, AppFileTransactionsModule, + EnumifyModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/ebitemp-api/src/app/modules/config/config.module.ts b/apps/ebitemp-api/src/app/modules/config/config.module.ts index 2160bde..0816843 100644 --- a/apps/ebitemp-api/src/app/modules/config/config.module.ts +++ b/apps/ebitemp-api/src/app/modules/config/config.module.ts @@ -1,16 +1,12 @@ import { Module } from '@nestjs/common'; 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'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - expandVariables: true, - load: [localConfig, loggerConfig, databaseConfig, keyvConfig], + load: [localConfig], }), ], }) diff --git a/apps/ebitemp-api/src/app/modules/database/database.module.ts b/apps/ebitemp-api/src/app/modules/database/database.module.ts index b1e526e..dd9b74c 100644 --- a/apps/ebitemp-api/src/app/modules/database/database.module.ts +++ b/apps/ebitemp-api/src/app/modules/database/database.module.ts @@ -1,24 +1,24 @@ import { Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { APP_DATASOURCES } from './database.constants'; import { DatabaseConfig, databaseConfig } from './database.config'; +import { APP_DATASOURCES } from './database.constants'; import { typeormTransactionalDataSourceFactory } from './utils/typeorm-data-source-factory'; -import { typeormModuleOptionsFactory } from './utils/typeorm-module-options-factory'; import { typeormEntitiesFromImport } from './utils/typeorm-import-entities'; +import { typeormModuleOptionsFactory } from './utils/typeorm-module-options-factory'; +import { ConfigModule } from '@nestjs/config'; const dataSources: DataSource[] = []; const typeormModules = [ TypeOrmModule.forRootAsync({ - dataSourceFactory: typeormTransactionalDataSourceFactory(dataSources), + imports: databaseConfig.asProvider().imports, + dataSourceFactory: typeormTransactionalDataSourceFactory(), useFactory: async (dbConfig: DatabaseConfig) => { // const entities = await import('./path/to/entities'); const entities = {}; - const config = await typeormModuleOptionsFactory(dbConfig, [ - typeormEntitiesFromImport(entities), - ]); + const config = await typeormModuleOptionsFactory(dbConfig, [typeormEntitiesFromImport(entities)]); return config; }, inject: [databaseConfig.KEY], @@ -32,7 +32,7 @@ const dataSourcesProvider = { @Global() @Module({ - imports: [...typeormModules], + imports: [ConfigModule.forFeature(databaseConfig), ...typeormModules], providers: [dataSourcesProvider], exports: [...typeormModules, dataSourcesProvider], }) diff --git a/apps/ebitemp-api/src/app/modules/database/utils/typeorm-data-source-factory.ts b/apps/ebitemp-api/src/app/modules/database/utils/typeorm-data-source-factory.ts index b210302..fbd50ac 100644 --- a/apps/ebitemp-api/src/app/modules/database/utils/typeorm-data-source-factory.ts +++ b/apps/ebitemp-api/src/app/modules/database/utils/typeorm-data-source-factory.ts @@ -1,5 +1,7 @@ import { DataSource, DataSourceOptions } from 'typeorm'; +export const dataSources: DataSource[] = []; + export const typeormDataSourceFactory = (dataSources: DataSource[]) => async (options?: DataSourceOptions) => { const dataSource = await new DataSource(options!).initialize(); dataSources.push(dataSource); @@ -7,7 +9,7 @@ export const typeormDataSourceFactory = (dataSources: DataSource[]) => async (op }; export const typeormTransactionalDataSourceFactory = - (dataSources: DataSource[], name?: string) => async (options?: DataSourceOptions) => { + (name?: string) => async (options?: DataSourceOptions) => { const tt = await import('typeorm-transactional'); const dataSource = tt.addTransactionalDataSource({ name: name, dataSource: new DataSource(options!) }); dataSources.push(dataSource); diff --git a/apps/ebitemp-api/src/app/modules/enumify/enumify-update-from-database.service.ts b/apps/ebitemp-api/src/app/modules/enumify/enumify-update-from-database.service.ts new file mode 100644 index 0000000..8a50b4e --- /dev/null +++ b/apps/ebitemp-api/src/app/modules/enumify/enumify-update-from-database.service.ts @@ -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; + } + } + } +} diff --git a/apps/ebitemp-api/src/app/modules/enumify/enumify.config.ts b/apps/ebitemp-api/src/app/modules/enumify/enumify.config.ts new file mode 100644 index 0000000..6426883 --- /dev/null +++ b/apps/ebitemp-api/src/app/modules/enumify/enumify.config.ts @@ -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; + +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; +}); diff --git a/apps/ebitemp-api/src/app/modules/enumify/enumify.module.ts b/apps/ebitemp-api/src/app/modules/enumify/enumify.module.ts new file mode 100644 index 0000000..d9b0927 --- /dev/null +++ b/apps/ebitemp-api/src/app/modules/enumify/enumify.module.ts @@ -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 {} diff --git a/apps/ebitemp-api/src/app/modules/enumify/enumify.ts b/apps/ebitemp-api/src/app/modules/enumify/enumify.ts new file mode 100644 index 0000000..eb44f55 --- /dev/null +++ b/apps/ebitemp-api/src/app/modules/enumify/enumify.ts @@ -0,0 +1,74 @@ +// Based on https://github.com/rauschma/enumify +// Original license: MIT License - Copyright (c) 2020 Axel Rauschmayer + +export const registeredEnums: Record = {}; +export function Enum(schema: string) { + return function (target: typeof Enumify) { + registeredEnums[schema] ??= []; + registeredEnums[schema].push(target); + }; +} + +export class Enumify { + static enumKeys: Array; + static enumValues: Array; + + public readonly id?: number; + public readonly nome?: string; + + static closeEnum() { + const enumKeys: Array = []; + const enumValues: Array = []; + + 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})`; + } +} diff --git a/apps/ebitemp-api/src/app/modules/file-transactions/file-transactions.module.ts b/apps/ebitemp-api/src/app/modules/file-transactions/file-transactions.module.ts index 8d4c27c..87caeac 100644 --- a/apps/ebitemp-api/src/app/modules/file-transactions/file-transactions.module.ts +++ b/apps/ebitemp-api/src/app/modules/file-transactions/file-transactions.module.ts @@ -1,6 +1,6 @@ import { Global, Module } from '@nestjs/common'; -import { FileTransactionsService } from './file-transactions.service'; import { KeyvModule } from '../keyv/keyv.module'; +import { FileTransactionsService } from './file-transactions.service'; @Global() @Module({ diff --git a/apps/ebitemp-api/src/app/modules/keyv/keyv.module.ts b/apps/ebitemp-api/src/app/modules/keyv/keyv.module.ts index e308b72..5e639ed 100644 --- a/apps/ebitemp-api/src/app/modules/keyv/keyv.module.ts +++ b/apps/ebitemp-api/src/app/modules/keyv/keyv.module.ts @@ -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'; @Module({ - imports: [], + imports: [ConfigModule.forFeature(keyvConfig)], providers: [KeyvService], exports: [KeyvService], }) diff --git a/apps/ebitemp-api/src/app/modules/logger/logger.module.ts b/apps/ebitemp-api/src/app/modules/logger/logger.module.ts index f7e3fca..7551c7a 100644 --- a/apps/ebitemp-api/src/app/modules/logger/logger.module.ts +++ b/apps/ebitemp-api/src/app/modules/logger/logger.module.ts @@ -2,10 +2,13 @@ import { Module } from '@nestjs/common'; import { NestApplication } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; import { LoggerConfig, loggerConfig } from './logger.config'; +import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ + ConfigModule.forFeature(loggerConfig), LoggerModule.forRootAsync({ + imports: loggerConfig.asProvider().imports, useFactory: (loggerConfig: LoggerConfig) => ({ pinoHttp: { autoLogging: true,