@itwin/imodel-transformer 0.1.17-dev.3 → 0.1.17-fedguidopt.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ const PendingReferenceMap_1 = require("./PendingReferenceMap");
22
22
  const EntityMap_1 = require("./EntityMap");
23
23
  const IModelCloneContext_1 = require("./IModelCloneContext");
24
24
  const EntityUnifier_1 = require("./EntityUnifier");
25
+ const Algo_1 = require("./Algo");
25
26
  const loggerCategory = TransformerLoggerCategory_1.TransformerLoggerCategory.IModelTransformer;
26
27
  const nullLastProvenanceEntityInfo = {
27
28
  entityId: core_bentley_1.Id64.invalid,
@@ -94,6 +95,12 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
94
95
  get targetScopeElementId() {
95
96
  return this._options.targetScopeElementId;
96
97
  }
98
+ get _isReverseSynchronization() {
99
+ return this._isSynchronization && this._options.isReverseSynchronization;
100
+ }
101
+ get _isForwardSynchronization() {
102
+ return this._isSynchronization && !this._options.isReverseSynchronization;
103
+ }
97
104
  /** The element classes that are considered to define provenance in the iModel */
98
105
  static get provenanceElementClasses() {
99
106
  return [core_backend_1.FolderLink, core_backend_1.SynchronizationConfigLink, core_backend_1.ExternalSource, core_backend_1.ExternalSourceAttachment];
@@ -112,14 +119,42 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
112
119
  /** map of (unprocessed element, referencing processed element) pairs to the partially committed element that needs the reference resolved
113
120
  * and have some helper methods below for now */
114
121
  this._pendingReferences = new PendingReferenceMap_1.PendingReferenceMap();
122
+ /** a set of elements for which source provenance will be explicitly tracked by ExternalSourceAspects */
123
+ this._elementsWithExplicitlyTrackedProvenance = new Set();
115
124
  /** map of partially committed entities to their partial commit progress */
116
125
  this._partiallyCommittedEntities = new EntityMap_1.EntityMap();
126
+ this._isSynchronization = false;
127
+ this._changesetRanges = undefined;
128
+ // FIXME: add test using this
129
+ /**
130
+ * Previously the transformer would insert provenance always pointing to the "target" relationship.
131
+ * It should (and now by default does) instead insert provenance pointing to the provenanceSource
132
+ * SEE: https://github.com/iTwin/imodel-transformer/issues/54
133
+ * This exists only to facilitate testing that the transformer can handle the older, flawed method
134
+ */
135
+ this._forceOldRelationshipProvenanceMethod = false;
136
+ /** NOTE: the json properties must be converted to string before insertion */
137
+ this._targetScopeProvenanceProps = undefined;
138
+ /**
139
+ * Index of the changeset that the transformer was at when the transformation begins (was constructed).
140
+ * Used to determine at the end which changesets were part of a synchronization.
141
+ */
142
+ this._startingTargetChangesetIndex = undefined;
143
+ this._cachedSynchronizationVersion = undefined;
144
+ this._targetClassNameToClassIdCache = new Map();
145
+ // if undefined, it can be initialized by calling [[this._cacheSourceChanges]]
146
+ this._hasElementChangedCache = undefined;
147
+ this._deletedSourceRelationshipData = undefined;
117
148
  this._yieldManager = new core_bentley_1.YieldManager();
118
149
  /** The directory where schemas will be exported, a random temporary directory */
119
150
  this._schemaExportDir = path.join(core_backend_1.KnownLocations.tmpdir, core_bentley_1.Guid.createValue());
120
151
  this._longNamedSchemasMap = new Map();
121
152
  /** state to prevent reinitialization, @see [[initialize]] */
122
153
  this._initialized = false;
154
+ /** length === 0 when _changeDataState = "no-change", length > 0 means "has-changes", otherwise undefined */
155
+ this._changeSummaryIds = undefined;
156
+ this._sourceChangeDataState = "uninited";
157
+ /** previous provenance, either a federation guid, a `${sourceFedGuid}/${targetFedGuid}` pair, or required aspect props */
123
158
  this._lastProvenanceEntityInfo = nullLastProvenanceEntityInfo;
124
159
  // initialize IModelTransformOptions
125
160
  this._options = {
@@ -167,6 +202,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
167
202
  this.targetDb = this.importer.targetDb;
168
203
  // create the IModelCloneContext, it must be initialized later
169
204
  this.context = new IModelCloneContext_1.IModelCloneContext(this.sourceDb, this.targetDb);
205
+ this._startingTargetChangesetIndex = this.targetDb?.changeset.index;
170
206
  }
171
207
  /** Dispose any native resources associated with this IModelTransformer. */
172
208
  dispose() {
@@ -195,26 +231,23 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
195
231
  get provenanceDb() {
196
232
  return this._options.isReverseSynchronization ? this.sourceDb : this.targetDb;
197
233
  }
198
- /** Return the IModelDb where IModelTransformer looks for entities referred to by stored provenance.
234
+ /** Return the IModelDb where IModelTransformer will NOT store its provenance.
199
235
  * @note This will be [[sourceDb]] except when it is a reverse synchronization. In that case it be [[targetDb]].
200
236
  */
201
237
  get provenanceSourceDb() {
202
238
  return this._options.isReverseSynchronization ? this.targetDb : this.sourceDb;
203
239
  }
204
- /** Create an ExternalSourceAspectProps in a standard way for an Element in an iModel --> iModel transformation. */
205
240
  initElementProvenance(sourceElementId, targetElementId) {
241
+ // FIXME: deprecate isReverseSync option and instead detect from targetScopeElement provenance
206
242
  const elementId = this._options.isReverseSynchronization ? sourceElementId : targetElementId;
207
243
  const aspectIdentifier = this._options.isReverseSynchronization ? targetElementId : sourceElementId;
208
- const version = this._options.isReverseSynchronization
209
- ? this.targetDb.elements.queryLastModifiedTime(targetElementId)
210
- : this.sourceDb.elements.queryLastModifiedTime(sourceElementId);
211
244
  const aspectProps = {
212
245
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
213
246
  element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
214
247
  scope: { id: this.targetScopeElementId },
215
248
  identifier: aspectIdentifier,
216
249
  kind: core_backend_1.ExternalSourceAspect.Kind.Element,
217
- version,
250
+ version: this.sourceDb.elements.queryLastModifiedTime(sourceElementId),
218
251
  };
219
252
  return aspectProps;
220
253
  }
@@ -224,32 +257,89 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
224
257
  * The ECInstanceId of the relationship in the target iModel will be stored in the JsonProperties of the ExternalSourceAspect.
225
258
  */
226
259
  initRelationshipProvenance(sourceRelationship, targetRelInstanceId) {
227
- const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, targetRelInstanceId);
228
- const elementId = this._options.isReverseSynchronization ? sourceRelationship.sourceId : targetRelationship.sourceId;
260
+ const elementId = this._options.isReverseSynchronization
261
+ ? sourceRelationship.sourceId
262
+ : this.targetDb.withPreparedStatement("SELECT SourceECInstanceId FROM Bis.ElementRefersToElements WHERE ECInstanceId=?", (stmt) => {
263
+ stmt.bindId(1, targetRelInstanceId);
264
+ nodeAssert(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
265
+ return stmt.getValue(0).getId();
266
+ });
229
267
  const aspectIdentifier = this._options.isReverseSynchronization ? targetRelInstanceId : sourceRelationship.id;
268
+ const jsonProperties = this._forceOldRelationshipProvenanceMethod
269
+ ? { targetRelInstanceId }
270
+ : { provenanceRelInstanceId: this._isReverseSynchronization
271
+ ? sourceRelationship.id
272
+ : targetRelInstanceId,
273
+ };
230
274
  const aspectProps = {
231
275
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
232
276
  element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
233
277
  scope: { id: this.targetScopeElementId },
234
278
  identifier: aspectIdentifier,
235
279
  kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
236
- jsonProperties: JSON.stringify({ targetRelInstanceId }),
280
+ jsonProperties: JSON.stringify(jsonProperties),
237
281
  };
238
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps);
239
282
  return aspectProps;
240
283
  }
241
- validateScopeProvenance() {
284
+ /** the changeset in the scoping element's source version found for this transformation
285
+ * @note: the version depends on whether this is a reverse synchronization or not, as
286
+ * it is stored separately for both synchronization directions
287
+ * @note: empty string and -1 for changeset and index if it has never been transformed
288
+ */
289
+ get _synchronizationVersion() {
290
+ if (!this._cachedSynchronizationVersion) {
291
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps was not set yet");
292
+ const version = this._options.isReverseSynchronization
293
+ ? this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion
294
+ : this._targetScopeProvenanceProps.version;
295
+ nodeAssert(version !== undefined, "no version contained in target scope");
296
+ const [id, index] = version === ""
297
+ ? ["", -1]
298
+ : version.split(";");
299
+ this._cachedSynchronizationVersion = { index: Number(index), id };
300
+ nodeAssert(!Number.isNaN(this._cachedSynchronizationVersion.index), "bad parse: invalid index in version");
301
+ }
302
+ return this._cachedSynchronizationVersion;
303
+ }
304
+ /**
305
+ * Make sure there are no conflicting other scope-type external source aspects on the *target scope element*,
306
+ * If there are none at all, insert one, then this must be a first synchronization.
307
+ * @returns the last synced version (changesetId) on the target scope's external source aspect,
308
+ * if this was a [BriefcaseDb]($backend)
309
+ */
310
+ initScopeProvenance() {
242
311
  const aspectProps = {
312
+ id: undefined,
313
+ version: undefined,
243
314
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
244
315
  element: { id: this.targetScopeElementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
245
316
  scope: { id: core_common_1.IModel.rootSubjectId },
246
- identifier: this._options.isReverseSynchronization ? this.targetDb.iModelId : this.sourceDb.iModelId,
317
+ identifier: this.provenanceSourceDb.iModelId,
247
318
  kind: core_backend_1.ExternalSourceAspect.Kind.Scope,
319
+ jsonProperties: undefined,
248
320
  };
249
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps); // this query includes "identifier"
321
+ // FIXME: handle older transformed iModels which do NOT have the version
322
+ // or reverseSyncVersion set correctly
323
+ const externalSource = this.queryScopeExternalSource(aspectProps, { getJsonProperties: true }); // this query includes "identifier"
324
+ aspectProps.id = externalSource.aspectId;
325
+ aspectProps.version = externalSource.version;
326
+ aspectProps.jsonProperties = externalSource.jsonProperties ? JSON.parse(externalSource.jsonProperties) : {};
250
327
  if (undefined === aspectProps.id) {
328
+ aspectProps.version = ""; // empty since never before transformed. Will be updated in [[finalizeTransformation]]
329
+ aspectProps.jsonProperties = {
330
+ pendingReverseSyncChangesetIndices: [],
331
+ pendingSyncChangesetIndices: [],
332
+ reverseSyncVersion: "", // empty since never before transformed. Will be updated in first reverse sync
333
+ };
251
334
  // this query does not include "identifier" to find possible conflicts
252
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind LIMIT 1`;
335
+ const sql = `
336
+ SELECT ECInstanceId
337
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
338
+ WHERE Element.Id=:elementId
339
+ AND Scope.Id=:scopeId
340
+ AND Kind=:kind
341
+ LIMIT 1
342
+ `;
253
343
  const hasConflictingScope = this.provenanceDb.withPreparedStatement(sql, (statement) => {
254
344
  statement.bindId("elementId", aspectProps.element.id);
255
345
  statement.bindId("scopeId", aspectProps.scope.id); // this scope.id can never be invalid, we create it above
@@ -260,41 +350,121 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
260
350
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.InvalidId, "Provenance scope conflict");
261
351
  }
262
352
  if (!this._options.noProvenance) {
263
- this.provenanceDb.elements.insertAspect(aspectProps);
353
+ this.provenanceDb.elements.insertAspect({
354
+ ...aspectProps,
355
+ jsonProperties: JSON.stringify(aspectProps.jsonProperties),
356
+ });
264
357
  }
265
358
  }
359
+ this._targetScopeProvenanceProps = aspectProps;
266
360
  }
267
- queryExternalSourceAspectId(aspectProps) {
268
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind AND Identifier=:identifier LIMIT 1`;
361
+ /**
362
+ * @returns the id and version of an aspect with the given element, scope, kind, and identifier
363
+ * May also return a reverseSyncVersion from json properties if requested
364
+ */
365
+ queryScopeExternalSource(aspectProps, { getJsonProperties = false } = {}) {
366
+ const sql = `
367
+ SELECT ECInstanceId, Version
368
+ ${getJsonProperties ? ", JsonProperties" : ""}
369
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
370
+ WHERE Element.Id=:elementId
371
+ AND Scope.Id=:scopeId
372
+ AND Kind=:kind
373
+ AND Identifier=:identifier
374
+ LIMIT 1
375
+ `;
376
+ const emptyResult = { aspectId: undefined, version: undefined, jsonProperties: undefined };
269
377
  return this.provenanceDb.withPreparedStatement(sql, (statement) => {
270
378
  statement.bindId("elementId", aspectProps.element.id);
271
379
  if (aspectProps.scope === undefined)
272
- return undefined; // return undefined instead of binding an invalid id
380
+ return emptyResult; // return undefined instead of binding an invalid id
273
381
  statement.bindId("scopeId", aspectProps.scope.id);
274
382
  statement.bindString("kind", aspectProps.kind);
275
383
  statement.bindString("identifier", aspectProps.identifier);
276
- return (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) ? statement.getValue(0).getId() : undefined;
384
+ if (core_bentley_1.DbResult.BE_SQLITE_ROW !== statement.step())
385
+ return emptyResult;
386
+ const aspectId = statement.getValue(0).getId();
387
+ const version = statement.getValue(1).getString();
388
+ const jsonProperties = getJsonProperties ? statement.getValue(2).getString() : undefined;
389
+ return { aspectId, version, jsonProperties };
277
390
  });
278
391
  }
279
- /** Iterate all matching ExternalSourceAspects in the provenance iModel (target unless reverse sync) and call a function for each one. */
392
+ /**
393
+ * Iterate all matching ExternalSourceAspects in the provenance iModel (target unless reverse sync) and call a function for each one.
394
+ * @note provenance is done by federation guids where possible
395
+ * @note this may execute on each element more than once! Only use in cases where that is handled
396
+ */
280
397
  forEachTrackedElement(fn) {
398
+ // FIXME: do we need an alternative for in-iModel transforms?
399
+ if (!this.context.isBetweenIModels)
400
+ return;
281
401
  if (!this.provenanceDb.containsClass(core_backend_1.ExternalSourceAspect.classFullName)) {
282
402
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadSchema, "The BisCore schema version of the target database is too old");
283
403
  }
284
- const sql = `SELECT Identifier,Element.Id FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Scope.Id=:scopeId AND Kind=:kind`;
285
- this.provenanceDb.withPreparedStatement(sql, (statement) => {
286
- statement.bindId("scopeId", this.targetScopeElementId);
287
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
288
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
289
- const aspectIdentifier = statement.getValue(0).getString(); // ExternalSourceAspect.Identifier is of type string
290
- const elementId = statement.getValue(1).getId();
291
- if (this._options.isReverseSynchronization) {
292
- fn(elementId, aspectIdentifier); // provenance coming from the sourceDb
404
+ // query for provenanceDb
405
+ const elementIdByFedGuidQuery = `
406
+ SELECT e.ECInstanceId, FederationGuid
407
+ FROM bis.Element e
408
+ WHERE e.ECInstanceId NOT IN (0x1, 0xe, 0x10) -- special static elements
409
+ ORDER BY FederationGuid
410
+ `;
411
+ // iterate through sorted list of fed guids from both dbs to get the intersection
412
+ // NOTE: if we exposed the native attach database support,
413
+ // we could get the intersection of fed guids in one query, not sure if it would be faster
414
+ // OR we could do a raw sqlite query...
415
+ this.sourceDb.withStatement(elementIdByFedGuidQuery, (sourceStmt) => this.targetDb.withStatement(elementIdByFedGuidQuery, (targetStmt) => {
416
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
417
+ return;
418
+ let sourceRow = sourceStmt.getRow();
419
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
420
+ return;
421
+ let targetRow = targetStmt.getRow();
422
+ // NOTE: these comparisons rely upon the lowercase of the guid,
423
+ // and the fact that '0' < '9' < a' < 'f' in ascii/utf8
424
+ while (true) {
425
+ const currSourceRow = sourceRow, currTargetRow = targetRow;
426
+ if (currSourceRow.federationGuid !== undefined
427
+ && currTargetRow.federationGuid !== undefined
428
+ && currSourceRow.federationGuid === currTargetRow.federationGuid) {
429
+ // data flow direction is always sourceDb -> targetDb and it does not depend on where the explicit element provenance is stored
430
+ fn(sourceRow.id, targetRow.id);
293
431
  }
294
- else {
295
- fn(aspectIdentifier, elementId); // provenance coming from the targetDb
432
+ if (currTargetRow.federationGuid === undefined
433
+ || (currSourceRow.federationGuid !== undefined
434
+ && currSourceRow.federationGuid >= currTargetRow.federationGuid)) {
435
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
436
+ return;
437
+ targetRow = targetStmt.getRow();
438
+ }
439
+ if (currSourceRow.federationGuid === undefined
440
+ || (currTargetRow.federationGuid !== undefined
441
+ && currSourceRow.federationGuid <= currTargetRow.federationGuid)) {
442
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
443
+ return;
444
+ sourceRow = sourceStmt.getRow();
296
445
  }
297
446
  }
447
+ }));
448
+ // query for provenanceDb
449
+ const provenanceAspectsQuery = `
450
+ SELECT esa.Identifier, Element.Id
451
+ FROM bis.ExternalSourceAspect esa
452
+ WHERE Scope.Id=:scopeId
453
+ AND Kind=:kind
454
+ `;
455
+ // Technically this will a second time call the function (as documented) on
456
+ // victims of the old provenance method that have both fedguids and an inserted aspect.
457
+ // But this is a private function with one known caller where that doesn't matter
458
+ this.provenanceDb.withPreparedStatement(provenanceAspectsQuery, (stmt) => {
459
+ const runFnInDataFlowDirection = (sourceId, targetId) => this._options.isReverseSynchronization ? fn(sourceId, targetId) : fn(targetId, sourceId);
460
+ stmt.bindId("scopeId", this.targetScopeElementId);
461
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
462
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
463
+ // ExternalSourceAspect.Identifier is of type string
464
+ const aspectIdentifier = stmt.getValue(0).getString();
465
+ const elementId = stmt.getValue(1).getId();
466
+ runFnInDataFlowDirection(elementId, aspectIdentifier);
467
+ }
298
468
  });
299
469
  }
300
470
  /** Initialize the source to target Element mapping from ExternalSourceAspects in the target iModel.
@@ -308,105 +478,329 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
308
478
  this.context.remapElement(sourceElementId, targetElementId);
309
479
  });
310
480
  if (args)
311
- return this.remapDeletedSourceElements(args);
481
+ return this.remapDeletedSourceEntities();
312
482
  }
313
- /** When processing deleted elements in a reverse synchronization, the [[provenanceDb]] (usually a branch iModel) has already
314
- * deleted the [ExternalSourceAspect]($backend)s that tell us which elements in the reverse synchronization target (usually
315
- * a master iModel) should be deleted. We must use the changesets to get the values of those before they were deleted.
483
+ /**
484
+ * Scan changesets for deleted entities, if in a reverse synchronization, provenance has
485
+ * already been deleted, so we must scan for that as well.
316
486
  */
317
- async remapDeletedSourceElements(args) {
487
+ async remapDeletedSourceEntities() {
318
488
  // we need a connected iModel with changes to remap elements with deletions
319
- if (this.sourceDb.iTwinId === undefined)
489
+ const notConnectedModel = this.sourceDb.iTwinId === undefined;
490
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
491
+ if (notConnectedModel || noChanges)
320
492
  return;
321
- try {
322
- const startChangesetIndexOrId = args.startChangeset?.index
323
- ?? args.startChangeset?.id
324
- ?? this.sourceDb.changeset.index
325
- ?? this.sourceDb.changeset.id;
326
- const endChangesetId = this.sourceDb.changeset.id;
327
- const [firstChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
328
- .map(async (indexOrId) => typeof indexOrId === "number"
329
- ? indexOrId
330
- : core_backend_1.IModelHost.hubAccess
331
- .queryChangeset({
332
- iModelId: this.sourceDb.iModelId,
333
- // eslint-disable-next-line deprecation/deprecation
334
- changeset: { id: indexOrId },
335
- accessToken: args.accessToken,
336
- })
337
- .then((changeset) => changeset.index)));
338
- const changesetIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
339
- accessToken: args.accessToken,
340
- iModelId: this.sourceDb.iModelId,
341
- iTwinId: this.sourceDb.iTwinId,
342
- range: { first: firstChangesetIndex, end: endChangesetIndex },
343
- });
344
- core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
345
- for (const changesetId of changesetIds) {
346
- this.sourceDb.withPreparedStatement(`
347
- SELECT esac.Element.Id, esac.Identifier
348
- FROM ecchange.change.InstanceChange ic
349
- JOIN BisCore.ExternalSourceAspect.Changes(:changesetId, 'BeforeDelete') esac
350
- ON ic.ChangedInstance.Id=esac.ECInstanceId
351
- WHERE ic.OpCode=:opcode
352
- AND ic.Summary.Id=:changesetId
353
- AND esac.Scope.Id=:targetScopeElementId
354
- -- not yet documented ecsql feature to check class id
355
- AND ic.ChangedInstance.ClassId IS (ONLY BisCore.ExternalSourceAspect)
356
- `, (stmt) => {
357
- stmt.bindInteger("opcode", core_common_1.ChangeOpCode.Delete);
358
- stmt.bindInteger("changesetId", changesetId);
359
- stmt.bindInteger("targetScopeElementId", this.targetScopeElementId);
360
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
361
- const targetId = stmt.getValue(0).getId();
362
- const sourceId = stmt.getValue(1).getString(); // BisCore.ExternalSourceAspect.Identifier stores a hex Id64String
363
- // TODO: maybe delete and don't just remap
364
- this.context.remapElement(targetId, sourceId);
493
+ this._deletedSourceRelationshipData = new Map();
494
+ nodeAssert(this._changeSummaryIds, "change summaries should be initialized before we get here");
495
+ nodeAssert(this._changeSummaryIds.length > 0, "change summaries should have at least one");
496
+ // optimization: if we have provenance, use it to avoid more querying later
497
+ // eventually when itwin.js supports attaching a second iModelDb in JS,
498
+ // this won't have to be a conditional part of the query, and we can always have it by attaching
499
+ const queryCanAccessProvenance = this.sourceDb === this.provenanceDb;
500
+ const deletedEntitySql = `
501
+ SELECT
502
+ 1 AS IsElemNotRel,
503
+ ic.ChangedInstance.Id AS InstanceId,
504
+ NULL AS InstId2, -- need these columns for relationship ends in the unioned query
505
+ NULL AS InstId3,
506
+ ec.FederationGuid AS FedGuid,
507
+ NULL AS FedGuid2,
508
+ ic.ChangedInstance.ClassId AS ClassId
509
+ ${queryCanAccessProvenance ? `
510
+ , coalesce(esa.Identifier, esac.Identifier) AS Identifier1
511
+ , NULL AS Identifier2
512
+ ` : ""}
513
+ FROM ecchange.change.InstanceChange ic
514
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') ec
515
+ ON ic.ChangedInstance.Id=ec.ECInstanceId
516
+ ${queryCanAccessProvenance ? `
517
+ LEFT JOIN bis.ExternalSourceAspect esa
518
+ ON ec.ECInstanceId=esa.Element.Id
519
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') esac
520
+ ON ec.ECInstanceId=esac.Element.Id
521
+ ` : ""}
522
+ WHERE ic.OpCode=:opDelete
523
+ AND ic.Summary.Id=:changeSummaryId
524
+ AND ic.ChangedInstance.ClassId IS (BisCore.Element)
525
+ ${queryCanAccessProvenance ? `
526
+ AND (esa.Scope.Id=:targetScopeElement OR esa.Scope.Id IS NULL)
527
+ AND (esa.Kind='Element' OR esa.Kind IS NULL)
528
+ AND (esac.Scope.Id=:targetScopeElement OR esac.Scope.Id IS NULL)
529
+ AND (esac.Kind='Element' OR esac.Kind IS NULL)
530
+ ` : ""}
531
+
532
+ UNION ALL
533
+
534
+ SELECT
535
+ 0 AS IsElemNotRel,
536
+ ic.ChangedInstance.Id AS InstanceId,
537
+ coalesce(se.ECInstanceId, sec.ECInstanceId) AS InstId2,
538
+ coalesce(te.ECInstanceId, tec.ECInstanceId) AS InstId3,
539
+ coalesce(se.FederationGuid, sec.FederationGuid) AS FedGuid1,
540
+ coalesce(te.FederationGuid, tec.FederationGuid) AS FedGuid2,
541
+ ic.ChangedInstance.ClassId AS ClassId
542
+ ${queryCanAccessProvenance ? `
543
+ , coalesce(sesa.Identifier, sesac.Identifier) AS Identifier1
544
+ , coalesce(tesa.Identifier, tesac.Identifier) AS Identifier2
545
+ ` : ""}
546
+ FROM ecchange.change.InstanceChange ic
547
+ LEFT JOIN bis.ElementRefersToElements.Changes(:changeSummaryId, 'BeforeDelete') ertec
548
+ ON ic.ChangedInstance.Id=ertec.ECInstanceId
549
+ -- FIXME: test a deletion of both an element and a relationship at the same time
550
+ LEFT JOIN bis.Element se
551
+ ON se.ECInstanceId=ertec.SourceECInstanceId
552
+ LEFT JOIN bis.Element te
553
+ ON te.ECInstanceId=ertec.TargetECInstanceId
554
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') sec
555
+ ON sec.ECInstanceId=ertec.SourceECInstanceId
556
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') tec
557
+ ON tec.ECInstanceId=ertec.TargetECInstanceId
558
+ ${queryCanAccessProvenance ? `
559
+ -- NOTE: need to join on both se/te and sec/tec incase the element was deleted
560
+ LEFT JOIN bis.ExternalSourceAspect sesa
561
+ ON se.ECInstanceId=sesa.Element.Id -- don't use *esac*.Identifier because it's a string
562
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') sesac
563
+ ON sec.ECInstanceId=sesac.Element.Id
564
+ LEFT JOIN bis.ExternalSourceAspect tesa
565
+ ON te.ECInstanceId=tesa.Element.Id
566
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') tesac
567
+ ON tec.ECInstanceId=tesac.Element.Id
568
+ ` : ""}
569
+ WHERE ic.OpCode=:opDelete
570
+ AND ic.Summary.Id=:changeSummaryId
571
+ AND ic.ChangedInstance.ClassId IS (BisCore.ElementRefersToElements)
572
+ ${queryCanAccessProvenance ? `
573
+ AND (sesa.Scope.Id=:targetScopeElement OR sesa.Scope.Id IS NULL)
574
+ AND (sesa.Kind='Relationship' OR sesa.Kind IS NULL)
575
+ AND (sesac.Scope.Id=:targetScopeElement OR sesac.Scope.Id IS NULL)
576
+ AND (sesac.Kind='Relationship' OR sesac.Kind IS NULL)
577
+ AND (tesa.Scope.Id=:targetScopeElement OR tesa.Scope.Id IS NULL)
578
+ AND (tesa.Kind='Relationship' OR tesa.Kind IS NULL)
579
+ AND (tesac.Scope.Id=:targetScopeElement OR tesac.Scope.Id IS NULL)
580
+ AND (tesac.Kind='Relationship' OR tesac.Kind IS NULL)
581
+ ` : ""}
582
+ `;
583
+ for (const changeSummaryId of this._changeSummaryIds) {
584
+ // FIXME: test deletion in both forward and reverse sync
585
+ this.sourceDb.withPreparedStatement(deletedEntitySql, (stmt) => {
586
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
587
+ if (queryCanAccessProvenance)
588
+ stmt.bindId("targetScopeElement", this.targetScopeElementId);
589
+ stmt.bindId("changeSummaryId", changeSummaryId);
590
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
591
+ const isElemNotRel = stmt.getValue(0).getBoolean();
592
+ const instId = stmt.getValue(1).getId();
593
+ if (isElemNotRel) {
594
+ const sourceElemFedGuid = stmt.getValue(4).getGuid();
595
+ // "Identifier" is a string, so null value returns '' which doesn't work with ??, and I don't like ||
596
+ let identifierValue;
597
+ // TODO: if I could attach the second db, will probably be much faster to get target id
598
+ // as part of the whole query rather than with _queryElemIdByFedGuid
599
+ const targetId = (queryCanAccessProvenance
600
+ && (identifierValue = stmt.getValue(7))
601
+ && !identifierValue.isNull
602
+ && identifierValue.getString())
603
+ // maybe batching these queries would perform better but we should
604
+ // try to attach the second db and query both together anyway
605
+ || (sourceElemFedGuid && this._queryElemIdByFedGuid(this.targetDb, sourceElemFedGuid))
606
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
607
+ || this._queryProvenanceForElement(instId);
608
+ // since we are processing one changeset at a time, we can see local source deletes
609
+ // of entities that were never synced and can be safely ignored
610
+ const deletionNotInTarget = !targetId;
611
+ if (deletionNotInTarget)
612
+ continue;
613
+ this.context.remapElement(instId, targetId);
365
614
  }
366
- });
367
- }
615
+ else { // is deleted relationship
616
+ const classFullName = stmt.getValue(6).getClassNameForClassId();
617
+ const [sourceIdInTarget, targetIdInTarget] = [
618
+ { guidColumn: 4, identifierColumn: 7, isTarget: false },
619
+ { guidColumn: 5, identifierColumn: 8, isTarget: true },
620
+ ].map(({ guidColumn, identifierColumn }) => {
621
+ const fedGuid = stmt.getValue(guidColumn).getGuid();
622
+ let identifierValue;
623
+ return ((queryCanAccessProvenance
624
+ // FIXME: this is really far from idiomatic, try to undo that
625
+ && (identifierValue = stmt.getValue(identifierColumn))
626
+ && !identifierValue.isNull
627
+ && identifierValue.getString())
628
+ // maybe batching these queries would perform better but we should
629
+ // try to attach the second db and query both together anyway
630
+ || (fedGuid && this._queryElemIdByFedGuid(this.targetDb, fedGuid)));
631
+ });
632
+ // since we are processing one changeset at a time, we can see local source deletes
633
+ // of entities that were never synced and can be safely ignored
634
+ if (sourceIdInTarget && targetIdInTarget) {
635
+ this._deletedSourceRelationshipData.set(instId, {
636
+ classFullName,
637
+ sourceIdInTarget,
638
+ targetIdInTarget,
639
+ });
640
+ }
641
+ else {
642
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
643
+ const relProvenance = this._queryProvenanceForRelationship(instId, {
644
+ classFullName,
645
+ sourceId: stmt.getValue(2).getId(),
646
+ targetId: stmt.getValue(3).getId(),
647
+ });
648
+ if (relProvenance && relProvenance.relationshipId)
649
+ this._deletedSourceRelationshipData.set(instId, {
650
+ classFullName,
651
+ relId: relProvenance.relationshipId,
652
+ provenanceAspectId: relProvenance.aspectId,
653
+ });
654
+ }
655
+ }
656
+ }
657
+ // NEXT: remap sourceId and targetId to target, get provenance there
658
+ // NOTE: it is possible during a forward sync for the target to already have deleted
659
+ // something that the source deleted, in which case we can safely ignore the gone provenance
660
+ });
368
661
  }
369
- finally {
370
- if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
371
- core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
662
+ }
663
+ _queryProvenanceForElement(entityInProvenanceSourceId) {
664
+ return this.provenanceDb.withPreparedStatement(`
665
+ SELECT esa.Element.Id
666
+ FROM Bis.ExternalSourceAspect esa
667
+ WHERE esa.Kind=?
668
+ AND esa.Scope.Id=?
669
+ AND esa.Identifier=?
670
+ `, (stmt) => {
671
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Element);
672
+ stmt.bindId(2, this.targetScopeElementId);
673
+ stmt.bindString(3, entityInProvenanceSourceId);
674
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
675
+ return stmt.getValue(0).getId();
676
+ else
677
+ return undefined;
678
+ });
679
+ }
680
+ _queryProvenanceForRelationship(entityInProvenanceSourceId, sourceRelInfo) {
681
+ return this.provenanceDb.withPreparedStatement(`
682
+ SELECT
683
+ ECInstanceId,
684
+ JSON_EXTRACT(JsonProperties, '$.targetRelInstanceId'),
685
+ JSON_EXTRACT(JsonProperties, '$.provenanceRelInstanceId')
686
+ FROM Bis.ExternalSourceAspect
687
+ WHERE Kind=?
688
+ AND Scope.Id=?
689
+ AND Identifier=?
690
+ `, (stmt) => {
691
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Relationship);
692
+ stmt.bindId(2, this.targetScopeElementId);
693
+ stmt.bindString(3, entityInProvenanceSourceId);
694
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
695
+ return undefined;
696
+ const aspectId = stmt.getValue(0).getId();
697
+ const provenanceRelInstIdVal = stmt.getValue(2);
698
+ const provenanceRelInstanceId = !provenanceRelInstIdVal.isNull
699
+ ? provenanceRelInstIdVal.getString()
700
+ : this._queryTargetRelId(sourceRelInfo);
701
+ return {
702
+ aspectId,
703
+ relationshipId: provenanceRelInstanceId,
704
+ };
705
+ });
706
+ }
707
+ _queryTargetRelId(sourceRelInfo) {
708
+ const targetRelInfo = {
709
+ sourceId: this.context.findTargetElementId(sourceRelInfo.sourceId),
710
+ targetId: this.context.findTargetElementId(sourceRelInfo.targetId),
711
+ };
712
+ if (targetRelInfo.sourceId === undefined || targetRelInfo.targetId === undefined)
713
+ return undefined; // couldn't find an element, rel is invalid or deleted
714
+ return this.targetDb.withPreparedStatement(`
715
+ SELECT ECInstanceId
716
+ FROM bis.ElementRefersToElements
717
+ WHERE SourceECInstanceId=?
718
+ AND TargetECInstanceId=?
719
+ AND ECClassId=?
720
+ `, (stmt) => {
721
+ stmt.bindId(1, targetRelInfo.sourceId);
722
+ stmt.bindId(2, targetRelInfo.targetId);
723
+ stmt.bindId(3, this._targetClassNameToClassId(sourceRelInfo.classFullName));
724
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
725
+ return undefined;
726
+ return stmt.getValue(0).getId();
727
+ });
728
+ }
729
+ _targetClassNameToClassId(classFullName) {
730
+ let classId = this._targetClassNameToClassIdCache.get(classFullName);
731
+ if (classId === undefined) {
732
+ classId = this._getRelClassId(this.targetDb, classFullName);
733
+ this._targetClassNameToClassIdCache.set(classFullName, classId);
372
734
  }
735
+ return classId;
736
+ }
737
+ // NOTE: this doesn't handle remapped element classes,
738
+ // but is only used for relationships rn
739
+ _getRelClassId(db, classFullName) {
740
+ return db.withPreparedStatement(`
741
+ SELECT c.ECInstanceId
742
+ FROM ECDbMeta.ECClassDef c
743
+ JOIN ECDbMeta.ECSchemaDef s ON c.Schema.Id=s.ECInstanceId
744
+ WHERE s.Name=? AND c.Name=?
745
+ `, (stmt) => {
746
+ const [schemaName, className] = classFullName.split(".");
747
+ stmt.bindString(1, schemaName);
748
+ stmt.bindString(2, className);
749
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
750
+ return stmt.getValue(0).getId();
751
+ (0, core_bentley_1.assert)(false, "relationship was not found");
752
+ });
753
+ }
754
+ _queryElemIdByFedGuid(db, fedGuid) {
755
+ return db.withPreparedStatement("SELECT ECInstanceId FROM Bis.Element WHERE FederationGuid=?", (stmt) => {
756
+ stmt.bindGuid(1, fedGuid);
757
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
758
+ return stmt.getValue(0).getId();
759
+ else
760
+ return undefined;
761
+ });
373
762
  }
374
763
  /** Returns `true` if *brute force* delete detections should be run.
375
764
  * @note Not relevant for processChanges when change history is known.
376
765
  */
377
766
  shouldDetectDeletes() {
767
+ // FIXME: all synchronizations should mark this as false
378
768
  if (this._isFirstSynchronization)
379
769
  return false; // not necessary the first time since there are no deletes to detect
380
770
  if (this._options.isReverseSynchronization)
381
771
  return false; // not possible for a reverse synchronization since provenance will be deleted when element is deleted
382
772
  return true;
383
773
  }
384
- /** Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements in the source iModel.
385
- * @see processChanges
386
- * @note This method is called from [[processAll]] and is not needed by [[processChanges]], so it only needs to be called directly when processing a subset of an iModel.
774
+ /**
775
+ * Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements
776
+ * in the source iModel.
777
+ * @deprecated in 0.1.x. This method is only called during [[processAll]] when the option
778
+ * [[IModelTransformerOptions.forceExternalSourceAspectProvenance]] is enabled. It is not
779
+ * necessary when using [[processChanges]] since changeset information is sufficient.
780
+ * @note you do not need to call this directly unless processing a subset of an iModel.
387
781
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
388
782
  */
389
783
  async detectElementDeletes() {
390
- if (this._options.isReverseSynchronization) {
391
- throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
392
- }
393
- const targetElementsToDelete = [];
394
- this.forEachTrackedElement((sourceElementId, targetElementId) => {
395
- if (undefined === this.sourceDb.elements.tryGetElementProps(sourceElementId)) {
396
- // if the sourceElement is not found, then it must have been deleted, so propagate the delete to the target iModel
397
- targetElementsToDelete.push(targetElementId);
398
- }
399
- });
400
- targetElementsToDelete.forEach((targetElementId) => {
401
- try {
402
- // TODO: make it possible to delete more elements at once to prevent redundant expensive
403
- // element reference scanning
404
- this.importer.deleteElement(targetElementId);
405
- }
406
- catch (err) {
407
- // ignore not found elements, iterative element tree deletion might have already deleted them
408
- if (err.name !== "Not Found")
409
- throw err;
784
+ const sql = `
785
+ SELECT Identifier, Element.Id
786
+ FROM BisCore.ExternalSourceAspect
787
+ WHERE Scope.Id=:scopeId
788
+ AND Kind=:kind
789
+ `;
790
+ nodeAssert(!this._options.isReverseSynchronization, "synchronizations with processChagnes already detect element deletes, don't call detectElementDeletes");
791
+ this.provenanceDb.withPreparedStatement(sql, (stmt) => {
792
+ stmt.bindId("scopeId", this.targetScopeElementId);
793
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
794
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
795
+ // ExternalSourceAspect.Identifier is of type string
796
+ const aspectIdentifier = stmt.getValue(0).getString();
797
+ if (!core_bentley_1.Id64.isId64(aspectIdentifier)) {
798
+ continue;
799
+ }
800
+ const targetElemId = stmt.getValue(1).getId();
801
+ const wasDeletedInSource = !EntityUnifier_1.EntityUnifier.exists(this.sourceDb, { entityReference: `e${aspectIdentifier}` });
802
+ if (wasDeletedInSource)
803
+ this.importer.deleteElement(targetElemId);
410
804
  }
411
805
  });
412
806
  }
@@ -433,24 +827,53 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
433
827
  }
434
828
  return targetElementProps;
435
829
  }
830
+ // FIXME: this is a PoC, see if we minimize memory usage
831
+ _cacheSourceChanges() {
832
+ nodeAssert(this._changeSummaryIds && this._changeSummaryIds.length > 0, "should have changeset data by now");
833
+ this._hasElementChangedCache = new Set();
834
+ const query = `
835
+ SELECT
836
+ ic.ChangedInstance.Id AS InstId
837
+ FROM ecchange.change.InstanceChange ic
838
+ JOIN iModelChange.Changeset imc ON ic.Summary.Id=imc.Summary.Id
839
+ -- FIXME: do relationship entities also need this cache optimization?
840
+ WHERE ic.ChangedInstance.ClassId IS (BisCore.Element)
841
+ AND InVirtualSet(:changeSummaryIds, ic.Summary.Id)
842
+ -- ignore deleted, we take care of those in remapDeletedSourceEntities
843
+ -- include inserted since inserted code-colliding elements should be considered
844
+ -- a change so that the colliding element is exported to the target
845
+ AND ic.OpCode<>:opDelete
846
+ `;
847
+ // there is a single mega-query multi-join+coalescing hack that I used originally to get around
848
+ // only being able to run table.Changes() on one changeset at once, but sqlite only supports up to 64
849
+ // tables in a join. Need to talk to core about .Changes being able to take a set of changesets
850
+ // You can find this version in the `federation-guid-optimization-megaquery` branch
851
+ // I wouldn't use it unless we prove via profiling that it speeds things up significantly
852
+ // And even then let's first try scanning the raw changesets instead of applying them as these queries
853
+ // require
854
+ this.sourceDb.withPreparedStatement(query, (stmt) => {
855
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
856
+ stmt.bindIdSet("changeSummaryIds", this._changeSummaryIds);
857
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
858
+ const instId = stmt.getValue(0).getId();
859
+ this._hasElementChangedCache.add(instId);
860
+ }
861
+ });
862
+ }
436
863
  /** Returns true if a change within sourceElement is detected.
437
864
  * @param sourceElement The Element from the source iModel
438
865
  * @param targetElementId The Element from the target iModel to compare against.
439
866
  * @note A subclass can override this method to provide custom change detection behavior.
440
867
  */
441
- hasElementChanged(sourceElement, targetElementId) {
442
- const sourceAspects = this.targetDb.elements.getAspects(targetElementId, core_backend_1.ExternalSourceAspect.classFullName);
443
- for (const sourceAspect of sourceAspects) {
444
- if (sourceAspect.scope === undefined) // if the scope was lost, we can't correlate so assume it changed
445
- return true;
446
- if (sourceAspect.identifier === sourceElement.id &&
447
- sourceAspect.scope.id === this.targetScopeElementId &&
448
- sourceAspect.kind === core_backend_1.ExternalSourceAspect.Kind.Element) {
449
- const lastModifiedTime = sourceElement.iModel.elements.queryLastModifiedTime(sourceElement.id);
450
- return lastModifiedTime !== sourceAspect.version;
451
- }
452
- }
453
- return true;
868
+ hasElementChanged(sourceElement, _targetElementId) {
869
+ if (this._sourceChangeDataState === "no-changes")
870
+ return false;
871
+ if (this._sourceChangeDataState === "unconnected")
872
+ return true;
873
+ nodeAssert(this._sourceChangeDataState === "has-changes", "change data should be initialized by now");
874
+ if (this._hasElementChangedCache === undefined)
875
+ this._cacheSourceChanges();
876
+ return this._hasElementChangedCache.has(sourceElement.id);
454
877
  }
455
878
  static transformCallbackFor(transformer, entity) {
456
879
  if (entity instanceof core_backend_1.Element)
@@ -622,51 +1045,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
622
1045
  targetElementId = this.context.findTargetElementId(sourceElement.id);
623
1046
  targetElementProps = this.onTransformElement(sourceElement);
624
1047
  }
1048
+ // if an existing remapping was not yet found, check by FederationGuid
1049
+ if (this.context.isBetweenIModels && !core_bentley_1.Id64.isValid(targetElementId) && sourceElement.federationGuid !== undefined) {
1050
+ targetElementId = this._queryElemIdByFedGuid(this.targetDb, sourceElement.federationGuid) ?? core_bentley_1.Id64.invalid;
1051
+ if (core_bentley_1.Id64.isValid(targetElementId))
1052
+ this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found
1053
+ }
625
1054
  // if an existing remapping was not yet found, check by Code as long as the CodeScope is valid (invalid means a missing reference so not worth checking)
626
- if (!core_bentley_1.Id64.isValidId64(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
1055
+ if (!core_bentley_1.Id64.isValid(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
627
1056
  // respond the same way to undefined code value as the @see Code class, but don't use that class because is trims
628
1057
  // whitespace from the value, and there are iModels out there with untrimmed whitespace that we ought not to trim
629
1058
  targetElementProps.code.value = targetElementProps.code.value ?? "";
630
- targetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
631
- if (undefined !== targetElementId) {
632
- const targetElement = this.targetDb.elements.getElement(targetElementId);
633
- if (targetElement.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1059
+ const maybeTargetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
1060
+ if (undefined !== maybeTargetElementId) {
1061
+ const maybeTargetElem = this.targetDb.elements.getElement(maybeTargetElementId);
1062
+ if (maybeTargetElem.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1063
+ targetElementId = maybeTargetElementId;
634
1064
  this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found by Code
635
1065
  }
636
1066
  else {
637
- targetElementId = undefined;
638
1067
  targetElementProps.code = core_common_1.Code.createEmpty(); // clear out invalid code
639
1068
  }
640
1069
  }
641
1070
  }
642
- if (undefined !== targetElementId && core_bentley_1.Id64.isValidId64(targetElementId)) {
643
- // compare LastMod of sourceElement to ExternalSourceAspect of targetElement to see there are changes to import
644
- if (!this.hasElementChanged(sourceElement, targetElementId)) {
645
- return;
646
- }
647
- }
1071
+ if (core_bentley_1.Id64.isValid(targetElementId) && !this.hasElementChanged(sourceElement, targetElementId))
1072
+ return;
648
1073
  this.collectUnmappedReferences(sourceElement);
649
- // TODO: untangle targetElementId state...
650
- if (targetElementId === core_bentley_1.Id64.invalid)
651
- targetElementId = undefined;
652
- targetElementProps.id = targetElementId; // targetElementId will be valid (indicating update) or undefined (indicating insert)
1074
+ // targetElementId will be valid (indicating update) or undefined (indicating insert)
1075
+ targetElementProps.id
1076
+ = core_bentley_1.Id64.isValid(targetElementId)
1077
+ ? targetElementId
1078
+ : undefined;
653
1079
  if (!this._options.wasSourceIModelCopiedToTarget) {
654
1080
  this.importer.importElement(targetElementProps); // don't need to import if iModel was copied
655
1081
  }
656
1082
  this.context.remapElement(sourceElement.id, targetElementProps.id); // targetElementProps.id assigned by importElement
657
1083
  // now that we've mapped this elem we can fix unmapped references to it
658
1084
  this.resolvePendingReferences(sourceElement);
1085
+ // the transformer does not currently 'split' or 'join' any elements, therefore, it does not
1086
+ // insert external source aspects because federation guids are sufficient for this.
1087
+ // Other transformer subclasses must insert the appropriate aspect (as provided by a TBD API)
1088
+ // when splitting/joining elements
1089
+ // physical consolidation is an example of a 'joining' transform
1090
+ // FIXME: document this externally!
1091
+ // verify at finalization time that we don't lose provenance on new elements
1092
+ // make public and improve `initElementProvenance` API for usage by consolidators
659
1093
  if (!this._options.noProvenance) {
660
- const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
661
- let aspectId = this.queryExternalSourceAspectId(aspectProps);
662
- if (aspectId === undefined) {
663
- aspectId = this.provenanceDb.elements.insertAspect(aspectProps);
664
- }
665
- else {
666
- this.provenanceDb.elements.updateAspect(aspectProps);
1094
+ let provenance = this._options.forceExternalSourceAspectProvenance || this._elementsWithExplicitlyTrackedProvenance.has(sourceElement.id)
1095
+ ? undefined
1096
+ : sourceElement.federationGuid;
1097
+ if (!provenance) {
1098
+ const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
1099
+ const aspectId = this.queryScopeExternalSource(aspectProps).aspectId;
1100
+ if (aspectId === undefined) {
1101
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1102
+ }
1103
+ else {
1104
+ aspectProps.id = aspectId;
1105
+ this.provenanceDb.elements.updateAspect(aspectProps);
1106
+ }
1107
+ provenance = aspectProps;
667
1108
  }
668
- aspectProps.id = aspectId;
669
- this.markLastProvenance(aspectProps, { isRelationship: false });
1109
+ this.markLastProvenance(provenance, { isRelationship: false });
670
1110
  }
671
1111
  }
672
1112
  resolvePendingReferences(entity) {
@@ -704,7 +1144,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
704
1144
  onDeleteModel(sourceModelId) {
705
1145
  // It is possible and apparently occasionally sensical to delete a model without deleting its underlying element.
706
1146
  // - If only the model is deleted, [[initFromExternalSourceAspects]] will have already remapped the underlying element since it still exists.
707
- // - If both were deleted, [[remapDeletedSourceElements]] will find and remap the deleted element making this operation valid
1147
+ // - If both were deleted, [[remapDeletedSourceEntities]] will find and remap the deleted element making this operation valid
708
1148
  const targetModelId = this.context.findTargetElementId(sourceModelId);
709
1149
  if (core_bentley_1.Id64.isValidId64(targetModelId)) {
710
1150
  this.importer.deleteModel(targetModelId);
@@ -780,7 +1220,56 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
780
1220
  * @deprecated in 3.x. This method is no longer necessary since the transformer no longer needs to defer elements
781
1221
  */
782
1222
  async processDeferredElements(_numRetries = 3) { }
1223
+ /** called at the end ([[finalizeTransformation]]) of a transformation,
1224
+ * updates the target scope element to say that transformation up through the
1225
+ * source's changeset has been performed.
1226
+ */
1227
+ _updateSynchronizationVersion() {
1228
+ if (this._sourceChangeDataState !== "has-changes" && !this._isFirstSynchronization)
1229
+ return;
1230
+ nodeAssert(this._targetScopeProvenanceProps);
1231
+ const sourceVersion = `${this.sourceDb.changeset.id};${this.sourceDb.changeset.index}`;
1232
+ if (this._isFirstSynchronization) {
1233
+ const targetVersion = `${this.targetDb.changeset.id};${this.targetDb.changeset.index}`;
1234
+ this._targetScopeProvenanceProps.version = sourceVersion;
1235
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = targetVersion;
1236
+ }
1237
+ else if (this._options.isReverseSynchronization) {
1238
+ const oldVersion = this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion;
1239
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating reverse version from ${oldVersion} to ${sourceVersion}`);
1240
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = sourceVersion;
1241
+ }
1242
+ else if (!this._options.isReverseSynchronization) {
1243
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating sync version from ${this._targetScopeProvenanceProps.version} to ${sourceVersion}`);
1244
+ this._targetScopeProvenanceProps.version = sourceVersion;
1245
+ }
1246
+ if (this._isSynchronization) {
1247
+ (0, core_bentley_1.assert)(this.targetDb.changeset.index !== undefined && this._startingTargetChangesetIndex !== undefined, "_updateSynchronizationVersion was called without change history");
1248
+ const jsonProps = this._targetScopeProvenanceProps.jsonProperties;
1249
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1250
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1251
+ const [syncChangesetsToClear, syncChangesetsToUpdate] = this._isReverseSynchronization
1252
+ ? [jsonProps.pendingReverseSyncChangesetIndices, jsonProps.pendingSyncChangesetIndices]
1253
+ : [jsonProps.pendingSyncChangesetIndices, jsonProps.pendingReverseSyncChangesetIndices];
1254
+ // NOTE that as documented in [[processChanges]], this assumes that right after
1255
+ // transformation finalization, the work will be saved immediately, otherwise we've
1256
+ // just marked this changeset as a synchronization to ignore, and the user can add other
1257
+ // stuff to it which would break future synchronizations
1258
+ // FIXME: force save for the user to prevent that
1259
+ for (let i = this._startingTargetChangesetIndex + 1; i <= this.targetDb.changeset.index + 1; i++)
1260
+ syncChangesetsToUpdate.push(i);
1261
+ syncChangesetsToClear.length = 0;
1262
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1263
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1264
+ }
1265
+ this.provenanceDb.elements.updateAspect({
1266
+ ...this._targetScopeProvenanceProps,
1267
+ jsonProperties: JSON.stringify(this._targetScopeProvenanceProps.jsonProperties),
1268
+ });
1269
+ }
1270
+ // FIXME: is this necessary when manually using lowlevel transform APIs?
783
1271
  finalizeTransformation() {
1272
+ this._updateSynchronizationVersion();
784
1273
  if (this._partiallyCommittedEntities.size > 0) {
785
1274
  core_bentley_1.Logger.logWarning(loggerCategory, [
786
1275
  "The following elements were never fully resolved:",
@@ -792,6 +1281,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
792
1281
  partiallyCommittedElem.forceComplete();
793
1282
  }
794
1283
  }
1284
+ // FIXME: make processAll have a try {} finally {} that cleans this up
1285
+ if (!this._options.noDetachChangeCache) {
1286
+ if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
1287
+ core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
1288
+ }
795
1289
  }
796
1290
  /** Imports all relationships that subclass from the specified base class.
797
1291
  * @param baseRelClassFullName The specified base relationship class.
@@ -809,40 +1303,52 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
809
1303
  * This override calls [[onTransformRelationship]] and then [IModelImporter.importRelationship]($transformer) to update the target iModel.
810
1304
  */
811
1305
  onExportRelationship(sourceRelationship) {
1306
+ const sourceFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.sourceId);
1307
+ const targetFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.targetId);
812
1308
  const targetRelationshipProps = this.onTransformRelationship(sourceRelationship);
813
1309
  const targetRelationshipInstanceId = this.importer.importRelationship(targetRelationshipProps);
814
- if (!this._options.noProvenance && core_bentley_1.Id64.isValidId64(targetRelationshipInstanceId)) {
815
- const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
816
- if (undefined === aspectProps.id) {
817
- aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1310
+ if (!this._options.noProvenance && core_bentley_1.Id64.isValid(targetRelationshipInstanceId)) {
1311
+ let provenance = !this._options.forceExternalSourceAspectProvenance
1312
+ ? sourceFedGuid && targetFedGuid && `${sourceFedGuid}/${targetFedGuid}`
1313
+ : undefined;
1314
+ if (!provenance) {
1315
+ const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
1316
+ aspectProps.id = this.queryScopeExternalSource(aspectProps).aspectId;
1317
+ if (undefined === aspectProps.id) {
1318
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1319
+ }
1320
+ provenance = aspectProps;
818
1321
  }
819
- (0, core_bentley_1.assert)(aspectProps.id !== undefined);
820
- this.markLastProvenance(aspectProps, { isRelationship: true });
1322
+ this.markLastProvenance(provenance, { isRelationship: true });
821
1323
  }
822
1324
  }
823
1325
  /** Override of [IModelExportHandler.onDeleteRelationship]($transformer) that is called when [IModelExporter]($transformer) detects that a [Relationship]($backend) has been deleted from the source iModel.
824
1326
  * This override propagates the delete to the target iModel via [IModelImporter.deleteRelationship]($transformer).
825
1327
  */
826
1328
  onDeleteRelationship(sourceRelInstanceId) {
827
- const sql = `SELECT ECInstanceId,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect` +
828
- ` WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind AND aspect.Identifier=:identifier LIMIT 1`;
829
- this.targetDb.withPreparedStatement(sql, (statement) => {
830
- statement.bindId("scopeId", this.targetScopeElementId);
831
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
832
- statement.bindString("identifier", sourceRelInstanceId);
833
- if (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
834
- const json = JSON.parse(statement.getValue(1).getString());
835
- if (undefined !== json.targetRelInstanceId) {
836
- const targetRelationship = this.targetDb.relationships.tryGetInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
837
- if (targetRelationship) {
838
- this.importer.deleteRelationship(targetRelationship.toJSON());
839
- }
840
- this.targetDb.elements.deleteAspect(statement.getValue(0).getId());
841
- }
842
- }
843
- });
1329
+ nodeAssert(this._deletedSourceRelationshipData, "should be defined at initialization by now");
1330
+ const deletedRelData = this._deletedSourceRelationshipData.get(sourceRelInstanceId);
1331
+ if (!deletedRelData) {
1332
+ // this can occur if both the source and target deleted it
1333
+ core_bentley_1.Logger.logWarning(loggerCategory, "tried to delete a relationship that wasn't in change data");
1334
+ return;
1335
+ }
1336
+ const relArg = deletedRelData.relId ?? {
1337
+ sourceId: deletedRelData.sourceIdInTarget,
1338
+ targetId: deletedRelData.targetIdInTarget,
1339
+ };
1340
+ //
1341
+ // FIXME: make importer.deleteRelationship not need full props
1342
+ const targetRelationship = this.targetDb.relationships.tryGetInstance(deletedRelData.classFullName, relArg);
1343
+ if (targetRelationship) {
1344
+ this.importer.deleteRelationship(targetRelationship.toJSON());
1345
+ }
1346
+ if (deletedRelData.provenanceAspectId) {
1347
+ this.provenanceDb.elements.deleteAspect(deletedRelData.provenanceAspectId);
1348
+ }
844
1349
  }
845
1350
  /** Detect Relationship deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against relationships in the source iModel.
1351
+ * @deprecated
846
1352
  * @see processChanges
847
1353
  * @note This method is called from [[processAll]] and is not needed by [[processChanges]], so it only needs to be called directly when processing a subset of an iModel.
848
1354
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
@@ -852,13 +1358,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
852
1358
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
853
1359
  }
854
1360
  const aspectDeleteIds = [];
855
- const sql = `SELECT ECInstanceId,Identifier,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind`;
1361
+ const sql = `
1362
+ SELECT ECInstanceId, Identifier, JsonProperties
1363
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect
1364
+ WHERE aspect.Scope.Id=:scopeId
1365
+ AND aspect.Kind=:kind
1366
+ `;
856
1367
  await this.targetDb.withPreparedStatement(sql, async (statement) => {
857
1368
  statement.bindId("scopeId", this.targetScopeElementId);
858
1369
  statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
859
1370
  while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
860
1371
  const sourceRelInstanceId = core_bentley_1.Id64.fromJSON(statement.getValue(1).getString());
861
1372
  if (undefined === this.sourceDb.relationships.tryGetInstanceProps(core_backend_1.ElementRefersToElements.classFullName, sourceRelInstanceId)) {
1373
+ // FIXME: make sure matches new provenance-based method
1374
+ // FIXME: use sql JSON_EXTRACT
862
1375
  const json = JSON.parse(statement.getValue(2).getString());
863
1376
  if (undefined !== json.targetRelInstanceId) {
864
1377
  const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
@@ -880,6 +1393,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
880
1393
  const targetRelationshipProps = sourceRelationship.toJSON();
881
1394
  targetRelationshipProps.sourceId = this.context.findTargetElementId(sourceRelationship.sourceId);
882
1395
  targetRelationshipProps.targetId = this.context.findTargetElementId(sourceRelationship.targetId);
1396
+ // TODO: move to cloneRelationship in IModelCloneContext
883
1397
  sourceRelationship.forEachProperty((propertyName, propertyMetaData) => {
884
1398
  if ((core_common_1.PrimitiveTypeCode.Long === propertyMetaData.primitiveType) && ("Id" === propertyMetaData.extendedType)) {
885
1399
  targetRelationshipProps[propertyName] = this.context.findTargetElementId(sourceRelationship.asAny[propertyName]);
@@ -1041,26 +1555,85 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1041
1555
  return this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1042
1556
  }
1043
1557
  /**
1044
- * Initialize prerequisites of processing, you must initialize with an [[InitFromExternalSourceAspectsArgs]] if you
1045
- * are intending process changes, but prefer using [[processChanges]]
1046
- * Called by all `process*` functions implicitly.
1558
+ * Initialize prerequisites of processing, you must initialize with an [[InitOptions]] if you
1559
+ * are intending to process changes, but prefer using [[processChanges]] explicitly since it calls this.
1560
+ * @note Called by all `process*` functions implicitly.
1047
1561
  * Overriders must call `super.initialize()` first
1048
1562
  */
1049
1563
  async initialize(args) {
1050
1564
  if (this._initialized)
1051
1565
  return;
1052
1566
  await this.context.initialize();
1567
+ await this._tryInitChangesetData(args);
1053
1568
  // eslint-disable-next-line deprecation/deprecation
1054
1569
  await this.initFromExternalSourceAspects(args);
1055
1570
  this._initialized = true;
1056
1571
  }
1572
+ async _tryInitChangesetData(args) {
1573
+ if (!args || this.sourceDb.iTwinId === undefined || this.sourceDb.changeset.index === undefined) {
1574
+ this._sourceChangeDataState = "unconnected";
1575
+ return;
1576
+ }
1577
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
1578
+ if (noChanges) {
1579
+ this._sourceChangeDataState = "no-changes";
1580
+ this._changeSummaryIds = [];
1581
+ return;
1582
+ }
1583
+ // NOTE: that we do NOT download the changesummary for the last transformed version, we want
1584
+ // to ignore those already processed changes
1585
+ const startChangesetIndexOrId = args.startChangeset?.index
1586
+ ?? args.startChangeset?.id
1587
+ ?? this._synchronizationVersion.index + 1;
1588
+ const endChangesetId = this.sourceDb.changeset.id;
1589
+ const [startChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
1590
+ .map(async (indexOrId) => typeof indexOrId === "number"
1591
+ ? indexOrId
1592
+ : core_backend_1.IModelHost.hubAccess
1593
+ .queryChangeset({
1594
+ iModelId: this.sourceDb.iModelId,
1595
+ // eslint-disable-next-line deprecation/deprecation
1596
+ changeset: { id: indexOrId },
1597
+ accessToken: args.accessToken,
1598
+ })
1599
+ .then((changeset) => changeset.index)));
1600
+ const missingChangesets = startChangesetIndex > this._synchronizationVersion.index + 1;
1601
+ // FIXME: add an option to ignore this check
1602
+ if (!this._options.ignoreMissingChangesetsInSynchronizations
1603
+ && startChangesetIndex !== this._synchronizationVersion.index + 1
1604
+ && this._synchronizationVersion.index !== -1) {
1605
+ throw Error(`synchronization is ${missingChangesets ? "missing changesets" : ""},`
1606
+ + " startChangesetId should be"
1607
+ + " exactly the first changeset *after* the previous synchronization to not miss data."
1608
+ + ` You specified '${startChangesetIndexOrId}' which is changeset #${startChangesetIndex}`
1609
+ + ` but the previous synchronization for this targetScopeElement was '${this._synchronizationVersion.id}'`
1610
+ + ` which is changeset #${this._synchronizationVersion.index}. The transformer expected`
1611
+ + ` #${this._synchronizationVersion.index + 1}.`);
1612
+ }
1613
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps should be set by now");
1614
+ const changesetsToSkip = this._isReverseSynchronization
1615
+ ? this._targetScopeProvenanceProps.jsonProperties.pendingReverseSyncChangesetIndices
1616
+ : this._targetScopeProvenanceProps.jsonProperties.pendingSyncChangesetIndices;
1617
+ core_bentley_1.Logger.logTrace(loggerCategory, `changesets to skip: ${changesetsToSkip}`);
1618
+ this._changesetRanges = (0, Algo_1.rangesFromRangeAndSkipped)(startChangesetIndex, endChangesetIndex, changesetsToSkip);
1619
+ core_bentley_1.Logger.logTrace(loggerCategory, `ranges: ${this._changesetRanges}`);
1620
+ for (const [first, end] of this._changesetRanges) {
1621
+ this._changeSummaryIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
1622
+ accessToken: args.accessToken,
1623
+ iModelId: this.sourceDb.iModelId,
1624
+ iTwinId: this.sourceDb.iTwinId,
1625
+ range: { first, end },
1626
+ });
1627
+ }
1628
+ core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
1629
+ this._sourceChangeDataState = "has-changes";
1630
+ }
1057
1631
  /** Export everything from the source iModel and import the transformed entities into the target iModel.
1058
1632
  * @note [[processSchemas]] is not called automatically since the target iModel may want a different collection of schemas.
1059
1633
  */
1060
1634
  async processAll() {
1061
- core_bentley_1.Logger.logTrace(loggerCategory, "processAll()");
1062
1635
  this.logSettings();
1063
- this.validateScopeProvenance();
1636
+ this.initScopeProvenance();
1064
1637
  await this.initialize();
1065
1638
  await this.exporter.exportCodeSpecs();
1066
1639
  await this.exporter.exportFonts();
@@ -1080,12 +1653,15 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1080
1653
  this.finalizeTransformation();
1081
1654
  }
1082
1655
  markLastProvenance(sourceAspect, { isRelationship = false }) {
1083
- this._lastProvenanceEntityInfo = {
1084
- entityId: sourceAspect.element.id,
1085
- aspectId: sourceAspect.id,
1086
- aspectVersion: sourceAspect.version ?? "",
1087
- aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1088
- };
1656
+ this._lastProvenanceEntityInfo
1657
+ = typeof sourceAspect === "string"
1658
+ ? sourceAspect
1659
+ : {
1660
+ entityId: sourceAspect.element.id,
1661
+ aspectId: sourceAspect.id,
1662
+ aspectVersion: sourceAspect.version ?? "",
1663
+ aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1664
+ };
1089
1665
  }
1090
1666
  /**
1091
1667
  * Load the state of the active transformation from an open SQLiteDb
@@ -1097,17 +1673,35 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1097
1673
  const lastProvenanceEntityInfo = db.withSqliteStatement(`SELECT entityId, aspectId, aspectVersion, aspectKind FROM ${IModelTransformer.lastProvenanceEntityInfoTable}`, (stmt) => {
1098
1674
  if (core_bentley_1.DbResult.BE_SQLITE_ROW !== stmt.step())
1099
1675
  throw Error("expected row when getting lastProvenanceEntityId from target state table");
1100
- return {
1101
- entityId: stmt.getValueString(0),
1102
- aspectId: stmt.getValueString(1),
1103
- aspectVersion: stmt.getValueString(2),
1104
- aspectKind: stmt.getValueString(3),
1105
- };
1676
+ const entityId = stmt.getValueString(0);
1677
+ const isGuidOrGuidPair = entityId.includes("-");
1678
+ return isGuidOrGuidPair
1679
+ ? entityId
1680
+ : {
1681
+ entityId,
1682
+ aspectId: stmt.getValueString(1),
1683
+ aspectVersion: stmt.getValueString(2),
1684
+ aspectKind: stmt.getValueString(3),
1685
+ };
1106
1686
  });
1107
- const targetHasCorrectLastProvenance =
1108
- // ignore provenance check if it's null since we can't bind those ids
1109
- !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1687
+ /*
1688
+ // TODO: maybe save transformer state resumption state based on target changset and require calls
1689
+ // to saveChanges
1690
+ if () {
1691
+ const [sourceFedGuid, targetFedGuid, relClassFullName] = lastProvenanceEntityInfo.split("/");
1692
+ const isRelProvenance = targetFedGuid !== undefined;
1693
+ const instanceId = isRelProvenance
1694
+ ? this.targetDb.elements.getElement({federationGuid: sourceFedGuid})
1695
+ : "";
1696
+ //const classId =
1697
+ if (isRelProvenance) {
1698
+ }
1699
+ }
1700
+ */
1701
+ const targetHasCorrectLastProvenance = typeof lastProvenanceEntityInfo === "string" ||
1702
+ // ignore provenance check if it's null since we can't bind those ids
1110
1703
  !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.entityId) ||
1704
+ !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1111
1705
  this.provenanceDb.withPreparedStatement(`
1112
1706
  SELECT Version FROM ${core_backend_1.ExternalSourceAspect.classFullName}
1113
1707
  WHERE Scope.Id=:scopeId
@@ -1148,6 +1742,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1148
1742
  this.context.loadStateFromDb(db);
1149
1743
  this.importer.loadStateFromJson(state.importerState);
1150
1744
  this.exporter.loadStateFromJson(state.exporterState);
1745
+ this._elementsWithExplicitlyTrackedProvenance = core_bentley_1.CompressedId64Set.decompressSet(state.explicitlyTrackedElements);
1151
1746
  this.loadAdditionalStateJson(state.additionalState);
1152
1747
  }
1153
1748
  /**
@@ -1195,6 +1790,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1195
1790
  const jsonState = {
1196
1791
  transformerClass: this.constructor.name,
1197
1792
  options: this._options,
1793
+ explicitlyTrackedElements: core_bentley_1.CompressedId64Set.compressSet(this._elementsWithExplicitlyTrackedProvenance),
1198
1794
  importerState: this.importer.saveStateToJson(),
1199
1795
  exporterState: this.exporter.saveStateToJson(),
1200
1796
  additionalState: this.getAdditionalStateJson(),
@@ -1204,8 +1800,9 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1204
1800
  throw Error("Failed to create the js state table in the state database");
1205
1801
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== db.executeSQL(`
1206
1802
  CREATE TABLE ${IModelTransformer.lastProvenanceEntityInfoTable} (
1207
- -- because we cannot bind the invalid id which we use for our null state, we actually store the id as a hex string
1803
+ -- either the invalid id for null provenance state, federation guid (or pair for rels) of the entity, or a hex element id
1208
1804
  entityId TEXT,
1805
+ -- the following are only valid if the above entityId is a hex id representation
1209
1806
  aspectId TEXT,
1210
1807
  aspectVersion TEXT,
1211
1808
  aspectKind TEXT
@@ -1219,10 +1816,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1219
1816
  throw Error("Failed to insert options into the state database");
1220
1817
  });
1221
1818
  db.withSqliteStatement(`INSERT INTO ${IModelTransformer.lastProvenanceEntityInfoTable} (entityId, aspectId, aspectVersion, aspectKind) VALUES (?,?,?,?)`, (stmt) => {
1222
- stmt.bindString(1, this._lastProvenanceEntityInfo.entityId);
1223
- stmt.bindString(2, this._lastProvenanceEntityInfo.aspectId);
1224
- stmt.bindString(3, this._lastProvenanceEntityInfo.aspectVersion);
1225
- stmt.bindString(4, this._lastProvenanceEntityInfo.aspectKind);
1819
+ const lastProvenanceEntityInfo = this._lastProvenanceEntityInfo;
1820
+ stmt.bindString(1, lastProvenanceEntityInfo?.entityId ?? this._lastProvenanceEntityInfo);
1821
+ stmt.bindString(2, lastProvenanceEntityInfo?.aspectId ?? "");
1822
+ stmt.bindString(3, lastProvenanceEntityInfo?.aspectVersion ?? "");
1823
+ stmt.bindString(4, lastProvenanceEntityInfo?.aspectKind ?? "");
1226
1824
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== stmt.step())
1227
1825
  throw Error("Failed to insert options into the state database");
1228
1826
  });
@@ -1250,31 +1848,50 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1250
1848
  db.closeDb();
1251
1849
  }
1252
1850
  }
1253
- async processChanges(accessTokenOrArgs, startChangesetId) {
1254
- core_bentley_1.Logger.logTrace(loggerCategory, "processChanges()");
1255
- this.logSettings();
1256
- this.validateScopeProvenance();
1257
- const options = typeof accessTokenOrArgs === "string"
1851
+ async processChanges(optionsOrAccessToken, startChangesetId) {
1852
+ this._isSynchronization = true;
1853
+ const args = typeof optionsOrAccessToken === "string"
1258
1854
  ? {
1259
- accessToken: accessTokenOrArgs,
1260
- startChangeset: startChangesetId ? { id: startChangesetId } : this.sourceDb.changeset,
1261
- changedInstanceIds: undefined,
1855
+ accessToken: optionsOrAccessToken,
1856
+ startChangeset: startChangesetId
1857
+ ? { id: startChangesetId }
1858
+ : this.sourceDb.changeset,
1262
1859
  }
1263
- : accessTokenOrArgs;
1264
- await this.initialize(options);
1265
- await this.exporter.exportChanges(options);
1860
+ : optionsOrAccessToken;
1861
+ this.logSettings();
1862
+ // FIXME: we used to validateScopeProvenance... does initing it cover that?
1863
+ this.initScopeProvenance();
1864
+ await this.initialize(args);
1865
+ // must wait for initialization of synchronization provenance data
1866
+ const changeArgs = this._changesetRanges
1867
+ ? { changesetRanges: this._changesetRanges }
1868
+ : args.startChangeset
1869
+ ? { startChangeset: args.startChangeset }
1870
+ : { startChangeset: { index: this._synchronizationVersion.index + 1 } };
1871
+ await this.exporter.exportChanges({ accessToken: args.accessToken, ...changeArgs });
1266
1872
  await this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1267
1873
  if (this._options.optimizeGeometry)
1268
1874
  this.importer.optimizeGeometry(this._options.optimizeGeometry);
1269
1875
  this.importer.computeProjectExtents();
1270
1876
  this.finalizeTransformation();
1271
1877
  }
1878
+ /** Combine an array of source elements into a single target element.
1879
+ * All source and target elements must be created before calling this method.
1880
+ * The "combine" operation is a remap and no properties from the source elements will be exported into the target
1881
+ * and provenance will be explicitly tracked by ExternalSourceAspects
1882
+ */
1883
+ combineElements(sourceElementIds, targetElementId) {
1884
+ for (const elementId of sourceElementIds) {
1885
+ this.context.remapElement(elementId, targetElementId);
1886
+ this._elementsWithExplicitlyTrackedProvenance.add(elementId);
1887
+ }
1888
+ }
1272
1889
  }
1273
- exports.IModelTransformer = IModelTransformer;
1274
1890
  /** @internal the name of the table where javascript state of the transformer is serialized in transformer state dumps */
1275
1891
  IModelTransformer.jsStateTable = "TransformerJsState";
1276
1892
  /** @internal the name of the table where the target state heuristics is serialized in transformer state dumps */
1277
1893
  IModelTransformer.lastProvenanceEntityInfoTable = "LastProvenanceEntityInfo";
1894
+ exports.IModelTransformer = IModelTransformer;
1278
1895
  /** IModelTransformer that clones the contents of a template model.
1279
1896
  * @beta
1280
1897
  */
@@ -1299,6 +1916,7 @@ class TemplateModelCloner extends IModelTransformer {
1299
1916
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1300
1917
  */
1301
1918
  async placeTemplate3d(sourceTemplateModelId, targetModelId, placement) {
1919
+ await this.initialize();
1302
1920
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1303
1921
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(placement.origin, placement.angles.toMatrix3d());
1304
1922
  this._sourceIdToTargetIdMap = new Map();
@@ -1319,6 +1937,7 @@ class TemplateModelCloner extends IModelTransformer {
1319
1937
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1320
1938
  */
1321
1939
  async placeTemplate2d(sourceTemplateModelId, targetModelId, placement) {
1940
+ await this.initialize();
1322
1941
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1323
1942
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(core_geometry_1.Point3d.createFrom(placement.origin), placement.rotation);
1324
1943
  this._sourceIdToTargetIdMap = new Map();
@@ -1355,16 +1974,12 @@ class TemplateModelCloner extends IModelTransformer {
1355
1974
  const targetElementProps = super.onTransformElement(sourceElement);
1356
1975
  targetElementProps.federationGuid = core_bentley_1.Guid.createValue(); // clone from template should create a new federationGuid
1357
1976
  targetElementProps.code = core_common_1.Code.createEmpty(); // clone from template should not maintain codes
1358
- if (sourceElement instanceof core_backend_1.GeometricElement3d) {
1359
- const placement = core_common_1.Placement3d.fromJSON(targetElementProps.placement);
1360
- if (placement.isValid) {
1361
- placement.multiplyTransform(this._transform3d);
1362
- targetElementProps.placement = placement;
1363
- }
1364
- }
1365
- else if (sourceElement instanceof core_backend_1.GeometricElement2d) {
1366
- const placement = core_common_1.Placement2d.fromJSON(targetElementProps.placement);
1977
+ if (sourceElement instanceof core_backend_1.GeometricElement) {
1978
+ const is3d = sourceElement instanceof core_backend_1.GeometricElement3d;
1979
+ const placementClass = is3d ? core_common_1.Placement3d : core_common_1.Placement2d;
1980
+ const placement = (placementClass).fromJSON(targetElementProps.placement);
1367
1981
  if (placement.isValid) {
1982
+ nodeAssert(this._transform3d);
1368
1983
  placement.multiplyTransform(this._transform3d);
1369
1984
  targetElementProps.placement = placement;
1370
1985
  }
@@ -1374,4 +1989,17 @@ class TemplateModelCloner extends IModelTransformer {
1374
1989
  }
1375
1990
  }
1376
1991
  exports.TemplateModelCloner = TemplateModelCloner;
1992
+ function queryElemFedGuid(db, elemId) {
1993
+ return db.withPreparedStatement(`
1994
+ SELECT FederationGuid
1995
+ FROM bis.Element
1996
+ WHERE ECInstanceId=?
1997
+ `, (stmt) => {
1998
+ stmt.bindId(1, elemId);
1999
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
2000
+ const result = stmt.getValue(0).getGuid();
2001
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_DONE);
2002
+ return result;
2003
+ });
2004
+ }
1377
2005
  //# sourceMappingURL=IModelTransformer.js.map