quaerateum
Version:
Simple typescript ORM for node.js based on data-mapper, unit-of-work and identity-map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JS.
275 lines (217 loc) • 10.1 kB
text/typescript
import { sync as globby } from 'globby';
import { extname } from 'path';
import { EntityClass, EntityClassGroup, EntityMetadata, EntityProperty, IEntityType } from '../decorators';
import { EntityManager } from '../EntityManager';
import { Configuration, Logger, Utils } from '../utils';
import { MetadataValidator } from './MetadataValidator';
import { MetadataStorage } from './MetadataStorage';
import { Cascade, EntityHelper, ReferenceType } from '../entity';
export class MetadataDiscovery {
private readonly metadata = MetadataStorage.getMetadata();
private readonly namingStrategy = this.config.getNamingStrategy();
private readonly metadataProvider = this.config.getMetadataProvider();
private readonly cache = this.config.getCacheAdapter();
private readonly platform = this.em.getDriver().getPlatform();
private readonly validator = new MetadataValidator();
private readonly discovered: EntityMetadata[] = [];
constructor(private readonly em: EntityManager,
private readonly config: Configuration,
private readonly logger: Logger) { }
async discover(): Promise<Record<string, EntityMetadata>> {
const startTime = Date.now();
this.logger.debug(`ORM entity discovery started`);
this.discovered.length = 0;
const tsNode = process.argv[0].endsWith('ts-node') || process.argv.slice(1).some(arg => arg.includes('ts-node'));
if (this.config.get('entities').length > 0) {
await Promise.all(this.config.get('entities').map(entity => this.discoverEntity(entity)));
} else if (tsNode) {
await Promise.all(this.config.get('entitiesDirsTs').map(dir => this.discoverDirectory(dir)));
} else {
await Promise.all(this.config.get('entitiesDirs').map(dir => this.discoverDirectory(dir)));
}
// ignore base entities (not annotated with @Entity)
const filtered = this.discovered.filter(meta => meta.name);
filtered.forEach(meta => this.defineBaseEntityProperties(meta));
filtered.forEach(meta => this.discovered.push(...this.processEntity(meta)));
const diff = Date.now() - startTime;
this.logger.debug(`- entity discovery finished after ${diff} ms`);
const discovered: Record<string, EntityMetadata> = {};
this.discovered
.filter(meta => meta.name)
.forEach(meta => discovered[meta.name] = meta );
return discovered;
}
private async discoverDirectory(basePath: string): Promise<void> {
const files = globby('*', { cwd: `${this.config.get('baseDir')}/${basePath}` });
this.logger.debug(`- processing ${files.length} files from directory ${basePath}`);
for (const file of files) {
if (
!file.match(/\.[jt]s$/) ||
file.endsWith('.js.map') ||
file.endsWith('.d.ts') ||
file.startsWith('.') ||
file.match(/index\.[jt]s$/)
) {
this.logger.debug(`- ignoring file ${file}`);
continue;
}
const name = this.getClassName(file);
const path = `${this.config.get('baseDir')}/${basePath}/${file}`;
const target = require(path)[name]; // include the file to trigger loading of metadata
await this.discoverEntity(target, path);
}
}
private async discoverEntity<T extends IEntityType<T>>(entity: EntityClass<T> | EntityClassGroup<T>, path?: string): Promise<void> {
entity = this.metadataProvider.prepare(entity);
this.logger.debug(`- processing entity ${entity.name}`);
const meta = MetadataStorage.getMetadata(entity.name);
meta.prototype = entity.prototype;
meta.path = path || meta.path;
meta.toJsonParams = Utils.getParamNames(entity.prototype.toJSON || '').filter(p => p !== '...args');
const cache = meta.path && this.cache.get(entity.name + extname(meta.path));
if (cache) {
this.logger.debug(`- using cached metadata for entity ${entity.name}`);
this.metadataProvider.loadFromCache(meta, cache);
this.discovered.push(meta);
return;
}
await this.metadataProvider.loadEntityMetadata(meta, entity.name);
if (!meta.collection && meta.name) {
meta.collection = this.namingStrategy.classToTableName(meta.name);
}
this.saveToCache(meta, entity);
this.discovered.push(meta);
}
private saveToCache<T extends IEntityType<T>>(meta: EntityMetadata, entity: EntityClass<T>): void {
const copy = Object.assign({}, meta);
delete copy.prototype;
// base entity without properties might not have path, but nothing to cache there
if (meta.path) {
this.cache.set(entity.name + extname(meta.path), copy, meta.path);
}
}
private applyNamingStrategy(meta: EntityMetadata, prop: EntityProperty): void {
if (!prop.fieldName) {
this.initFieldName(prop);
}
if (prop.reference === ReferenceType.MANY_TO_MANY) {
this.initManyToManyFields(meta, prop);
}
if (prop.reference === ReferenceType.ONE_TO_MANY || prop.reference === ReferenceType.ONE_TO_ONE) {
this.initOneToManyFields(meta, prop);
}
}
private initFieldName(prop: EntityProperty): void {
if (prop.reference === ReferenceType.SCALAR) {
prop.fieldName = this.namingStrategy.propertyToColumnName(prop.name);
} else if (prop.reference === ReferenceType.MANY_TO_ONE || prop.reference === ReferenceType.ONE_TO_ONE) {
prop.fieldName = this.initManyToOneFieldName(prop, prop.name);
} else if (prop.reference === ReferenceType.MANY_TO_MANY && prop.owner) {
prop.fieldName = this.namingStrategy.propertyToColumnName(prop.name);
}
}
private initManyToOneFieldName(prop: EntityProperty, name: string): string {
const meta2 = this.metadata[prop.type];
const referenceColumnName = meta2.properties[meta2.primaryKey].fieldName;
return this.namingStrategy.joinKeyColumnName(name, referenceColumnName);
}
private initManyToManyFields(meta: EntityMetadata, prop: EntityProperty): void {
if (!prop.pivotTable && prop.owner) {
prop.pivotTable = this.namingStrategy.joinTableName(meta.name, prop.type, prop.name);
}
if (!prop.referenceColumnName) {
prop.referenceColumnName = meta.properties[meta.primaryKey].fieldName;
}
if (!prop.inverseJoinColumn) {
prop.inverseJoinColumn = this.initManyToOneFieldName(prop, prop.type);
}
if (!prop.joinColumn) {
prop.joinColumn = this.namingStrategy.joinKeyColumnName(meta.name, prop.referenceColumnName);
}
}
private initOneToManyFields(meta: EntityMetadata, prop: EntityProperty): void {
if (!prop.joinColumn) {
prop.joinColumn = this.namingStrategy.joinColumnName(prop.name);
}
if (prop.reference === ReferenceType.ONE_TO_ONE && !prop.inverseJoinColumn && prop.mappedBy) {
prop.inverseJoinColumn = this.metadata[prop.type].properties[prop.mappedBy].fieldName;
}
if (!prop.referenceColumnName) {
prop.referenceColumnName = meta.properties[meta.primaryKey].fieldName;
}
}
private processEntity(meta: EntityMetadata): EntityMetadata[] {
this.defineBaseEntityProperties(meta);
// BC with 1:m `fk` option
Object.values(meta.properties)
.filter(prop => prop.reference === ReferenceType.ONE_TO_MANY)
.forEach(prop => Utils.renameKey(prop, 'fk', 'mappedBy'));
this.validator.validateEntityDefinition(this.metadata, meta.name);
Object.values(meta.properties).forEach(prop => this.applyNamingStrategy(meta, prop));
meta.serializedPrimaryKey = this.platform.getSerializedPrimaryKeyField(meta.primaryKey);
if (!Utils.isEntity(meta.prototype)) {
EntityHelper.decorate(meta, this.em);
}
const ret: EntityMetadata[] = [];
if (this.platform.usesPivotTable()) {
Object.values(meta.properties).forEach(prop => {
const pivotMeta = this.definePivotTableEntity(meta, prop);
if (pivotMeta) {
ret.push(pivotMeta);
}
});
}
return ret;
}
private definePivotTableEntity(meta: EntityMetadata, prop: EntityProperty): EntityMetadata | undefined {
if (prop.reference === ReferenceType.MANY_TO_MANY && prop.owner && prop.pivotTable) {
const pk = this.namingStrategy.referenceColumnName();
const primaryProp = { name: pk, type: 'number', reference: ReferenceType.SCALAR, primary: true } as EntityProperty;
this.initFieldName(primaryProp);
return this.metadata[prop.pivotTable] = {
name: prop.pivotTable,
collection: prop.pivotTable,
primaryKey: pk,
properties: {
[pk]: primaryProp,
[meta.name]: this.definePivotProperty(prop, meta.name),
[prop.type]: this.definePivotProperty(prop, prop.type),
},
} as EntityMetadata;
}
}
private definePivotProperty(prop: EntityProperty, name: string): EntityProperty {
const ret = { name, type: name, reference: ReferenceType.MANY_TO_ONE, cascade: [Cascade.ALL] } as EntityProperty;
if (name === prop.type) {
const meta = this.metadata[name];
const prop2 = meta.properties[meta.primaryKey];
if (!prop2.fieldName) {
this.initFieldName(prop2);
}
ret.referenceColumnName = prop2.fieldName;
ret.fieldName = ret.joinColumn = prop.inverseJoinColumn;
ret.inverseJoinColumn = prop.joinColumn;
} else {
ret.referenceColumnName = prop.referenceColumnName;
ret.fieldName = ret.joinColumn = prop.joinColumn;
ret.inverseJoinColumn = prop.inverseJoinColumn;
}
return ret;
}
private getClassName(file: string): string {
const name = file.split('.')[0];
const ret = name.replace(/-(\w)/, m => m[1].toUpperCase());
return ret.charAt(0).toUpperCase() + ret.slice(1);
}
private defineBaseEntityProperties(meta: EntityMetadata): void {
const base = this.metadata[meta.extends];
if (!meta.extends || !base) {
return;
}
meta.properties = { ...base.properties, ...meta.properties };
const primary = Object.values(meta.properties).find(p => p.primary);
if (primary && !meta.primaryKey) {
meta.primaryKey = primary.name;
}
}
}