Write an iModel Bridge

As explained in the overview, a "bridge" is a program that:

  1. Reads information from a data source,
  2. Aligns the source data with the BIS schema and preferrably a domain schema, and
  3. Writes BIS data to an iModel.

Specificaly, a bridge must:

  • Open a local briefcase copy of the iModel that is to be updated.
  • Import or Update Schema
  • Convert Changed Data
    • Connect to the data source.
    • Detect changes to the source data.
    • Transform the new or changed source data into the target BIS schema.
    • Write the resulting BIS data to the local briefcase.
    • Remove BIS data corresponding to deleted source data.
    • Obtain required Locks and Codes from the iModel server and/or code server.
    • Push changes to the iModel server.

Writing a bridge

A bridge would import the following packages:

import { Id64String, ClientRequestContext } from "@bentley/bentleyjs-core";
import { BriefcaseManager, CategorySelector, ConcurrencyControl, DefinitionModel, DisplayStyle3d, IModelDb, IModelHost, ModelSelector, OpenParams, OrthographicViewDefinition, PhysicalModel, SpatialCategory, Subject } from "@bentley/imodeljs-backend";
import { ColorByName, ColorDef, IModel } from "@bentley/imodeljs-common";

When the bridge runs for the very first time, it would look like the following. This example revolves around the fictitious "RobotWorld" schema. RobotWorld consists of Robots and Barriers. The details of RobotWorld and its schema are not important. The steps, such as importing a schema, reserving codes, pushing changesets, creating definition models, etc., are the important points to see in the example code.

async function runBridgeFirstTime(requestContext: AuthorizedClientRequestContext, iModelId: string, projectId: string, assetsDir: string) {
  // Start the IModelHost
  IModelHost.startup();

  const briefcase = await IModelDb.open(requestContext, projectId, iModelId, OpenParams.pullAndPush());
  briefcase.concurrencyControl.setPolicy(new ConcurrencyControl.OptimisticPolicy());

  // I. Import the schema.
  await briefcase.importSchema(requestContext, path.join(assetsDir, "RobotWorld.ecschema.xml"));
  //    You must acquire all locks reserve all Codes used before saving or pushing.
  await briefcase.concurrencyControl.request(requestContext);
  //    You *must* push this to the iModel right now.
  briefcase.saveChanges();
  await briefcase.pullAndMergeChanges(requestContext);
  await briefcase.pushChanges(requestContext);

  // II. Import data

  // 1. Create the bridge's job subject and definition models.
  //    To keep things organized and to avoid name collisions with other bridges and apps,
  //    a bridge should normally create its own unique subject and then scope its models and
  //    definitions under that.
  const jobSubjectId = Subject.insert(briefcase, IModel.rootSubjectId, "RobotWorld"); // Job subject name must be unique among job subjects
  const defModelId = DefinitionModel.insert(briefcase, jobSubjectId, "definitions");

  //  Create the spatial categories that will be used by the Robots and Barriers that will be imported.
  SpatialCategory.insert(briefcase, defModelId, Robot.classFullName, { color: new ColorDef(ColorByName.silver) });
  SpatialCategory.insert(briefcase, defModelId, Barrier.classFullName, { color: new ColorDef(ColorByName.brown) });

  // 2. Convert elements, aspects, etc.

  // For this example, we'll put all of the in-coming elements in a single model.
  const spatialModelId = PhysicalModel.insert(briefcase, jobSubjectId, "spatial model 1");

  //  Pretend that I connected to a datasource and read it.
  //  In this simple example, the source format is JSON. It could be anything.
  //  Whatever the format, the bridge must be able to read it.
  const sourceData: RobotWorldProps = {
    barriers: [
      { location: { x: 0, y: 5, z: 0 }, angle: { degrees: 0 }, length: 5 },
    ],
    robots: [
      { location: { x: 0, y: 0, z: 0 }, name: "r1" },
    ],
  };
  convertToBis(briefcase, spatialModelId, sourceData);

  // 3. Create views.
  //    Note that the view definition and helper objects go into the definition model, not the spatial model.
  //    Note how Element IDs are captured as strings.
  const viewName = "Test Robot View";
  const modelSelectorId = ModelSelector.insert(briefcase, defModelId, viewName, [spatialModelId]);
  const spatialCategoryIds = [Robot.getCategory(briefcase).id, Barrier.getCategory(briefcase).id];
  const categorySelectorId = CategorySelector.insert(briefcase, defModelId, viewName, spatialCategoryIds);
  const displayStyleId = DisplayStyle3d.insert(briefcase, defModelId, viewName);
  const viewRange = new Range3d(0, 0, 0, 10, 10, 1); // Note that you could compute the extents from the imported geometry. But real-world assets have known extents.
  OrthographicViewDefinition.insert(briefcase, defModelId, viewName, modelSelectorId, categorySelectorId, displayStyleId, viewRange);

  //  III. Push the data changes to iModel Server

  // 1. Acquire Resources
  //    You must reserve all Codes used before saving or pushing.
  await briefcase.concurrencyControl.request(requestContext);

  // 2. Pull and then push.
  //    Note that you pull and merge first, in case another user has pushed.
  briefcase.saveChanges();
  await briefcase.pullAndMergeChanges(requestContext);
  await briefcase.pushChanges(requestContext);
}

