UNPKG

@itwin/core-backend

Version:
931 lines • 49.8 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { assert, expect } from "chai"; import * as path from "path"; import { Guid } from "@itwin/core-bentley"; import { Code, IModel } from "@itwin/core-common"; import { withEditTxn } from "../../EditTxn"; import { ChannelControl, DataTransformationStrategy, IModelJsFs, StandaloneDb } from "../../core-backend"; import { HubWrappers, IModelTestUtils } from "../IModelTestUtils"; import { KnownTestLocations } from "../KnownTestLocations"; import { HubMock } from "../../internal/HubMock"; describe("Schema Import Callbacks", () => { let imodel; // Test schema with version changes const testSchemaV100 = () => `<?xml version="1.0" encoding="UTF-8"?> <ECSchema schemaName="TestSchema" alias="ts" version="1.0.0" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2"> <ECSchemaReference name="BisCore" version="1.0.0" alias="bis"/> <ECEntityClass typeName="TestElement"> <BaseClass>bis:DefinitionElement</BaseClass> <ECProperty propertyName="StringProp" typeName="string" /> <ECProperty propertyName="IntProp" typeName="int" /> <ECProperty propertyName="ModelName" typeName="string" /> </ECEntityClass> </ECSchema>`; const testSchemaV101 = () => `<?xml version="1.0" encoding="UTF-8"?> <ECSchema schemaName="TestSchema" alias="ts" version="1.0.1" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2"> <ECSchemaReference name="BisCore" version="1.0.0" alias="bis"/> <ECEntityClass typeName="TestElement"> <BaseClass>bis:DefinitionElement</BaseClass> <ECProperty propertyName="StringProp" typeName="string" /> <ECProperty propertyName="IntProp" typeName="int" /> <ECProperty propertyName="NewProp" typeName="string" /> <ECProperty propertyName="ModelName" typeName="string" /> </ECEntityClass> </ECSchema>`; beforeEach(() => { const testFileName = IModelTestUtils.prepareOutputFile("SchemaImportCallbacks", `SchemaCallbackTest_${Guid.createValue()}.bim`); imodel = StandaloneDb.createEmpty(testFileName, { rootSubject: { name: "TestSubject" }, allowEdit: JSON.stringify({ txns: true }) }); assert.exists(imodel); }); afterEach(() => { if (imodel?.isOpen) imodel.close(); }); describe("Basic Callback Execution", () => { it("should work without callbacks", async () => { // This should not throw and should work as before await imodel.importSchemaStrings([testSchemaV100()]); assert.isTrue(imodel.containsClass(`TestSchema:TestElement`)); }); it("should call both callbacks in correct order", async () => { const callOrder = []; await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => { callOrder.push("before"); return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async () => { callOrder.push("after"); }, }, }); assert.deepEqual(callOrder, ["before", "after"]); }); }); describe("DataTransformationStrategy.None", () => { it("should not create snapshot or cache data with None strategy", async () => { await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { postSchemaImportCallback: async (context) => { assert.isUndefined(context.resources.snapshot); assert.isUndefined(context.resources.cachedData); }, }, }); }); }); describe("DataTransformationStrategy.InMemory", () => { it("should pass cached data from preImport to postImport", async () => { let receivedCachedData; const expectedCachedData = { elements: [ { id: "0x1", classFullName: "BisCore:DefinitionElement" }, { id: "0x2", classFullName: "BisCore:DefinitionElement" }, ], cachedNumber: 1234, }; await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.InMemory, cachedData: { elements: [ { id: "0x1", classFullName: "BisCore:DefinitionElement" }, { id: "0x2", classFullName: "BisCore:DefinitionElement" }, ], cachedNumber: 1234, }, }), postSchemaImportCallback: async (context) => { receivedCachedData = context.resources.cachedData; assert.isDefined(context.resources.cachedData); assert.equal(context.resources.cachedData.elements.length, 2); assert.deepEqual(receivedCachedData.elements, expectedCachedData.elements); assert.equal(receivedCachedData.cachedNumber, expectedCachedData.cachedNumber); assert.isUndefined(context.resources.snapshot); }, }, }); }); it("should cache element properties and use them after import", async () => { // First import the schema await imodel.importSchemaStrings([testSchemaV100()]); // Create a test element const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "original value of first element", intProp: 42, }; const elementId1 = withEditTxn(imodel, (txn) => txn.insertElement(elementProps)); const elementIds = [elementId1]; await withEditTxn(imodel, "cache callback writes", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { // Create another element before the schema import assert.equal(elementIds.length, 1); const element = context.iModel.elements.getElementProps(elementIds[0]); assert.isDefined(element.stringProp); assert.isDefined(element.intProp); elementProps.stringProp = "original value of second element"; elementProps.intProp = 84; elementIds.push(txn.insertElement(elementProps)); const cached = { ids: elementIds }; return { transformStrategy: DataTransformationStrategy.InMemory, cachedData: cached, }; }, postSchemaImportCallback: async (context) => { // Use cached data to update element with new property assert.isDefined(context.resources.cachedData?.ids); const updatedElementProps = context.iModel.elements.getElementProps(context.resources.cachedData.ids[1]); assert.isDefined(updatedElementProps.stringProp); assert.isDefined(updatedElementProps.intProp); assert.isUndefined(updatedElementProps.newProp); updatedElementProps.stringProp = "modified in postImport"; updatedElementProps.newProp = `New Prop Added`; txn.updateElement(updatedElementProps); }, }, }); }); // Verify the transformation let finalElementProps = imodel.elements.getElementProps(elementIds[0]); assert.equal(finalElementProps.stringProp, "original value of first element"); assert.isUndefined(finalElementProps.newProp); finalElementProps = imodel.elements.getElementProps(elementIds[1]); assert.equal(finalElementProps.stringProp, "modified in postImport"); assert.isDefined(finalElementProps.newProp); assert.equal(finalElementProps.newProp, "New Prop Added"); }); }); describe("DataTransformationStrategy.Snapshot", () => { it("should provide snapshot in postImport callback", async () => { let snapshotProvided = false; await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.Snapshot }), postSchemaImportCallback: async (context) => { assert.isDefined(context.resources.snapshot); assert.notEqual(context.resources.snapshot, context.iModel); assert.isTrue(context.resources.snapshot.isSnapshot); snapshotProvided = true; }, }, }); assert.isTrue(snapshotProvided); }); it("should allow reading pre-import state from snapshot", async () => { // First import initial schema await imodel.importSchemaStrings([testSchemaV100()]); // Create element with original schema const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "snapshot test", intProp: 123, }; const elementId = withEditTxn(imodel, "Insert element before schema upgrade", (txn) => txn.insertElement(elementProps)); // Import updated schema with snapshot strategy let originalStringValue; await withEditTxn(imodel, "snapshot callback writes", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.Snapshot }), postSchemaImportCallback: async (context) => { assert.isDefined(context.resources.snapshot); assert.equal(context.resources.snapshot?.getSchemaProps("TestSchema").version, "01.00.00"); assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.01"); // Read original value from snapshot const snapshotElementProps = context.resources.snapshot.elements.getElementProps(elementId); originalStringValue = snapshotElementProps.stringProp; // Update element in main iModel with new property based on snapshot data const updatedElementProps = context.iModel.elements.getElementProps(elementId); updatedElementProps.newProp = `Original was: ${originalStringValue}`; txn.updateElement(updatedElementProps); }, }, }); }); assert.equal(originalStringValue, "snapshot test"); const finalElement = imodel.elements.getElement(elementId); assert.equal(finalElement.newProp, "Original was: snapshot test"); }); it("should clean up snapshot after successful import", async () => { let snapshotPath; await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.Snapshot }), postSchemaImportCallback: async (context) => { assert.isDefined(context.resources.snapshot); snapshotPath = context.resources.snapshot.pathName; assert.isTrue(IModelJsFs.existsSync(snapshotPath)); }, }, }); if (snapshotPath) { assert.isFalse(IModelJsFs.existsSync(snapshotPath), "Snapshot file should be cleaned up"); } }); }); describe("Error Handling", () => { it("In memory strategy selected without caching any data pre import", async () => { try { await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.InMemory }), postSchemaImportCallback: async (context) => { assert.isUndefined(context.resources.snapshot); }, }, }); assert.fail("Should have thrown error"); } catch (err) { expect(err.message).to.equal("Failed to execute preSchemaImportCallback: InMemory transform strategy requires cachedData to be provided."); } }); it("should abandon changes if postImport callback throws", async () => { // First import initial schema await imodel.importSchemaStrings([testSchemaV100()]); // Create element const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "test", intProp: 1, }; assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); const elementId = withEditTxn(imodel, "Insert test element", (txn) => txn.insertElement(elementProps)); // Try to import with failing callback try { await withEditTxn(imodel, "failing callback writes", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { postSchemaImportCallback: async (context) => { // Make a change const updatedElementProps = context.iModel.elements.getElementProps(elementId); updatedElementProps.intProp += 1; updatedElementProps.stringProp = "should be reverted"; updatedElementProps.newProp = "should be reverted"; txn.updateElement(updatedElementProps); // Then throw error throw new Error("Intentional callback failure"); }, }, }); }); assert.fail("Should have thrown error"); } catch (err) { assert.equal(err.message, "Failed to execute postSchemaImportCallback: Intentional callback failure"); } // Schema import should have been successful assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.01"); // Changes should be abandoned - element should not have newProp const finalElementProps = imodel.elements.getElementProps(elementId); assert.equal(finalElementProps.intProp, 1); assert.equal(finalElementProps.stringProp, "test"); assert.isUndefined(finalElementProps.newProp); }); it("should clean up snapshot when postImport throws error", async () => { let snapshotPath; try { await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.Snapshot }), postSchemaImportCallback: async (context) => { snapshotPath = context.resources.snapshot.pathName; throw new Error("Error after snapshot created"); }, }, }); assert.fail("Should have thrown error"); } catch (err) { assert.equal(err.message, "Failed to execute postSchemaImportCallback: Error after snapshot created"); } if (snapshotPath) { assert.isFalse(IModelJsFs.existsSync(snapshotPath), "Snapshot should be cleaned up even after error"); } }); it("should handle error in preImport callback", async () => { await imodel.importSchemaStrings([testSchemaV100()]); assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); try { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async () => { throw new Error("Error in preImport"); }, }, }); assert.fail("Should have thrown error"); } catch (err) { assert.equal(err.message, "Failed to execute preSchemaImportCallback: Error in preImport"); } // Schema should not have been imported assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); }); }); describe("File-based Schema Import", () => { it("should work with importSchemas() using file paths", async () => { const schemaPath = path.join(KnownTestLocations.outputDir, `TestSchema.01.00.00.ecschema.xml`); IModelJsFs.writeFileSync(schemaPath, testSchemaV100()); let callbackExecuted = false; try { await imodel.importSchemas([schemaPath], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { assert.isDefined(context.schemaData); assert.equal(context.schemaData.length, 1); assert.equal(context.schemaData[0], schemaPath); callbackExecuted = true; return { transformStrategy: DataTransformationStrategy.None }; }, }, }); assert.isTrue(callbackExecuted); assert.isTrue(imodel.containsClass(`TestSchema:TestElement`)); } finally { if (IModelJsFs.existsSync(schemaPath)) { IModelJsFs.removeSync(schemaPath); } } }); it("should have consistent behavior between importSchemas and importSchemaStrings", async () => { const schemaPath = path.join(KnownTestLocations.outputDir, `TestSchema.01.00.00.ecschema.xml`); IModelJsFs.writeFileSync(schemaPath, testSchemaV100()); const executionLogFile = []; const executionLogString = []; try { // Test with file-based import await imodel.importSchemas([schemaPath], { data: { testId: "file-based" }, channelUpgrade: { channelKey: "shared", fromVersion: "1.0.0", toVersion: "2.0.0", callback: async (context) => { executionLogFile.push(`channel: ${context.data.testId}`); }, }, schemaImportCallbacks: { preSchemaImportCallback: async (context) => { executionLogFile.push(`pre: ${context.data.testId}`); return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async (context) => { executionLogFile.push(`post: ${context.data.testId}`); }, }, }); // Clean iModel for next test imodel.close(); const testFileName2 = IModelTestUtils.prepareOutputFile("SchemaImportCallbacks", `SchemaCallbackTest_${Guid.createValue()}.bim`); imodel = StandaloneDb.createEmpty(testFileName2, { rootSubject: { name: "TestSubject" }, allowEdit: JSON.stringify({ txns: true }) }); // Test with string-based import await imodel.importSchemaStrings([testSchemaV100()], { data: { testId: "string-based" }, channelUpgrade: { channelKey: "shared", fromVersion: "1.0.0", toVersion: "2.0.0", callback: async (context) => { executionLogString.push(`channel: ${context.data.testId}`); }, }, schemaImportCallbacks: { preSchemaImportCallback: async (context) => { executionLogString.push(`pre: ${context.data.testId}`); return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async (context) => { executionLogString.push(`post: ${context.data.testId}`); }, }, }); // Both should have identical execution patterns assert.equal(executionLogFile.length, executionLogString.length); assert.deepEqual(executionLogFile.map(s => s.split(":")[0]), executionLogString.map(s => s.split(":")[0])); } finally { if (IModelJsFs.existsSync(schemaPath)) IModelJsFs.removeSync(schemaPath); } }); }); describe("Channel Access Validation", () => { beforeEach(async () => { // Import schema first await imodel.importSchemaStrings([testSchemaV100()]); assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); imodel.channels.addAllowedChannel("shared"); }); it("should throw ChannelConstraintViolation when modifying element from disabled channel pre import", async () => { const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "test element", intProp: 100, }; const elementId = withEditTxn(imodel, "Create test element", (txn) => txn.insertElement(elementProps)); // Now REMOVE the shared channel permission imodel.channels.removeAllowedChannel("shared"); // Try to import schema and modify element - should fail try { await withEditTxn(imodel, "channel validation pre import", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { // This should throw because shared channel is not allowed const updatedProps = context.iModel.elements.getElementProps(elementId); updatedProps.newProp = "This should fail"; txn.updateElement(updatedProps); // Should throw here return { transformStrategy: DataTransformationStrategy.None }; } }, }); }); assert.fail("Should have thrown ChannelConstraintViolation"); } catch (err) { // Verify it's a channel constraint error assert.include(err.message.toLowerCase(), "error updating element [channel shared is not allowed]", "Error should mention channel constraint"); } assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); }); it("should throw ChannelConstraintViolation when modifying element from disabled channel post import", async () => { const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "test element", intProp: 100, }; const elementId = withEditTxn(imodel, "Create test element", (txn) => txn.insertElement(elementProps)); // Now REMOVE the shared channel permission imodel.channels.removeAllowedChannel("shared"); // Try to import schema and modify element - should fail try { await withEditTxn(imodel, "channel validation post import", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.None }), postSchemaImportCallback: async (context) => { // This should throw because shared channel is not allowed const updatedProps = context.iModel.elements.getElementProps(elementId); updatedProps.newProp = "This should fail"; txn.updateElement(updatedProps); // Should throw here }, }, }); }); assert.fail("Should have thrown ChannelConstraintViolation"); } catch (err) { // Verify it's a channel constraint error assert.include(err.message.toLowerCase(), "error updating element [channel shared is not allowed]", "Error should mention channel constraint"); } // Verify element was NOT modified (changes were abandoned) imodel.channels.addAllowedChannel("shared"); // Re-enable to read const finalProps = imodel.elements.getElementProps(elementId); assert.isUndefined(finalProps.newProp, "Element should not have been modified"); }); it("should handle channel permission check in snapshot strategy", async () => { // Enable channel and create element imodel.channels.addAllowedChannel("shared"); await imodel.importSchemaStrings([testSchemaV100()]); const model = imodel.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "snapshot test", intProp: 123, }; const elementId = withEditTxn(imodel, "Create test element", (txn) => txn.insertElement(elementProps)); // Disable channel before schema import with snapshot imodel.channels.removeAllowedChannel("shared"); try { await withEditTxn(imodel, "channel validation snapshot import", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.Snapshot }), postSchemaImportCallback: async (context) => { // Can read from snapshot (it's read-only, no channel check) const snapshotProps = context.resources.snapshot.elements.getElementProps(elementId); assert.equal(snapshotProps.stringProp, "snapshot test"); // But can't modify in main iModel without channel permission const updatedProps = context.iModel.elements.getElementProps(elementId); updatedProps.newProp = "This should fail"; txn.updateElement(updatedProps); // Should throw }, }, }); }); assert.fail("Should have thrown ChannelConstraintViolation"); } catch (err) { assert.include(err.message.toLowerCase(), "channel", "Error should mention channel constraint"); } }); }); describe("User Data Propagation", () => { it("should pass user data to all callbacks", async () => { const userData = { processId: "test-process-123", cachedNumber: 1234, metadata: { version: "1.0.0" }, }; const receivedData = {}; await imodel.importSchemaStrings([testSchemaV100()], { data: userData, channelUpgrade: { channelKey: "shared", fromVersion: "1.0.0", toVersion: "1.1.0", callback: async (context) => { receivedData.channel = context.data; }, }, schemaImportCallbacks: { preSchemaImportCallback: async (context) => { receivedData.pre = context.data; return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async (context) => { receivedData.post = context.data; }, }, }); // Verify all callbacks received the user data passed assert.deepEqual(receivedData.channel, userData); assert.deepEqual(receivedData.pre, userData); assert.deepEqual(receivedData.post, userData); }); it("should handle more complicated user data structures", async () => { const userData = { elementMap: new Map([["0x1", "Element1"], ["0x2", "Element2"]]), counters: { inserted: 0, updated: 0 }, handlers: [ { name: "Handler1", enabled: true }, { name: "Handler2", enabled: false }, ], }; let receivedData; await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async () => ({ transformStrategy: DataTransformationStrategy.None, }), postSchemaImportCallback: async (context) => { receivedData = context.data; }, }, data: userData, }); assert.isDefined(receivedData); assert.equal(receivedData.elementMap.size, 2); assert.equal(receivedData.elementMap.get("0x1"), "Element1"); assert.deepEqual(receivedData.counters, { inserted: 0, updated: 0 }); assert.equal(receivedData.handlers.length, 2); }); it("should work with undefined user data", async () => { await imodel.importSchemaStrings([testSchemaV100()], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { assert.isUndefined(context.data); return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async (context) => { assert.isUndefined(context.data); }, }, }); }); it("should allow user data mutation across callbacks", async () => { let userData = { step: 0, log: [], }; // All 3 callbacks called await imodel.importSchemaStrings([testSchemaV100()], { data: userData, channelUpgrade: { channelKey: "shared", fromVersion: "1.0.0", toVersion: "1.1.0", callback: async (context) => { context.data.step += 1; context.data.log.push("channel-upgrade"); }, }, schemaImportCallbacks: { preSchemaImportCallback: async (context) => { assert.equal(context.data.step, 1); assert.deepEqual(context.data.log, ["channel-upgrade"]); context.data.step += 1; context.data.log.push("pre-import"); return { transformStrategy: DataTransformationStrategy.None }; }, postSchemaImportCallback: async (context) => { assert.equal(context.data.step, 2); assert.deepEqual(context.data.log, ["channel-upgrade", "pre-import"]); context.data.step += 1; context.data.log.push("post-import"); }, }, }); // Verify final state assert.equal(userData.step, 3); // All three callbacks executed assert.deepEqual(userData.log, ["channel-upgrade", "pre-import", "post-import"]); userData = { step: 0, log: [], }; await imodel.importSchemaStrings([testSchemaV100()], { data: userData, schemaImportCallbacks: { postSchemaImportCallback: async (context) => { context.data.step += 1; context.data.log.push("post-import"); }, }, }); // Verify final state assert.equal(userData.step, 1); // Only post-import was called assert.deepEqual(userData.log, ["post-import"]); }); }); describe("Channel Reorganization Before Schema Import", () => { let channelRootId; /** * Helper functions to get and set the version from a ChannelRootAspect */ function getChannelVersion(iModel) { const aspects = iModel.elements.getAspects(channelRootId, "BisCore:ChannelRootAspect"); if (aspects.length === 0) return undefined; return aspects[0].asAny.version; } function setChannelVersion(txn, version) { const iModel = txn.iModel; const aspects = iModel.elements.getAspects(channelRootId, "BisCore:ChannelRootAspect"); assert.equal(aspects.length, 1, "Should have exactly one ChannelRootAspect"); const aspect = aspects[0]; aspect.asAny.version = version; txn.updateAspect(aspect.toJSON()); } // Code that simulates a channel upgrade. // This simulates elements being moved to different models as per the new channel organization. const channelUpgradeCallback = (txn) => async (context) => { context.data.elementIds.forEach((id) => { const elementProps = context.iModel.elements.getElementProps(id); if (elementProps.stringProp === "Material1") { elementProps.modelName = "MaterialModel"; } else if (elementProps.stringProp === "LineStyle1") { elementProps.modelName = "StyleModel"; } else if (elementProps.stringProp === "Category1") { elementProps.modelName = "StyleModel"; } txn.updateElement(elementProps); }); setChannelVersion(txn, "1.0.1"); assert.equal(getChannelVersion(context.iModel), "1.0.1"); }; it("need for channel reorganization before schema import", async () => { // Initial setup: Import v1.0.0 schema await imodel.importSchemaStrings([testSchemaV100()]); assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.00"); const channelKey = "TestChannel"; imodel.channels.addAllowedChannel(channelKey); // Create a channel channelRootId = withEditTxn(imodel, (txn) => imodel.channels.insertChannelSubject({ subjectName: "Test Channel", channelKey, txn, })); assert.isUndefined(getChannelVersion(imodel), "Version should be undefined initially"); // Set version to 1.0.0 withEditTxn(imodel, (txn) => setChannelVersion(txn, "1.0.0")); // Enable shared channel and create elements imodel.channels.addAllowedChannel("shared"); const rootModel = imodel.models.getModel(IModel.dictionaryId); assert.equal(getChannelVersion(imodel), "1.0.0"); // Create test elements in the "old" channel structure (all in root) const elementIds = []; for (const [index, name] of ["Material1", "LineStyle1", "Category1"].entries()) { const elementProps = { classFullName: `TestSchema:TestElement`, model: rootModel.id, code: Code.createEmpty(), stringProp: name, intProp: 100 + index * 100, modelName: "DefinitionModel" }; elementIds.push(withEditTxn(imodel, (txn) => txn.insertElement(elementProps))); } // Scenario: Let's assume schema has evolved and v1.0.1 expects elements to be in their dedicated models (i.e. it relies on channel v1.0.1) // The post schema upgrade code will look for elements in their own models. // If the channel is still in version 1.0.0, the callback will obviously fail. await withEditTxn(imodel, "channel reorganization callback writes", async (txn) => { await imodel.importSchemaStrings([testSchemaV101()], { data: { elementIds }, channelUpgrade: { channelKey, fromVersion: "1.0.0", toVersion: "1.0.1", callback: channelUpgradeCallback(txn), }, schemaImportCallbacks: { preSchemaImportCallback: async () => { return { transformStrategy: DataTransformationStrategy.InMemory, cachedData: { elementIds, }, }; }, postSchemaImportCallback: async (context) => { // Now schema v1.0.1 expects elements to be in their dedicated models. const elementIdList = context.resources.cachedData.elementIds; assert.equal(imodel.getSchemaProps("TestSchema").version, "01.00.01"); assert.equal(getChannelVersion(context.iModel), "1.0.1"); elementIdList.forEach((elementId) => { const elementProps = context.iModel.elements.getElementProps(elementId); assert.notEqual(elementProps.modelName, "DefinitionModel", "Element should have been moved to new model as part of the channel upgrade"); }); }, }, }); }); }); }); describe("Locking behavior", () => { let iModelId; const channelKey = "TestChannel"; before(async () => { HubMock.startup("SchemaImportCallbackLockingTest", KnownTestLocations.outputDir); iModelId = await HubMock.createNewIModel({ accessToken: "User1", iTwinId: HubMock.iTwinId, iModelName: "Test", description: "TestSubject" }); }); after(async () => { HubMock.shutdown(); }); async function setupBriefcase() { const briefcaseDb = await HubWrappers.downloadAndOpenBriefcase({ accessToken: "User1", iTwinId: HubMock.iTwinId, iModelId }); briefcaseDb.channels.addAllowedChannel(ChannelControl.sharedChannelName); await briefcaseDb.importSchemaStrings([testSchemaV100()]); briefcaseDb.channels.addAllowedChannel(channelKey); if (briefcaseDb.channels.queryChannelRoot(channelKey) === undefined) { withEditTxn(briefcaseDb, (txn) => briefcaseDb.channels.insertChannelSubject({ subjectName: "Test Channel", channelKey, txn, })); } const model = briefcaseDb.models.getModel(IModel.dictionaryId); const elementProps = { classFullName: `TestSchema:TestElement`, model: model.id, code: Code.createEmpty(), stringProp: "test", intProp: 100, }; const elementId = withEditTxn(briefcaseDb, "Create test element", (txn) => txn.insertElement(elementProps)); await briefcaseDb.pushChanges({ description: "Create test element" }); return [briefcaseDb, elementId]; } it("channel upgrade callback fails without locks", async () => { const [briefcaseDb, elementId] = await setupBriefcase(); // Try to modify without acquiring locks in the callback try { await withEditTxn(briefcaseDb, "channel upgrade without locks", async (txn) => { await briefcaseDb.importSchemaStrings([testSchemaV101()], { channelUpgrade: { channelKey, fromVersion: "1.0.0", toVersion: "1.0.1", callback: async (context) => { // Intentionally NOT acquiring locks const props = context.iModel.elements.getElementProps(elementId); props.stringProp = "should fail"; txn.updateElement(props); }, }, }); }); assert.fail("Should have thrown error about missing locks"); } catch (err) { assert.equal(err.iTwinErrorId.key.name, "Lock Not Held"); } // Try again with locks try { await withEditTxn(briefcaseDb, "channel upgrade with locks", async (txn) => { await briefcaseDb.importSchemaStrings([testSchemaV101()], { channelUpgrade: { channelKey, fromVersion: "1.0.0", toVersion: "1.0.1", callback: async (context) => { await context.iModel.locks.acquireLocks({ exclusive: elementId }); const props = context.iModel.elements.getElementProps(elementId); props.stringProp = "updated with locks"; txn.updateElement(props); }, }, }); }); } catch (err) { assert.fail(`Should not have thrown error when locks are acquired: ${err.message}`); } await HubWrappers.closeAndDeleteBriefcaseDb("User1", briefcaseDb); }); it("preSchemaImportCallback fails without locks", async () => { const [briefcaseDb, elementId] = await setupBriefcase(); // Try to modify without acquiring locks in the callback try { await withEditTxn(briefcaseDb, "pre import without locks", async (txn) => { await briefcaseDb.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { // Intentionally NOT acquiring locks const props = context.iModel.elements.getElementProps(elementId); props.stringProp = "should fail"; txn.updateElement(props); return { transformStrategy: DataTransformationStrategy.None }; }, }, }); }); assert.fail("Should have thrown error about missing locks"); } catch (err) { assert.equal(err.name, "Lock Not Held"); } // Try again with locks try { await withEditTxn(briefcaseDb, "pre import with locks", async (txn) => { await briefcaseDb.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { preSchemaImportCallback: async (context) => { await context.iModel.locks.acquireLocks({ exclusive: elementId }); const props = context.iModel.elements.getElementProps(elementId); props.stringProp = "should fail"; txn.updateElement(props); return { transformStrategy: DataTransformationStrategy.None }; }, }, }); }); } catch (err) { assert.fail(`Should not have thrown error when locks are acquired: ${err.message}`); } await HubWrappers.closeAndDeleteBriefcaseDb("User1", briefcaseDb); }); it("lock acquisition is not required during postSchemaImportCallback", async () => { const [briefcaseDb, elementId] = await setupBriefcase(); let postImportCalled = false; await withEditTxn(briefcaseDb, "post import with schema lock", async (txn) => { await briefcaseDb.importSchemaStrings([testSchemaV101()], { schemaImportCallbacks: { postSchemaImportCallback: async (context) => { postImportCalled = true; // Schema lock is already held at this point const props = context.iModel.elements.getElementProps(elementId); props.newProp = "added in postImport with schema lock held"; txn.updateElement(props); }, }, }); }); assert.isTrue(postImportCalled, "Post-import callback should have been called"); const finalProps = briefcaseDb.elements.getElementProps(elementId); assert.equal(finalProps.newProp, "added in postImport with schema lock held"); await HubWrappers.closeAndDeleteBriefcaseDb("User1", briefcaseDb); }); }); }); //# sourceMappingURL=SchemaImportCallbacks.test.js.map