@itwin/core-backend
Version:
iTwin.js backend components
421 lines • 24.9 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 iModels
*/
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
import { assert, DbResult, IModelStatus, Logger } from "@itwin/core-bentley";
import { IModelError, IModelVersion } from "@itwin/core-common";
import * as path from "path";
import { BackendLoggerCategory } from "./BackendLoggerCategory";
import { BriefcaseManager } from "./BriefcaseManager";
import { ECDb, ECDbOpenMode } from "./ECDb";
import { BriefcaseDb } from "./IModelDb";
import { IModelHost, KnownLocations } from "./IModelHost";
import { IModelJsFs } from "./IModelJsFs";
import { _hubAccess, _nativeDb } from "./internal/Symbols";
const loggerCategory = BackendLoggerCategory.ECDb;
/** Class to extract Change Summaries for a briefcase.
*
* See also:
* - [ChangeSummary Overview]($docs/learning/ChangeSummaries)
* @beta
*/
export class ChangeSummaryManager {
static _currentIModelChangeSchemaVersion = { read: 2, write: 0, minor: 0 };
/** Determines whether the *Change Cache file* is attached to the specified iModel or not
* @param iModel iModel to check whether a *Change Cache file* is attached
* @returns Returns true if the *Change Cache file* is attached to the iModel. false otherwise
*/
static isChangeCacheAttached(iModel) {
if (!iModel || !iModel.isOpen)
throw new IModelError(IModelStatus.BadRequest, "Briefcase must be open");
return iModel[_nativeDb].isChangeCacheAttached();
}
/** Attaches the *Change Cache file* to the specified iModel if it hasn't been attached yet.
* A new *Change Cache file* will be created for the iModel if it hasn't existed before.
* @param iModel iModel to attach the *Change Cache file* file to
* @throws [IModelError]($common)
*/
static attachChangeCache(iModel) {
if (!iModel || !iModel.isOpen)
throw new IModelError(IModelStatus.BadRequest, "Briefcase must be open");
if (ChangeSummaryManager.isChangeCacheAttached(iModel))
return;
const changesCacheFilePath = BriefcaseManager.getChangeCachePathName(iModel.iModelId);
if (!IModelJsFs.existsSync(changesCacheFilePath)) {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
const changeCacheFile = __addDisposableResource(env_1, new ECDb(), false);
ChangeSummaryManager.createChangeCacheFile(iModel, changeCacheFile, changesCacheFilePath);
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
assert(IModelJsFs.existsSync(changesCacheFilePath));
const res = iModel[_nativeDb].attachChangeCache(changesCacheFilePath);
if (res !== DbResult.BE_SQLITE_OK)
throw new IModelError(res, `Failed to attach Change Cache file to ${iModel.pathName}.`);
}
/** Detaches the *Change Cache file* from the specified iModel.
* - note that this method will cause any pending (currently running or queued) queries to fail
* @param iModel iModel to detach the *Change Cache file* to
* @throws [IModelError]($common) in case of errors, e.g. if no *Change Cache file* was attached before.
*/
static detachChangeCache(iModel) {
if (!iModel || !iModel.isOpen)
throw new IModelError(IModelStatus.BadRequest, "Briefcase must be open");
iModel.clearCaches();
const res = iModel[_nativeDb].detachChangeCache();
if (res !== DbResult.BE_SQLITE_OK)
throw new IModelError(res, `Failed to detach Change Cache file from ${iModel.pathName}.`);
}
static openOrCreateChangesFile(iModel) {
if (!iModel?.isOpen)
throw new IModelError(IModelStatus.BadArg, "Invalid iModel handle. iModel must be open.");
const changesFile = new ECDb();
const changeCacheFilePath = BriefcaseManager.getChangeCachePathName(iModel.iModelId);
if (IModelJsFs.existsSync(changeCacheFilePath)) {
ChangeSummaryManager.openChangeCacheFile(changesFile, changeCacheFilePath);
return changesFile;
}
try {
ChangeSummaryManager.createChangeCacheFile(iModel, changesFile, changeCacheFilePath);
return changesFile;
}
catch (e) {
// delete cache file again in case it was created but schema import failed
if (IModelJsFs.existsSync(changeCacheFilePath))
IModelJsFs.removeSync(changeCacheFilePath);
throw e;
}
}
static createChangeCacheFile(iModel, changesFile, changeCacheFilePath) {
if (!iModel?.isOpen)
throw new IModelError(IModelStatus.BadArg, "Invalid iModel object. iModel must be open.");
const stat = iModel[_nativeDb].createChangeCache(changesFile[_nativeDb], changeCacheFilePath);
if (stat !== DbResult.BE_SQLITE_OK)
throw new IModelError(stat, `Failed to create Change Cache file at "${changeCacheFilePath}".`);
// Extended information like changeset ids, push dates are persisted in the IModelChange ECSchema
changesFile.importSchema(ChangeSummaryManager.getExtendedSchemaPath());
}
static openChangeCacheFile(changesFile, changeCacheFilePath) {
changesFile.openDb(changeCacheFilePath, ECDbOpenMode.FileUpgrade);
// eslint-disable-next-line @typescript-eslint/no-deprecated
const actualSchemaVersion = changesFile.withPreparedStatement("SELECT VersionMajor read,VersionWrite write,VersionMinor minor FROM meta.ECSchemaDef WHERE Name='IModelChange'", (stmt) => {
if (stmt.step() !== DbResult.BE_SQLITE_ROW)
throw new IModelError(DbResult.BE_SQLITE_ERROR, "File is not a valid Change Cache file.");
return stmt.getRow();
});
if (actualSchemaVersion.read === ChangeSummaryManager._currentIModelChangeSchemaVersion.read &&
actualSchemaVersion.write === ChangeSummaryManager._currentIModelChangeSchemaVersion.write &&
actualSchemaVersion.minor === ChangeSummaryManager._currentIModelChangeSchemaVersion.minor)
return;
changesFile.importSchema(ChangeSummaryManager.getExtendedSchemaPath());
}
static getExtendedSchemaPath() { return path.join(KnownLocations.packageAssetsDir, "IModelChange.02.00.00.ecschema.xml"); }
static isSummaryAlreadyExtracted(changesFile, changeSetId) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
return changesFile.withPreparedStatement("SELECT Summary.Id summaryid FROM imodelchange.ChangeSet WHERE WsgId=?", (stmt) => {
stmt.bindString(1, changeSetId);
if (DbResult.BE_SQLITE_ROW === stmt.step())
return stmt.getValue(0).getId();
return undefined;
});
}
static addExtendedInfos(changesFile, changeSummaryId, changesetWsgId, changesetParentWsgId, description, changesetPushDate, changeSetUserCreated) {
changesFile.withCachedWriteStatement("INSERT INTO imodelchange.ChangeSet(Summary.Id,WsgId,ParentWsgId,Description,PushDate,UserCreated) VALUES(?,?,?,?,?,?)", (stmt) => {
stmt.bindId(1, changeSummaryId);
stmt.bindString(2, changesetWsgId);
if (changesetParentWsgId)
stmt.bindString(3, changesetParentWsgId);
if (description)
stmt.bindString(4, description);
if (changesetPushDate)
stmt.bindDateTime(5, changesetPushDate);
if (changeSetUserCreated)
stmt.bindString(6, changeSetUserCreated);
const r = stmt.stepForInsert();
if (r.status !== DbResult.BE_SQLITE_DONE)
throw new IModelError(r.status, `Failed to add changeset information to extracted change summary ${changeSummaryId}`);
});
}
/** Queries the ChangeSummary for the specified change summary id
*
* See also
* - `ECDbChange.ChangeSummary` ECClass in the *ECDbChange* ECSchema
* - [Change Summary Overview]($docs/learning/ChangeSummaries)
* @param iModel iModel
* @param changeSummaryId ECInstanceId of the ChangeSummary
* @returns Returns the requested ChangeSummary object
* @throws [IModelError]($common) If change summary does not exist for the specified id, or if the
* change cache file hasn't been attached, or in case of other errors.
*/
static queryChangeSummary(iModel, changeSummaryId) {
if (!ChangeSummaryManager.isChangeCacheAttached(iModel))
throw new IModelError(IModelStatus.BadArg, "Change Cache file must be attached to iModel.");
// eslint-disable-next-line @typescript-eslint/no-deprecated
return iModel.withPreparedStatement("SELECT WsgId,ParentWsgId,Description,PushDate,UserCreated FROM ecchange.imodelchange.ChangeSet WHERE Summary.Id=?", (stmt) => {
stmt.bindId(1, changeSummaryId);
if (stmt.step() !== DbResult.BE_SQLITE_ROW)
throw new IModelError(IModelStatus.BadArg, `No ChangeSet information found for ChangeSummary ${changeSummaryId}.`);
const row = stmt.getRow();
return { id: changeSummaryId, changeSet: { wsgId: row.wsgId, parentWsgId: row.parentWsgId, description: row.description, pushDate: row.pushDate, userCreated: row.userCreated } };
});
}
/** Queries the InstanceChange for the specified instance change id.
*
* See also
* - `ECDbChange.InstanceChange` ECClass in the *ECDbChange* ECSchema
* - [Change Summary Overview]($docs/learning/ChangeSummaries)
* @param iModel iModel
* @param instanceChangeId ECInstanceId of the InstanceChange (see `ECDbChange.InstanceChange` ECClass in the *ECDbChange* ECSchema)
* @returns Returns the requested InstanceChange object (see `ECDbChange.InstanceChange` ECClass in the *ECDbChange* ECSchema)
* @throws [IModelError]($common) if instance change does not exist for the specified id, or if the
* change cache file hasn't been attached, or in case of other errors.
*/
static queryInstanceChange(iModel, instanceChangeId) {
if (!ChangeSummaryManager.isChangeCacheAttached(iModel))
throw new IModelError(IModelStatus.BadArg, "Change Cache file must be attached to iModel.");
// query instance changes
// eslint-disable-next-line @typescript-eslint/no-deprecated
const instanceChange = iModel.withPreparedStatement(`SELECT ic.Summary.Id summaryId, s.Name changedInstanceSchemaName, c.Name changedInstanceClassName, ic.ChangedInstance.Id changedInstanceId,
ic.OpCode, ic.IsIndirect FROM ecchange.change.InstanceChange ic JOIN main.meta.ECClassDef c ON c.ECInstanceId = ic.ChangedInstance.ClassId
JOIN main.meta.ECSchemaDef s ON c.Schema.Id = s.ECInstanceId WHERE ic.ECInstanceId =? `,
// eslint-disable-next-line @typescript-eslint/no-deprecated
(stmt) => {
stmt.bindId(1, instanceChangeId);
if (stmt.step() !== DbResult.BE_SQLITE_ROW)
throw new IModelError(IModelStatus.BadArg, `No InstanceChange found for id ${instanceChangeId}.`);
const row = stmt.getRow();
const changedInstanceId = row.changedInstanceId;
const changedInstanceClassName = `[${row.changedInstanceSchemaName}].[${row.changedInstanceClassName}]`;
const op = row.opCode;
return {
id: instanceChangeId, summaryId: row.summaryId, changedInstance: { id: changedInstanceId, className: changedInstanceClassName },
opCode: op, isIndirect: row.isIndirect,
};
});
return instanceChange;
}
/** Retrieves the names of the properties whose values have changed for the given instance change
*
* See also [Change Summary Overview]($docs/learning/ChangeSummaries)
* @param iModel iModel
* @param instanceChangeId Id of the InstanceChange to query the properties whose values have changed
* @returns Returns names of the properties whose values have changed for the given instance change
* @throws [IModelError]($common) if the change cache file hasn't been attached, or in case of other errors.
*/
static getChangedPropertyValueNames(iModel, instanceChangeId) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
return iModel.withPreparedStatement("SELECT AccessString FROM ecchange.change.PropertyValueChange WHERE InstanceChange.Id=?", (stmt) => {
stmt.bindId(1, instanceChangeId);
const selectClauseItems = [];
while (stmt.step() === DbResult.BE_SQLITE_ROW) {
// access string tokens need to be escaped as they might collide with reserved words in ECSQL or SQLite
const accessString = stmt.getValue(0).getString();
const accessStringTokens = accessString.split(".");
assert(accessStringTokens.length > 0);
let isFirstToken = true;
let item = "";
for (const token of accessStringTokens) {
if (!isFirstToken)
item += ".";
item += `[${token}]`;
isFirstToken = false;
}
selectClauseItems.push(item);
}
return selectClauseItems;
});
}
/** Builds the ECSQL to query the property value changes for the specified instance change and the specified ChangedValueState.
*
* See also [Change Summary Overview]($docs/learning/ChangeSummaries)
* @param iModel iModel
* @param instanceChangeInfo InstanceChange to query the property value changes for
* changedInstance.className must be fully qualified and schema and class name must be escaped with square brackets if they collide with reserved ECSQL words: `[schema name].[class name]`
* @param changedValueState The Changed State to query the values for. This must correspond to the [InstanceChange.OpCode]($backend) of the InstanceChange.
* @param changedPropertyNames List of the property names for which values have changed for the specified instance change.
* The list can be obtained by calling [ChangeSummaryManager.getChangedPropertyValueNames]($core-backend).
* If omitted, the method will call the above method by itself. The parameter allows for checking first whether
* an instance change has any property value changes at all. If there are no property value changes, this method
* should not be called, as it will throw an error.
* @returns Returns the ECSQL that will retrieve the property value changes
* @throws [IModelError]($common) if instance change does not exist, if there are not property value changes for the instance change,
* if the change cache file hasn't been attached, or in case of other errors.
*/
static buildPropertyValueChangesECSql(iModel, instanceChangeInfo, changedValueState, changedPropertyNames) {
let selectClauseItems;
if (!changedPropertyNames) {
// query property value changes just to build a SELECT statement against the class of the changed instance
selectClauseItems = ChangeSummaryManager.getChangedPropertyValueNames(iModel, instanceChangeInfo.id);
}
else
selectClauseItems = changedPropertyNames;
if (selectClauseItems.length === 0)
throw new IModelError(IModelStatus.BadArg, `No property value changes found for InstanceChange ${instanceChangeInfo.id}.`);
let ecsql = "SELECT ";
selectClauseItems.map((item, index) => {
if (index !== 0)
ecsql += ",";
ecsql += item;
});
// Avoiding parameters in the Changes function speeds up performance because ECDb can do optimizations
// if it knows the function args at prepare time
ecsql += ` FROM main.${instanceChangeInfo.changedInstance.className}.Changes(${instanceChangeInfo.summaryId},${changedValueState}) WHERE ECInstanceId=${instanceChangeInfo.changedInstance.id}`;
return ecsql;
}
/**
* Creates a change summary for the last applied change set to the iModel
* @param accessToken A valid access token string
* @param iModel iModel to extract change summaries for. The iModel must not be a standalone iModel, and must have at least one change set applied to it.
* @returns The id of the extracted change summary.
* @beta
*/
static async createChangeSummary(accessToken, iModel) {
if (!iModel?.isOpen)
throw new IModelError(IModelStatus.BadRequest, "Briefcase must be open");
const changesetId = iModel.changeset.id;
if (!changesetId)
throw new IModelError(IModelStatus.BadRequest, "No change set was applied to the iModel");
if (this.isChangeCacheAttached(iModel))
throw new IModelError(IModelStatus.BadRequest, "Change cache must be detached before extraction");
const iModelId = iModel.iModelId;
const changesetsFolder = BriefcaseManager.getChangeSetsPath(iModelId);
const changeset = await IModelHost[_hubAccess].downloadChangeset({ accessToken: IModelHost.authorizationClient ? undefined : accessToken, iModelId, changeset: { id: iModel.changeset.id }, targetDir: changesetsFolder });
if (!IModelJsFs.existsSync(changeset.pathname))
throw new IModelError(IModelStatus.FileNotFound, `Failed to download change set: ${changeset.pathname}`);
try {
const env_2 = { stack: [], error: void 0, hasError: false };
try {
const changesFile = __addDisposableResource(env_2, ChangeSummaryManager.openOrCreateChangesFile(iModel), false);
assert(changesFile[_nativeDb] !== undefined, "Invalid changesFile - should've caused an exception");
let changeSummaryId = ChangeSummaryManager.isSummaryAlreadyExtracted(changesFile, changesetId);
if (changeSummaryId !== undefined) {
Logger.logInfo(loggerCategory, `Change Summary for changeset already exists. It is not extracted again.`, () => ({ iModelId, changeSetId: changesetId }));
return changeSummaryId;
}
const stat = iModel[_nativeDb].extractChangeSummary(changesFile[_nativeDb], changeset.pathname);
if (stat.error && stat.error.status !== DbResult.BE_SQLITE_OK)
throw new IModelError(stat.error.status, stat.error.message);
assert(undefined !== stat.result);
changeSummaryId = stat.result;
ChangeSummaryManager.addExtendedInfos(changesFile, changeSummaryId, changesetId, changeset.parentId, changeset.description, changeset.pushDate, changeset.userCreated);
changesFile.saveChanges();
return changeSummaryId;
}
catch (e_2) {
env_2.error = e_2;
env_2.hasError = true;
}
finally {
__disposeResources(env_2);
}
}
finally {
IModelJsFs.unlinkSync(changeset.pathname);
}
}
/**
* Creates change summaries for the specified iModel and a specified range of versions
* @note This may be an expensive operation - downloads the first version and starts applying the change sets, extracting summaries one by one
* @param args Arguments including the range of versions for which Change Summaries are to be created, and other necessary input for creation
*/
static async createChangeSummaries(args) {
// if we pass undefined to hubAccess methods they will use our authorizationClient to refresh the token as needed.
const accessToken = IModelHost.authorizationClient ? undefined : args.accessToken ?? "";
const { iModelId, iTwinId, range } = args;
range.end = range.end ?? (await IModelHost[_hubAccess].getChangesetFromVersion({ accessToken, iModelId, version: IModelVersion.latest() })).index;
if (range.first > range.end)
throw new IModelError(IModelStatus.BadArg, "Invalid range of changesets");
if (range.first === 0 && range.end === 0)
return []; // no changesets exist, so the inclusive range is empty
const changesets = await IModelHost[_hubAccess].queryChangesets({ accessToken, iModelId, range });
// Setup a temporary briefcase to help with extracting change summaries
const briefcasePath = BriefcaseManager.getBriefcaseBasePath(iModelId);
const fileName = path.join(briefcasePath, `ChangeSummaryBriefcase.bim`);
if (IModelJsFs.existsSync(fileName))
IModelJsFs.removeSync(fileName);
let iModel;
try {
// Download a version that has the first change set applied
const props = await BriefcaseManager.downloadBriefcase({ accessToken, iTwinId, iModelId, asOf: { afterChangeSetId: changesets[0].id }, briefcaseId: 0, fileName });
iModel = await BriefcaseDb.open({ fileName: props.fileName });
const summaryIds = new Array();
for (let index = 0; index < changesets.length; index++) {
// Apply a change set if necessary
if (index > 0)
await iModel.pullChanges({ accessToken, toIndex: changesets[index].index });
// Create a change summary for the last change set that was applied
const summaryId = await this.createChangeSummary(accessToken ?? await IModelHost.authorizationClient?.getAccessToken() ?? "", iModel);
summaryIds.push(summaryId);
}
return summaryIds;
}
finally {
if (iModel !== undefined)
iModel.close();
IModelJsFs.removeSync(fileName);
}
}
}
//# sourceMappingURL=ChangeSummaryManager.js.map