See:

Here is a simple example of a fictitious source data format and the logic to convert and write it to an iModel:

interface BarrierProps {
  location: XYZProps;
  angle: AngleProps;
  length: number;
}

interface RobotProps {
  location: XYZProps;
  name: string;
}

interface RobotWorldProps {
  barriers: BarrierProps[];
  robots: RobotProps[];
}

// In this simple example, the source format is assumed to be JSON. It could be anything that the bridge
// can read. In this simple example, the conversion does not involve any alignment transformations. In general,
// the bridge's logic would perform non-trivial alignment of the source data into a BIS industry domain schema.
function convertToBis(briefcase: IModelDb, modelId: Id64String, data: RobotWorldProps) {
  for (const barrier of data.barriers) {
    RobotWorldEngine.insertBarrier(briefcase, modelId, Point3d.fromJSON(barrier.location), Angle.fromJSON(barrier.angle), barrier.length);
  }
  for (const robot of data.robots) {
    RobotWorldEngine.insertRobot(briefcase, modelId, robot.name, Point3d.fromJSON(robot.location));
  }
}

Detecting and pushing changes

Rather than starting over when the source data changes, a bridge should be able to detect and convert only the changes. That makes for compact, meaningful ChangeSets, which are added to the iModel's timeline.

In the case of source data that was previously converted and has changed, the bridge should update the data in the iModel that were the results of the previous conversion. In the case of source data that was previously converted and has been deleted in the source, the bridge should delete the results of the previous conversion. Source data that has been added should be inserted.

To do incremental updates, a bridge must do Id mapping and change-detection.

Id mapping

Id mapping is a way of looking up the data in the iModel that corresponds to a given piece of source data.

If the source data has stable, unique IDs, then Id mapping could be straightforward. The bridge just needs to record the source -> BIS Id mappings somewhere. If the source data IDs are GUIDs, then the bridge can assign them to the federationGuid property value of the BIS elements that it creates. That way, the mappings will be directly recorded in the iModel itself.

If the soruce data does not have stable, unique IDs, then the bridge will have to use some other means of identifying pieces of source data in a stable way. A crytographic hash of the source data itself can work as a stable Id -- that is, it can be used to identify data that has not changed.

Change-detection

Change-detection is a way of detecting changes in the source data.

If the source data is timestamped in some way, then the change-detection logic should be easy. The bridge just has to save the highest timestamp at the end of the conversion and then look for source data with later timestamps the next time it runs.

If timestamps are not available, then the bridge will have to use some other means of recording and then comparing the state of the source data from run to run. If conversion is cheap, then the source data can be be converted again and the results compared to the previous results, as stored in the iModel. Or, a crytographic hash of the source data may be used to represent the source data. The hash could be stored along with the mappings and used to detect changes.

A basic change-detection algorithm is:

  • For each source data item:
    • add source item's Id to the source_items_seen set
    • Look in the mappings for the corresponding data in the iModel (element, aspect, model)
    • If found,
      • Detect if the source item's current data has changed. If so,
        • Convert the source item to BIS data.
        • Update the corresponding data in the iModel
    • Else,
      • Convert the source data to BIS data
      • Insert the new data into the iModel
      • Add the source data item's Id to the mappings

Infer deletions:

  • For each source data item Id previously converted
    • if item Id is not in source_items_seen
      • Find the the corresponind data in the iModel
        • Delete the data in the iModel
        • Remove the the source data item's Id from the mappings

Last Updated: 08 January, 2020