@mikro-orm/reflection
Version:
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 JavaScript.
254 lines (253 loc) • 11.7 kB
JavaScript
import { extname } from 'node:path';
import { ModuleKind, Project } from 'ts-morph';
import { EntitySchema, MetadataError, MetadataProvider, MetadataStorage, RawQueryFragment, ReferenceKind, Type, Utils, } from '@mikro-orm/core';
import { fs } from '@mikro-orm/core/fs-utils';
/** Metadata provider that uses ts-morph to infer property types from TypeScript source files or declaration files. */
export class TsMorphMetadataProvider extends MetadataProvider {
project;
sources;
static useCache() {
return true;
}
useCache() {
return this.config.get('metadataCache').enabled ?? TsMorphMetadataProvider.useCache();
}
loadEntityMetadata(meta) {
if (!meta.path) {
return;
}
this.initProperties(meta);
}
getExistingSourceFile(path, ext, validate = true) {
if (!ext) {
return this.getExistingSourceFile(path, '.d.ts', false) || this.getExistingSourceFile(path, '.ts');
}
const tsPath = /.*\/[^/]+$/.exec(path)[0].replace(/\.js$/, ext);
return this.getSourceFile(tsPath, validate);
}
initProperties(meta) {
meta.path = fs.normalizePath(meta.path);
// load types and column names
for (const prop of Object.values(meta.properties)) {
const { type, target } = this.extractType(meta, prop);
this.initPropertyType(meta, prop);
prop.type = type ?? prop.type;
prop.target = target;
}
}
extractType(meta, prop) {
/* v8 ignore next */
if (typeof prop.entity === 'string') {
throw new Error(`Relation target needs to be an entity class or EntitySchema instance, '${prop.entity}' given instead for ${meta.className}.${prop.name}.`);
}
if (!prop.entity) {
return { type: prop.type };
}
const tmp = prop.entity();
const target = EntitySchema.is(tmp) ? tmp.meta.class : tmp;
return { type: Utils.className(target), target };
}
cleanUpTypeTags(type) {
const genericTags = [/Opt<(.*?)>/, /Hidden<(.*?)>/, /RequiredNullable<(.*?)>/];
const intersectionTags = ['Opt.Brand', 'Hidden.Brand', 'RequiredNullable.Brand'];
for (const tag of genericTags) {
type = type.replace(tag, '$1');
}
for (const tag of intersectionTags) {
type = type.replace(' & ' + tag, '');
type = type.replace(tag + ' & ', '');
}
return type;
}
initPropertyType(meta, prop) {
const { type: typeRaw, optional } = this.readTypeFromSource(meta, prop);
if (typeRaw != null) {
prop.type = this.cleanUpTypeTags(typeRaw);
}
if (optional) {
prop.optional = true;
}
this.processWrapper(prop, 'Ref');
this.processWrapper(prop, 'Reference');
this.processWrapper(prop, 'EntityRef');
this.processWrapper(prop, 'ScalarRef');
this.processWrapper(prop, 'ScalarReference');
// `LazyRef<T>` is a type-only marker — unwrap the type for metadata but do NOT set `ref: true`
// (there is no `Reference` wrapper at runtime for `LazyRef`). If the user also set `ref: true`
// explicitly via options, that's a contradiction — the property type promises a plain entity
// with PK access, but `ref: true` would produce a `Reference` wrapper at runtime.
const hadLazyRef = /(?:^|[.( ])LazyRef</.test(prop.type.replace(/import\(.*\)\./g, ''));
this.processWrapper(prop, 'LazyRef');
if (hadLazyRef && prop.ref) {
throw new MetadataError(`Property '${meta.className}.${prop.name}' is typed as 'LazyRef<T>' but also has 'ref: true' set — these are incompatible. Remove 'ref: true' to keep 'LazyRef' semantics, or change the type to 'Ref<T>'.`);
}
this.processWrapper(prop, 'Collection');
prop.runtimeType ??= prop.type;
if (/^(Dictionary|Record)<.*>$/.exec(prop.type.replace(/import\(.*\)\./g, ''))) {
prop.type = 'json';
}
}
readTypeFromSource(meta, prop) {
const source = this.getExistingSourceFile(meta.path);
const cls = source.getClass(meta.className);
/* v8 ignore next */
if (!cls) {
throw new MetadataError(`Source class for entity ${meta.className} not found. Verify you have 'compilerOptions.declaration' enabled in your 'tsconfig.json'. If you are using webpack, see https://bit.ly/35pPDNn`);
}
const property = (cls.getInstanceProperty(prop.name) ??
// fallback to the type checker for inherited properties (e.g. with TC39/ES decorators)
cls.getType().getProperty(prop.name)?.getDeclarations()?.[0]);
if (!property) {
return { type: prop.type, optional: prop.nullable };
}
const tsType = property.getType();
const typeName = tsType.getText(property);
if (prop.enum && tsType.isEnum()) {
prop.items = tsType.getUnionTypes().map(t => t.getLiteralValueOrThrow());
}
if (tsType.isArray()) {
prop.array = true;
/* v8 ignore next */
if (tsType.getArrayElementType().isEnum()) {
prop.items = tsType
.getArrayElementType()
.getUnionTypes()
.map(t => t.getLiteralValueOrThrow());
}
}
let type = typeName;
const union = type.split(' | ');
const optional = property.hasQuestionToken?.() || union.includes('null') || union.includes('undefined') || tsType.isNullable();
type = union.filter(t => !['null', 'undefined'].includes(t)).join(' | ');
prop.array ??= type.endsWith('[]') || !!/Array<(.*)>/.exec(type);
if (prop.array && prop.enum) {
prop.enum = false;
}
type = type
.replace(/Array<(.*)>/, '$1') // unwrap array
.replace(/\[]$/, '') // remove array suffix
.replace(/\((.*)\)/, '$1'); // unwrap union types
// keep the array suffix in the type, it is needed in few places in discovery and comparator (`prop.array` is used only for enum arrays)
if (prop.array && !type.includes(' | ') && prop.kind === ReferenceKind.SCALAR) {
type += '[]';
}
return { type, optional };
}
getSourceFile(tsPath, validate) {
if (!this.sources) {
this.initSourceFiles();
}
const baseDir = this.config.get('baseDir');
const outDir = this.project.getCompilerOptions().outDir;
let path = tsPath;
/* v8 ignore next */
if (outDir != null) {
const outDirRelative = fs.relativePath(outDir, baseDir);
path = path.replace(new RegExp(`^${outDirRelative}`), '');
}
path = this.stripRelativePath(path);
const source = this.sources.find(s => s.getFilePath().endsWith(path));
if (!source && validate) {
throw new MetadataError(`Source file '${fs.relativePath(tsPath, baseDir)}' not found. Check your 'entitiesTs' option and verify you have 'compilerOptions.declaration' enabled in your 'tsconfig.json'. If you are using webpack, see https://bit.ly/35pPDNn`);
}
return source;
}
stripRelativePath(str) {
return str.replace(/^(?:\.\.\/|\.\/)+/, '/');
}
processWrapper(prop, wrapper) {
// type can be sometimes in form of:
// `'({ object?: Entity | undefined; } & import("...").Reference<Entity>)'`
// `{ object?: import("...").Entity | undefined; } & import("...").Reference<Entity>`
// `{ node?: ({ id?: number | undefined; } & import("...").Reference<import("...").Entity>) | undefined; } & import("...").Reference<Entity>`
// the regexp is looking for the `wrapper`, possible prefixed with `.` or wrapped in parens.
const type = prop.type.replace(/import\(.*\)\./g, '').replace(/\{ .* } & ([\w &]+)/g, '$1');
const m = new RegExp(`(?:^|[.( ])${wrapper}<(\\w+),?.*>(?:$|[) ])`).exec(type);
if (!m) {
return;
}
prop.type = m[1];
if (['Ref', 'Reference', 'EntityRef', 'ScalarRef', 'ScalarReference'].includes(wrapper)) {
prop.ref = true;
}
}
initProject() {
/* v8 ignore next */
const tsConfigFilePath = this.config.get('discovery').tsConfigPath ?? './tsconfig.json';
try {
this.project = new Project({
tsConfigFilePath: fs.normalizePath(process.cwd(), tsConfigFilePath),
skipAddingFilesFromTsConfig: true,
compilerOptions: {
strictNullChecks: true,
module: ModuleKind.Node20,
},
});
}
catch (e) {
this.config.getLogger().warn('discovery', e.message);
this.project = new Project({
compilerOptions: {
strictNullChecks: true,
module: ModuleKind.Node20,
},
});
}
}
initSourceFiles() {
this.initProject();
this.sources = [];
// All entity files are first required during the discovery, before we reach here, so it is safe to get the parts from the global
// metadata storage. We know the path thanks to the decorators being executed. In case we are running the TS code, the extension
// will be already `.ts`, so no change is needed. `.js` files will get renamed to `.d.ts` files as they will be used as a source for
// the ts-morph reflection.
for (const meta of Utils.values(MetadataStorage.getMetadata())) {
const metaPath = fs.normalizePath(meta.path);
/* v8 ignore next */
const path = /\.[jt]s$/.exec(metaPath) ? metaPath.replace(/\.js$/, '.d.ts') : `${metaPath}.d.ts`; // when entities are bundled, their paths are just their names
const sourceFile = this.project.addSourceFileAtPathIfExists(path);
if (sourceFile) {
this.sources.push(sourceFile);
}
}
}
saveToCache(meta) {
if (!this.useCache()) {
return;
}
Reflect.deleteProperty(meta, 'root'); // to allow caching (as root can contain cycles)
const copy = Utils.copy(meta, false);
for (const prop of copy.props) {
if (Type.isMappedType(prop.type)) {
Reflect.deleteProperty(prop, 'type');
Reflect.deleteProperty(prop, 'customType');
}
if (prop.default) {
const raw = RawQueryFragment.getKnownFragment(prop.default);
if (raw) {
prop.defaultRaw ??= this.config.getPlatform().formatQuery(raw.sql, raw.params);
Reflect.deleteProperty(prop, 'default');
}
}
Reflect.deleteProperty(prop, 'targetMeta');
}
[
'prototype',
'props',
'referencingProperties',
'propertyOrder',
'relations',
'concurrencyCheckKeys',
'checks',
].forEach(key => delete copy[key]);
// base entity without properties might not have path, but nothing to cache there
if (meta.path) {
meta.path = fs.relativePath(meta.path, this.config.get('baseDir'));
this.config.getMetadataCacheAdapter().set(this.getCacheKey(meta), copy, meta.path);
}
}
getCacheKey(meta) {
/* v8 ignore next */
return meta.className + (meta.path ? extname(meta.path) : '');
}
}