UNPKG

@itwin/core-backend

Version:
421 lines • 24.9 kB
/*--------------------------------------------------------------------------------------------- * 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