@itwin/core-backend
Version:
iTwin.js backend components
931 lines • 49.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.
*--------------------------------------------------------------------------------------------*/
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