@itwin/core-backend
Version:
iTwin.js backend components
460 lines • 30.2 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 { LineSegment3d, Point3d } from "@itwin/core-geometry";
import { Code, CodeScopeSpec, GeometryStreamBuilder, IModel, SubCategoryAppearance } from "@itwin/core-common";
import { ChannelControl, DefinitionModel, EditTxn, IModelJsFs, PhysicalModel, SnapshotDb, SpatialCategory, StandaloneDb } from "../../core-backend";
import { IModelTestUtils } from "../IModelTestUtils";
import { KnownTestLocations } from "../KnownTestLocations";
import { withEditTxn } from "../TestEditTxn";
import { _cache } from "../../internal/Symbols";
describe("changeElementParent and changeElementModel", () => {
let seedDb;
let iModelDb;
let modelAId;
let modelBId;
let defModelAId;
let defModelBId;
let categoryId;
let relatedElementCodeSpecId;
let modelScopedCodeSpecId;
let parentElementCodeSpecId;
let repositoryCodeSpecId;
let txn;
before(async () => {
IModelJsFs.recursiveMkDirSync(KnownTestLocations.outputDir);
const seedFile = IModelTestUtils.prepareOutputFile("ChangeElementParentModel", "seed.bim");
seedDb = SnapshotDb.createEmpty(seedFile, { rootSubject: { name: "ChangeElementParentModel" } });
await withEditTxn(seedDb, "setup seed", async (editTxn) => {
modelAId = PhysicalModel.insert(editTxn, IModel.rootSubjectId, "ModelA");
modelBId = PhysicalModel.insert(editTxn, IModel.rootSubjectId, "ModelB");
defModelAId = DefinitionModel.insert(editTxn, IModel.rootSubjectId, "DefinitionModelA");
defModelBId = DefinitionModel.insert(editTxn, IModel.rootSubjectId, "DefinitionModelB");
categoryId = SpatialCategory.insert(editTxn, IModel.dictionaryId, "TestCategory", new SubCategoryAppearance());
relatedElementCodeSpecId = seedDb.codeSpecs.insert(editTxn, "RelatedElementCodeSpec", CodeScopeSpec.Type.RelatedElement);
modelScopedCodeSpecId = seedDb.codeSpecs.insert(editTxn, "ModelScopedCodeSpec", CodeScopeSpec.Type.Model);
parentElementCodeSpecId = seedDb.codeSpecs.insert(editTxn, "ParentElementCodeSpec", CodeScopeSpec.Type.ParentElement);
repositoryCodeSpecId = seedDb.codeSpecs.insert(editTxn, "RepositoryCodeSpec", CodeScopeSpec.Type.Repository);
assert.isNotEmpty(modelAId, "Expected a valid PhysicalModel id for ModelA");
assert.isNotEmpty(modelBId, "Expected a valid PhysicalModel id for ModelB");
assert.isNotEmpty(defModelAId, "Expected a valid DefinitionModel id for DefinitionModelA");
assert.isNotEmpty(defModelBId, "Expected a valid DefinitionModel id for DefinitionModelB");
assert.isNotEmpty(categoryId, "Expected a valid SpatialCategory id");
assert.isNotEmpty(relatedElementCodeSpecId, "Expected a valid RelatedElement CodeSpec id");
assert.isNotEmpty(modelScopedCodeSpecId, "Expected a valid Model CodeSpec id");
assert.isNotEmpty(parentElementCodeSpecId, "Expected a valid ParentElement CodeSpec id");
assert.isNotEmpty(repositoryCodeSpecId, "Expected a valid Repository CodeSpec id");
});
});
beforeEach(() => {
iModelDb = SnapshotDb.createFrom(seedDb, IModelTestUtils.prepareOutputFile("ChangeElementParentModel", "ChangeElementParentModel.bim"));
assert.isTrue(iModelDb.isOpen);
txn = new EditTxn(iModelDb, "change element parent/model");
txn.start();
iModelDb.channels.addAllowedChannel(ChannelControl.sharedChannelName);
});
afterEach(() => {
if (txn.isActive)
txn.end("abandon");
if (iModelDb.isOpen)
iModelDb.close();
});
after(() => {
if (seedDb.isOpen)
seedDb.close();
});
const insertElement = (modelId, opts = {}) => {
const { parentId, codeSpec, codeScope, codeValue, withGeometry } = opts;
const builder = new GeometryStreamBuilder();
if (withGeometry)
builder.appendGeometry(LineSegment3d.create(Point3d.createZero(), Point3d.create(1, 0, 0)));
const props = {
classFullName: "Generic:PhysicalObject",
model: modelId,
category: categoryId,
code: codeSpec && codeScope && codeValue ? { spec: codeSpec, scope: codeScope, value: codeValue } : Code.createEmpty(),
placement: { origin: [0, 0, 0], angles: { yaw: 0, pitch: 0, roll: 0 } },
...(withGeometry ? { geom: builder.geometryStream } : {}),
...(parentId ? { parent: { id: parentId, relClassName: "BisCore:ElementOwnsChildElements" } } : {}),
};
const id = txn.insertElement(props);
assert.isNotEmpty(id, "insertElement must return a valid ID");
txn.saveChanges();
return id;
};
describe("changeElementParent", () => {
it("changes parent of a leaf element in the same model", () => {
const parentA = insertElement(modelAId);
const leaf = insertElement(modelAId, { parentId: parentA });
const parentB = insertElement(modelAId);
txn.changeElementParent({ id: leaf, parentId: parentB });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(leaf);
assert.equal(moved.model, modelAId, "model should remain the same");
assert.equal(moved.parent?.id, parentB, "parent should be updated to parentB");
});
it("leaves children attached and in the same model when reparenting within the same model", () => {
const parent = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: parent });
const newParent = insertElement(modelAId);
txn.changeElementParent({ id: parent, parentId: newParent });
txn.saveChanges();
const movedParent = iModelDb.elements.getElementProps(parent);
assert.equal(movedParent.model, modelAId, "model stays the same");
assert.equal(movedParent.parent?.id, newParent, "parent updated");
const movedChild = iModelDb.elements.getElementProps(child);
assert.equal(movedChild.model, modelAId, "child model stays the same");
assert.equal(movedChild.parent?.id, parent, "child still parented to the moved element");
});
it("allows an element with a RelatedElement-scoped code", () => {
const parentA = insertElement(modelAId);
const leaf = insertElement(modelAId, {
parentId: parentA,
codeSpec: relatedElementCodeSpecId,
codeScope: parentA,
codeValue: "RelatedCode",
});
const parentB = insertElement(modelAId);
txn.changeElementParent({ id: leaf, parentId: parentB });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(leaf);
assert.equal(moved.parent?.id, parentB, "parent should be updated");
});
it("allows an element with a Model-scoped code (the model does not change)", () => {
const parentA = insertElement(modelAId);
const leaf = insertElement(modelAId, {
parentId: parentA,
codeSpec: modelScopedCodeSpecId,
codeScope: modelAId,
codeValue: "ModelScopedSameModel",
});
const parentB = insertElement(modelAId);
txn.changeElementParent({ id: leaf, parentId: parentB });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(leaf);
assert.equal(moved.parent?.id, parentB, "parent should be updated within same model");
});
it("blocks an element with a ParentElement-scoped code", () => {
const parentA = insertElement(modelAId);
const leaf = insertElement(modelAId, {
parentId: parentA,
codeSpec: parentElementCodeSpecId,
codeScope: parentA,
codeValue: "ParentScopedCode",
});
const parentB = insertElement(modelAId);
expect(() => txn.changeElementParent({ id: leaf, parentId: parentB })).to.throw();
});
it("rejects self-parenting and leaves the element unchanged", () => {
const elem = insertElement(modelAId);
expect(() => txn.changeElementParent({ id: elem, parentId: elem })).to.throw();
const unchanged = iModelDb.elements.getElementProps(elem);
assert.equal(unchanged.model, modelAId, "model should remain unchanged");
assert.isUndefined(unchanged.parent, "element should remain a root");
});
it("rejects reparenting to a parent in a different model", () => {
const parentInA = insertElement(modelAId);
const leaf = insertElement(modelAId, { parentId: parentInA });
const targetInB = insertElement(modelBId);
expect(() => txn.changeElementParent({ id: leaf, parentId: targetInB })).to.throw("different model");
// A rejected reparent must leave the element untouched.
const unchanged = iModelDb.elements.getElementProps(leaf);
assert.equal(unchanged.model, modelAId, "model should remain ModelA");
assert.equal(unchanged.parent?.id, parentInA, "parent should remain unchanged");
});
});
describe("changeElementModel", () => {
it("changes model of a leaf element (becomes root in new model)", () => {
const elem = insertElement(modelAId);
txn.changeElementModel({ id: elem, modelId: modelBId });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(elem);
assert.equal(moved.model, modelBId, "model should change to ModelB");
assert.isUndefined(moved.parent, "element should have no parent (root in ModelB)");
});
it("blocks moving an element that has a parent", () => {
const parent = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: parent });
// Only root elements (no parent) can be moved between models.
expect(() => txn.changeElementModel({ id: child, modelId: modelBId })).to.throw();
});
it("moves the whole subtree when changing the model of a root element with children", () => {
// A root element (no parent) may itself have children. BIS requires a parent and all of its
// children to reside in the same model (see element-fundamentals: "Both the parent and the
// children must live in the same Model"). Moving such a root must therefore move its entire
// subtree, never leave the children orphaned in the source model.
const root = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: root });
const grandchild = insertElement(modelAId, { parentId: child });
txn.changeElementModel({ id: root, modelId: modelBId });
txn.saveChanges();
const movedRoot = iModelDb.elements.getElementProps(root);
const movedChild = iModelDb.elements.getElementProps(child);
const movedGrandchild = iModelDb.elements.getElementProps(grandchild);
// The root moves to the target model.
assert.equal(movedRoot.model, modelBId, "root should move to ModelB");
// The descendants must move with it - otherwise the assembly spans two models (BIS-invalid).
assert.equal(movedChild.model, modelBId, "child must move with its parent (same-model invariant)");
assert.equal(movedGrandchild.model, modelBId, "grandchild must move with the subtree");
// The hierarchy itself must be preserved.
assert.equal(movedChild.parent?.id, root, "child still parented to root");
assert.equal(movedGrandchild.parent?.id, child, "grandchild still parented to child");
});
it("invalidates cached descendant props after the subtree move", () => {
// Reading descendant props before the move populates the element cache with the source model.
// Since the entire subtree is relocated, those cached entries become stale and must be invalidated;
// otherwise a subsequent read returns the old model. This regresses the bug where only the moved
// root's cache was invalidated.
const root = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: root });
const grandchild = insertElement(modelAId, { parentId: child });
// Prime the cache while the subtree is still in ModelA.
assert.equal(iModelDb.elements.getElementProps(child).model, modelAId, "child starts in ModelA");
assert.equal(iModelDb.elements.getElementProps(grandchild).model, modelAId, "grandchild starts in ModelA");
txn.changeElementModel({ id: root, modelId: modelBId });
txn.saveChanges();
// Reads after the move must reflect the new model, not the stale cached value.
assert.equal(iModelDb.elements.getElementProps(root).model, modelBId, "root cache must reflect ModelB");
assert.equal(iModelDb.elements.getElementProps(child).model, modelBId, "child cache must be invalidated and reflect ModelB");
assert.equal(iModelDb.elements.getElementProps(grandchild).model, modelBId, "grandchild cache must be invalidated and reflect ModelB");
});
it("invalidates the source and target model cache entries after the move", () => {
// Moving an element between models changes the membership - and therefore the geometry-derived state
// such as GeometricModel.geometryGuid - of both the source and target models. Element insert/update/
// delete invalidate models[_cache] via Element.onInserted/onUpdated/onDeleted for the same reason, but
// the native changeElementModel does not fire those element callbacks. The wrapper must therefore evict
// both affected models so a subsequent getModelProps does not return stale, pre-move ModelProps.
const elem = insertElement(modelAId);
// Prime the model cache for both the source and target models.
iModelDb.models.getModelProps(modelAId);
iModelDb.models.getModelProps(modelBId);
assert.isDefined(iModelDb.models[_cache].get(modelAId), "source model should be cached before the move");
assert.isDefined(iModelDb.models[_cache].get(modelBId), "target model should be cached before the move");
txn.changeElementModel({ id: elem, modelId: modelBId });
// Both affected models must be evicted from the cache after the native move.
assert.isUndefined(iModelDb.models[_cache].get(modelAId), "source model cache must be invalidated after the move");
assert.isUndefined(iModelDb.models[_cache].get(modelBId), "target model cache must be invalidated after the move");
});
it("requires exclusive locks for every element in the moved subtree", () => {
const root = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: root });
const grandchild = insertElement(modelAId, { parentId: child });
const originalLocks = iModelDb.locks;
const checkedIds = [];
iModelDb._locks = new Proxy(originalLocks, {
get(target, prop, receiver) {
if (prop === "checkExclusiveLock")
return (id) => {
checkedIds.push(id);
if (id === child)
throw new Error(`missing child lock ${child}`);
};
return Reflect.get(target, prop, receiver);
},
});
try {
expect(() => txn.changeElementModel({ id: root, modelId: modelBId })).to.throw(`missing child lock ${child}`);
}
finally {
iModelDb._locks = originalLocks;
}
assert.includeMembers(checkedIds, [root, child], "root and child locks must be checked before the native move");
assert.equal(iModelDb.elements.getElementProps(root).model, modelAId, "root must remain in ModelA");
assert.equal(iModelDb.elements.getElementProps(child).model, modelAId, "child must remain in ModelA");
assert.equal(iModelDb.elements.getElementProps(grandchild).model, modelAId, "grandchild must remain in ModelA");
});
it("updates source and target model geometry guids when moving geometric elements", () => {
const fileName = IModelTestUtils.prepareOutputFile("ChangeElementParentModel", "GeometryGuidMove.bim");
const db = StandaloneDb.createEmpty(fileName, { rootSubject: { name: "GeometryGuidMove" }, enableTransactions: true });
db.channels.addAllowedChannel(ChannelControl.sharedChannelName);
try {
const ids = withEditTxn(db, "setup geometry guid move test", (editTxn) => {
const sourceModelId = PhysicalModel.insert(editTxn, IModel.rootSubjectId, "SourceModel");
const targetModelId = PhysicalModel.insert(editTxn, IModel.rootSubjectId, "TargetModel");
const localCategoryId = SpatialCategory.insert(editTxn, IModel.dictionaryId, "GeometryGuidCategory", new SubCategoryAppearance());
const builder = new GeometryStreamBuilder();
builder.appendGeometry(LineSegment3d.create(Point3d.createZero(), Point3d.create(1, 0, 0)));
const elementProps = {
classFullName: "Generic:PhysicalObject",
model: sourceModelId,
category: localCategoryId,
code: Code.createEmpty(),
placement: { origin: [0, 0, 0], angles: { yaw: 0, pitch: 0, roll: 0 } },
geom: builder.geometryStream,
};
const elementId = editTxn.insertElement(elementProps);
return { elementId, sourceModelId, targetModelId };
});
const sourceGuidBefore = db.models.getModel(ids.sourceModelId).geometryGuid;
const targetGuidBefore = db.models.getModel(ids.targetModelId).geometryGuid;
withEditTxn(db, "move geometry between models", (editTxn) => {
editTxn.changeElementModel({ id: ids.elementId, modelId: ids.targetModelId });
});
const sourceGuidAfter = db.models.getModel(ids.sourceModelId).geometryGuid;
const targetGuidAfter = db.models.getModel(ids.targetModelId).geometryGuid;
assert.isDefined(sourceGuidBefore, "source model should have a geometry guid after inserting geometry");
assert.notEqual(sourceGuidAfter, sourceGuidBefore, "source model geometry guid must change after losing geometry");
assert.isDefined(targetGuidAfter, "target model should have a geometry guid after receiving geometry");
assert.notEqual(targetGuidAfter, targetGuidBefore, "target model geometry guid must change after receiving geometry");
}
finally {
db.close();
IModelJsFs.removeSync(fileName);
}
});
it("recursively moves a multi-level subtree with multiple children at each level", () => {
// A subtree is more than a single chain: a node can have several children, each with their own
// descendants. The recursive move must relocate every node in the tree, not just one branch.
// root
// ├── childA
// │ ├── grandchildA1
// │ └── grandchildA2
// └── childB
// └── grandchildB1
const root = insertElement(modelAId);
const childA = insertElement(modelAId, { parentId: root });
const childB = insertElement(modelAId, { parentId: root });
const grandchildA1 = insertElement(modelAId, { parentId: childA });
const grandchildA2 = insertElement(modelAId, { parentId: childA });
const grandchildB1 = insertElement(modelAId, { parentId: childB });
txn.changeElementModel({ id: root, modelId: modelBId });
txn.saveChanges();
// Every node in the tree must be relocated to the target model.
for (const id of [root, childA, childB, grandchildA1, grandchildA2, grandchildB1])
assert.equal(iModelDb.elements.getElementProps(id).model, modelBId, `element ${id} must move to ModelB`);
// The parent-child hierarchy must be preserved across the move.
assert.isUndefined(iModelDb.elements.getElementProps(root).parent, "root stays a root in ModelB");
assert.equal(iModelDb.elements.getElementProps(childA).parent?.id, root, "childA still parented to root");
assert.equal(iModelDb.elements.getElementProps(childB).parent?.id, root, "childB still parented to root");
assert.equal(iModelDb.elements.getElementProps(grandchildA1).parent?.id, childA, "grandchildA1 still parented to childA");
assert.equal(iModelDb.elements.getElementProps(grandchildA2).parent?.id, childA, "grandchildA2 still parented to childA");
assert.equal(iModelDb.elements.getElementProps(grandchildB1).parent?.id, childB, "grandchildB1 still parented to childB");
});
it("blocks an element with a Model-scoped code", () => {
const elem = insertElement(modelAId, {
codeSpec: modelScopedCodeSpecId,
codeScope: modelAId,
codeValue: "ModelScopedCode",
});
expect(() => txn.changeElementModel({ id: elem, modelId: modelBId })).to.throw();
});
it("blocks the subtree move when a descendant has a Model-scoped code, leaving the whole subtree untouched", () => {
// A Model-scoped code cannot survive a model change. Because the entire subtree is validated before
// anything moves, a Model-scoped code anywhere in the subtree (here on a grandchild) rejects the whole
// operation — and nothing is moved. This exercises the validate-first atomicity of the recursive move.
const root = insertElement(modelAId);
const child = insertElement(modelAId, { parentId: root });
const grandchild = insertElement(modelAId, {
parentId: child,
codeSpec: modelScopedCodeSpecId,
codeScope: modelAId,
codeValue: "DescendantModelScoped",
});
expect(() => txn.changeElementModel({ id: root, modelId: modelBId })).to.throw();
// Atomicity: the rejected move must leave the entire subtree in the source model.
assert.equal(iModelDb.elements.getElementProps(root).model, modelAId, "root must stay in ModelA");
assert.equal(iModelDb.elements.getElementProps(child).model, modelAId, "child must stay in ModelA");
assert.equal(iModelDb.elements.getElementProps(grandchild).model, modelAId, "grandchild must stay in ModelA");
});
it("allows the subtree move when a descendant has a ParentElement-scoped code (its parent moves with it)", () => {
// A ParentElement-scoped code is anchored to the element's parent. When the subtree moves, a descendant
// keeps its parent (the parent moves too), so the code stays valid and the move is allowed. This is the
// descendant counterpart to the blocked root case below, exercising the isRoot distinction in the addon.
const root = insertElement(modelAId);
const child = insertElement(modelAId, {
parentId: root,
codeSpec: parentElementCodeSpecId,
codeScope: root,
codeValue: "DescendantParentScoped",
});
const grandchild = insertElement(modelAId, {
parentId: child,
codeSpec: parentElementCodeSpecId,
codeScope: child,
codeValue: "DeeperParentScoped",
});
txn.changeElementModel({ id: root, modelId: modelBId });
txn.saveChanges();
assert.equal(iModelDb.elements.getElementProps(root).model, modelBId, "root moves to ModelB");
const movedChild = iModelDb.elements.getElementProps(child);
assert.equal(movedChild.model, modelBId, "child with parent-scoped code moves with the subtree");
assert.equal(movedChild.parent?.id, root, "child remains parented to root");
const movedGrandchild = iModelDb.elements.getElementProps(grandchild);
assert.equal(movedGrandchild.model, modelBId, "grandchild with parent-scoped code moves with the subtree");
assert.equal(movedGrandchild.parent?.id, child, "grandchild remains parented to child");
});
it("blocks moving a root element that has a ParentElement-scoped code", () => {
// The moved root has no parent to anchor a ParentElement-scoped code, so the move is blocked
// (use delete+insert instead). Contrast with the descendant case above, which is allowed.
const scopeElem = insertElement(modelAId);
const root = insertElement(modelAId, {
codeSpec: parentElementCodeSpecId,
codeScope: scopeElem,
codeValue: "RootParentScoped",
});
expect(() => txn.changeElementModel({ id: root, modelId: modelBId })).to.throw();
});
it("allows an element with a RelatedElement-scoped code", () => {
const scopeElem = insertElement(modelAId);
const elem = insertElement(modelAId, {
codeSpec: relatedElementCodeSpecId,
codeScope: scopeElem,
codeValue: "RelatedCode",
});
txn.changeElementModel({ id: elem, modelId: modelBId });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(elem);
assert.equal(moved.model, modelBId, "model should change to ModelB");
});
it("rejects a model change when the model types differ", () => {
const physElem = insertElement(modelAId);
expect(() => txn.changeElementModel({ id: physElem, modelId: defModelAId })).to.throw("cannot move element from model of type");
});
it("throws when attempting to change the model of a partition element", () => {
expect(() => txn.changeElementModel({ id: modelAId, modelId: modelBId })).to.throw();
});
});
describe("definition models", () => {
const insertDefinitionElement = (modelId, name, opts = {}) => {
const props = {
classFullName: "Generic:PhysicalType",
model: modelId,
code: { spec: relatedElementCodeSpecId, scope: modelId, value: name },
...(opts.parentId ? { parent: { id: opts.parentId, relClassName: "BisCore:ElementOwnsChildElements" } } : {}),
};
const id = txn.insertElement(props);
assert.isNotEmpty(id, "insertDefinitionElement must return a valid ID");
txn.saveChanges();
return id;
};
it("moves a definition element to another definition model via changeElementModel", () => {
const defElem = insertDefinitionElement(defModelAId, "MovableCategoryA");
txn.changeElementModel({ id: defElem, modelId: defModelBId });
txn.saveChanges();
const moved = iModelDb.elements.getElementProps(defElem);
assert.equal(moved.model, defModelBId, "model should change to DefinitionModelB");
assert.isUndefined(moved.parent, "element should be a root in DefinitionModelB");
});
it("rejects reparenting a definition element across definition models", () => {
const defElem = insertDefinitionElement(defModelAId, "MovableCategoryB");
const targetElem = insertDefinitionElement(defModelBId, "TargetInDefB");
expect(() => txn.changeElementParent({ id: defElem, parentId: targetElem })).to.throw("different model");
});
it("rejects changing the parent of a physical element to a parent in a definition model", () => {
const physElem = insertElement(modelAId);
const defTarget = insertDefinitionElement(defModelAId, "DefTargetForPhys");
expect(() => txn.changeElementParent({ id: physElem, parentId: defTarget })).to.throw("different model");
});
it("rejects changing a physical element to a definition model", () => {
const physElem = insertElement(modelAId);
expect(() => txn.changeElementModel({ id: physElem, modelId: defModelAId })).to.throw("cannot move element from model of type");
});
it("rejects changing a definition element into a physical model", () => {
const defElem = insertDefinitionElement(defModelAId, "DefElemToMoveToPhys");
expect(() => txn.changeElementModel({ id: defElem, modelId: modelAId })).to.throw("cannot move element from model of type");
});
});
});
//# sourceMappingURL=ChangeElementParentModel.test.js.map