@itwin/core-backend
Version:
iTwin.js backend components
332 lines • 17.8 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module Schema
*/
import { DbResult, Id64, IModelStatus, Logger } from "@itwin/core-bentley";
import { IModelError } from "@itwin/core-common";
import { Entity } from "./Entity";
import { Schema, Schemas } from "./Schema";
import { EntityReferences } from "./EntityReferences";
import * as assert from "assert";
import { _nativeDb } from "./internal/Symbols";
const isGeneratedClassTag = Symbol("isGeneratedClassTag");
/** Maintains the mapping between the name of a BIS [ECClass]($ecschema-metadata) (in "schema:class" format) and the JavaScript [[Entity]] class that implements it.
* @public
*/
export class EntityJsClassMap {
_classMap = new Map();
/** @internal */
has(classFullName) {
return this._classMap.has(classFullName.toLowerCase());
}
/** @internal */
get(classFullName) {
return this._classMap.get(classFullName.toLowerCase());
}
/** @internal */
set(classFullName, entityClass) {
this._classMap.set(classFullName.toLowerCase(), entityClass);
}
/** @internal */
delete(classFullName) {
return this._classMap.delete(classFullName.toLowerCase());
}
/** @internal */
clear() {
this._classMap.clear();
}
/** @internal */
[Symbol.iterator]() {
return this._classMap[Symbol.iterator]();
}
/**
* Registers a single `entityClass` defined in the specified `schema`.
* This method registers the class globally. To register a class for a specific iModel, use [[IModelDb.jsClassMap]].
*
* @param entityClass - The JavaScript class that implements the BIS [ECClass](@itwin/core-common) to be registered.
* @param schema - The schema that contains the `entityClass`.
*
* @throws Error if the class is already registered.
*
* @public
*/
register(entityClass, schema) {
const key = (`${schema.schemaName}:${entityClass.className}`).toLowerCase();
if (this.has(key)) {
const errMsg = `Class ${key} is already registered. Make sure static className member is correct on JavaScript class ${entityClass.name}`;
Logger.logError("core-frontend.classRegistry", errMsg);
throw new Error(errMsg);
}
entityClass.schema = schema;
this.set(key, entityClass);
}
}
/** Maintains the mapping between the name of a BIS [ECClass]($ecschema-metadata) (in "schema:class" format) and the JavaScript [[Entity]] class that implements it.
* Applications or modules that supply their own Entity subclasses should use [[registerModule]] or [[register]] at startup
* to establish their mappings.
*
* When creating custom Entity subclasses for registration, you should:
* - Override the `className` property to match your ECClass name:
* ```typescript
* public static override get className() { return "TestElement"; }
* ```
* - Do NOT override `schemaName` or `schema` - these will be wired up automatically during registration
*
* @public
*/
export class ClassRegistry {
static _globalClassMap = new EntityJsClassMap();
/** @internal */
static isNotFoundError(err) { return (err instanceof IModelError) && (err.errorNumber === IModelStatus.NotFound); }
/** @internal */
static makeMetaDataNotFoundError(className) { return new IModelError(IModelStatus.NotFound, `metadata not found for ${className}`); }
/** Register a single `entityClass` defined in the specified `schema`.
* @see [[registerModule]] to register multiple classes.
* @public
*/
static register(entityClass, schema) {
this._globalClassMap.register(entityClass, schema);
}
/** Generate a proxy Schema for a domain that has not been registered. */
static generateProxySchema(domain, iModel) {
const hasBehavior = iModel.withPreparedSqliteStatement(`
SELECT NULL FROM [ec_CustomAttribute] [c]
JOIN [ec_schema] [s] ON [s].[Id] = [c].[ContainerId]
JOIN [ec_class] [e] ON [e].[Id] = [c].[ClassId]
JOIN [ec_schema] [b] ON [e].[SchemaId] = [b].[Id]
WHERE [c].[ContainerType] = 1 AND [s].[Name] = ? AND [b].[Name] || '.' || [e].[name] = ?`, (stmt) => {
stmt.bindString(1, domain);
stmt.bindString(2, "BisCore.SchemaHasBehavior");
return stmt.step() === DbResult.BE_SQLITE_ROW;
});
const schemaClass = class extends Schema {
static get schemaName() { return domain; }
static get missingRequiredBehavior() { return hasBehavior; }
};
iModel.schemaMap.registerSchema(schemaClass); // register the class before we return it.
return schemaClass;
}
/** First, finds the root BisCore entity class for an entity, by traversing base classes and mixin targets (AppliesTo).
* Then, gets its metadata and returns that.
* @param iModel - iModel containing the metadata for this type
* @param ecTypeQualifier - a full name of an ECEntityClass to find the root of
* @returns the qualified full name of an ECEntityClass
* @internal public for testing only
*/
static getRootEntity(iModel, ecTypeQualifier) {
const [classSchema, className] = ecTypeQualifier.split(".");
const schemaItemJson = iModel[_nativeDb].getSchemaItem(classSchema, className);
if (schemaItemJson.error)
throw new IModelError(schemaItemJson.error.status, `failed to get schema item '${ecTypeQualifier}'`);
assert(undefined !== schemaItemJson.result);
const schemaItem = JSON.parse(schemaItemJson.result);
if (!("appliesTo" in schemaItem) && schemaItem.baseClass === undefined) {
return ecTypeQualifier;
}
// typescript doesn't understand that the inverse of the above condition is
// ("appliesTo" in rootclassMetaData || rootClassMetaData.baseClass !== undefined)
const parentItemQualifier = schemaItem.appliesTo ?? schemaItem.baseClass;
return this.getRootEntity(iModel, parentItemQualifier);
}
/** Generate a JavaScript class from Entity metadata.
* @param entityMetaData The Entity metadata that defines the class
*/
// eslint-disable-next-line @typescript-eslint/no-deprecated
static generateClassForEntity(entityMetaData, iModel) {
const name = entityMetaData.ecclass.split(":");
const domainName = name[0];
const className = name[1];
if (0 === entityMetaData.baseClasses.length) // metadata must contain a superclass
throw new IModelError(IModelStatus.BadArg, `class ${name} has no superclass`);
// make sure schema exists
let schema = iModel.schemaMap.get(domainName) ?? Schemas.getRegisteredSchema(domainName);
if (undefined === schema)
schema = this.generateProxySchema(domainName, iModel); // no schema found, create it too
const superClassFullName = entityMetaData.baseClasses[0].toLowerCase();
const superclass = iModel.jsClassMap.get(superClassFullName) ?? this._globalClassMap.get(superClassFullName);
if (undefined === superclass)
throw new IModelError(IModelStatus.NotFound, `cannot find superclass for class ${name}`);
// user defined class hierarchies may skip a class in the hierarchy, and therefore their JS base class cannot
// be used to tell if there are any generated classes in the hierarchy
let generatedClassHasNonGeneratedNonCoreAncestor = false;
let currentSuperclass = superclass;
const MAX_ITERS = 1000;
for (let i = 0; i < MAX_ITERS; ++i) {
if (currentSuperclass.schema.schemaName === "BisCore")
break;
if (!currentSuperclass.isGeneratedClass) {
generatedClassHasNonGeneratedNonCoreAncestor = true;
break;
}
// eslint-disable-next-line @typescript-eslint/no-deprecated
const superclassMetaData = iModel.classMetaDataRegistry.find(currentSuperclass.classFullName);
if (superclassMetaData === undefined)
throw new IModelError(IModelStatus.BadSchema, `could not find the metadata for class '${currentSuperclass.name}', class metadata should be loaded by now`);
const maybeNextSuperclass = this.getClass(superclassMetaData.baseClasses[0], iModel);
if (maybeNextSuperclass === undefined)
throw new IModelError(IModelStatus.BadSchema, `could not find the base class of '${currentSuperclass.name}', all generated classes must have a base class`);
currentSuperclass = maybeNextSuperclass;
}
const generatedClass = class extends superclass {
static get className() { return className; }
static [isGeneratedClassTag] = true;
static get isGeneratedClass() { return this.hasOwnProperty(isGeneratedClassTag); }
};
// the above creates an anonymous class. For help debugging, set the "constructor.name" property to be the same as the bisClassName.
Object.defineProperty(generatedClass, "name", { get: () => className }); // this is the (only) way to change that readonly property.
// a class only gets an automatic `collectReferenceIds` implementation if:
// - it is not in the `BisCore` schema
// - there are no ancestors with manually registered JS implementations, (excluding BisCore base classes)
if (!generatedClassHasNonGeneratedNonCoreAncestor) {
const navigationProps = Object.entries(entityMetaData.properties)
.filter(([_name, prop]) => prop.isNavigation)
// eslint-disable-next-line @typescript-eslint/no-shadow
.map(([name, prop]) => {
assert(prop.relationshipClass);
const maybeMetaData = iModel[_nativeDb].getSchemaItem(...prop.relationshipClass.split(":"));
assert(maybeMetaData.result !== undefined, "The nav props relationship metadata was not found");
const relMetaData = JSON.parse(maybeMetaData.result);
const rootClassMetaData = ClassRegistry.getRootEntity(iModel, relMetaData.target.constraintClasses[0]);
// root class must be in BisCore so should be loaded since biscore classes will never get this
// generated implementation
const normalizeClassName = (clsName) => clsName.replace(".", ":");
const rootClass = ClassRegistry.findRegisteredClass(normalizeClassName(rootClassMetaData));
assert(rootClass, `The root class for ${prop.relationshipClass} was not in BisCore.`);
return { name, concreteEntityType: EntityReferences.typeFromClass(rootClass) };
});
Object.defineProperty(generatedClass.prototype, "collectReferenceIds", {
value(referenceIds) {
// eslint-disable-next-line @typescript-eslint/dot-notation
const superImpl = superclass.prototype["collectReferenceIds"];
superImpl.call(this, referenceIds);
for (const navProp of navigationProps) {
const relatedElem = this[navProp.name]; // cast to any since subclass can have any extensions
if (!relatedElem || !Id64.isValid(relatedElem.id))
continue;
const referenceId = EntityReferences.fromEntityType(relatedElem.id, navProp.concreteEntityType);
referenceIds.add(referenceId);
}
},
// defaults for methods on a prototype (required for sinon to stub out methods on tests)
writable: true,
configurable: true,
});
}
// if the schema is a proxy for a domain with behavior, throw exceptions for all protected operations
if (schema.missingRequiredBehavior) {
const throwError = () => {
throw new IModelError(IModelStatus.WrongHandler, `Schema [${domainName}] not registered, but is marked with SchemaHasBehavior`);
};
superclass.protectedOperations.forEach((operation) => generatedClass[operation] = throwError);
}
iModel.jsClassMap.register(generatedClass, schema); // register it before returning
return generatedClass;
}
/** Register all of the classes found in the given module that derive from [[Entity]].
* [[register]] will be invoked for each subclass of `Entity` exported by `moduleObj`.
* @param moduleObj The module to search for subclasses of Entity
* @param schema The schema that contains all of the [ECClass]($ecschema-metadata)es exported by `moduleObj`.
*/
static registerModule(moduleObj, schema) {
for (const thisMember in moduleObj) { // eslint-disable-line guard-for-in
const thisClass = moduleObj[thisMember];
if (thisClass.prototype instanceof Entity)
this.register(thisClass, schema);
}
}
/**
* This function fetches the specified Entity from the imodel, generates a JavaScript class for it, and registers the generated
* class. This function also ensures that all of the base classes of the Entity exist and are registered.
*/
static generateClass(classFullName, iModel) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const metadata = iModel.classMetaDataRegistry.find(classFullName);
if (metadata === undefined || metadata.ecclass === undefined)
throw this.makeMetaDataNotFoundError(classFullName);
// Make sure we have all base classes registered.
if (metadata.baseClasses && (0 !== metadata.baseClasses.length))
this.getClass(metadata.baseClasses[0], iModel);
// Now we can generate the class from the classDef.
return this.generateClassForEntity(metadata, iModel);
}
/** Find a registered class by classFullName.
* @param classFullName class to find
* @param iModel The IModel that contains the class definitions
* @returns The Entity class or undefined
*/
static findRegisteredClass(classFullName) {
return this._globalClassMap.get(classFullName.toLowerCase());
}
/** Get the Entity class for the specified Entity className.
* @param classFullName The full BIS class name of the Entity
* @param iModel The IModel that contains the class definitions
* @returns The Entity class
*/
static getClass(classFullName, iModel) {
const key = classFullName.toLowerCase();
return iModel.jsClassMap.get(key) ?? this._globalClassMap.get(key) ?? this.generateClass(key, iModel);
}
/** Unregister a class, by name, if one is already registered.
* This function is not normally needed, but is useful for cases where a generated *proxy* class needs to be replaced by the *real* class.
* @param classFullName Name of the class to unregister
* @return true if the class was unregistered
* @internal
*/
static unregisterClass(classFullName) { return this._globalClassMap.delete(classFullName.toLowerCase()); }
/** Unregister all classes from a schema.
* This function is not normally needed, but is useful for cases where a generated *proxy* schema needs to be replaced by the *real* schema.
* @param schema Name of the schema to unregister
* @internal
*/
static unregisterClassesFrom(schema) {
for (const entry of Array.from(this._globalClassMap)) {
if (entry[1].schema === schema)
this.unregisterClass(entry[0]);
}
}
}
/**
* A cache that records the mapping between class names and class metadata.
* @see [[IModelDb.classMetaDataRegistry]] to access the registry for a specific iModel.
* @internal
* @deprecated in 5.0 - will not be removed until after 2026-06-13. Please use `schemaContext` from the `iModel` instead.
*
* @example
* @
* Current Usage:
* ```ts
* const metaData: EntityMetaData | undefined = iModel.classMetaDataRegistry.find("SchemaName:ClassName");
* ```
*
* Replacement:
* ```ts
* const entityMetaData: EntityClass | undefined = iModel.schemaContext.getSchemaItemSync("SchemaName.ClassName", EntityClass);
* const relationshipMetaData: RelationshipClass | undefined = iModel.schemaContext.getSchemaItemSync("SchemaName", "ClassName", RelationshipClass);
* ```
*/
export class MetaDataRegistry {
// eslint-disable-next-line @typescript-eslint/no-deprecated
_registry = new Map();
_classIdToName = new Map();
/** Get the specified Entity metadata */
// eslint-disable-next-line @typescript-eslint/no-deprecated
find(classFullName) {
return this._registry.get(classFullName.toLowerCase());
}
// eslint-disable-next-line @typescript-eslint/no-deprecated
findByClassId(classId) {
const name = this._classIdToName.get(classId);
return undefined !== name ? this.find(name) : undefined;
}
/** Add metadata to the cache */
// eslint-disable-next-line @typescript-eslint/no-deprecated
add(classFullName, metaData) {
const name = classFullName.toLowerCase();
this._registry.set(name, metaData);
this._classIdToName.set(metaData.classId, name);
}
}
//# sourceMappingURL=ClassRegistry.js.map