From c65ea25f880c7d0c20f0f3e7da1ce37912557f8f Mon Sep 17 00:00:00 2001 From: Kathleen Tuite Date: Wed, 23 Oct 2024 15:13:32 -0700 Subject: [PATCH] entity submission create-as-update --- lib/model/query/entities.js | 52 +++++++++++++++++---- test/integration/api/offline-entities.js | 57 +++++++++++++++++------- 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/lib/model/query/entities.js b/lib/model/query/entities.js index 08284d05b..60353644a 100644 --- a/lib/model/query/entities.js +++ b/lib/model/query/entities.js @@ -230,6 +230,13 @@ const _createEntity = (dataset, entityData, submissionId, submissionDef, submiss ? _partial.auxWith('def', { branchBaseVersion: _partial.def.baseVersion }) : _partial; + // Because of entity processing flow, we need to check for a conflict explicitly without + // distrupting the database transaction. + const maybeEntity = await Entities.getById(dataset.id, partial.uuid); + if (maybeEntity.isDefined()) + throw Problem.user.uniquenessViolation({ fields: ['uuid'], values: partial.uuid }); + + const sourceDetails = { submission: { instanceId: submissionDef.instanceId }, parentEventId: parentEvent ? parentEvent.id : undefined, @@ -252,14 +259,14 @@ const _createEntity = (dataset, entityData, submissionId, submissionDef, submiss return entity; }; -const _updateEntity = (dataset, entityData, submissionId, submissionDef, submissionDefId, event, forceOutOfOrderProcessing = false) => async ({ Audits, Entities }) => { +const _updateEntity = (dataset, entityData, submissionId, submissionDef, submissionDefId, event, forceOutOfOrderProcessing = false, createAsUpdate = false) => async ({ Audits, Entities }) => { if (!(event.action === 'submission.create' || event.action === 'submission.update.version' || event.action === 'submission.reprocess')) return null; // Get client version of entity - const clientEntity = await Entity.fromParseEntityData(entityData, { update: true }); // validation happens here + const clientEntity = await Entity.fromParseEntityData(entityData, createAsUpdate ? { create: true } : { update: true }); // validation happens here // Figure out the intended baseVersion // If this is an offline update with a branchId, the baseVersion value is local to that offline context. @@ -268,7 +275,7 @@ const _updateEntity = (dataset, entityData, submissionId, submissionDef, submiss // Try computing base version. // But if there is a 404.8 not found error, double-check if the entity never existed or was deleted. try { - baseEntityDef = await Entities._computeBaseVersion(event.id, dataset, clientEntity, submissionDef, forceOutOfOrderProcessing); + baseEntityDef = await Entities._computeBaseVersion(event.id, dataset, clientEntity, submissionDef, forceOutOfOrderProcessing, createAsUpdate); } catch (err) { if (err.problemCode === 404.8) { // Look up deleted entity by passing deleted as option argData @@ -315,6 +322,8 @@ const _updateEntity = (dataset, entityData, submissionId, submissionDef, submiss if (conflict !== ConflictType.HARD) { // We don't want to downgrade conflict here conflict = conflictingProperties.length > 0 ? ConflictType.HARD : ConflictType.SOFT; } + } else if (createAsUpdate) { + conflict = ConflictType.SOFT; } // merge data @@ -324,7 +333,8 @@ const _updateEntity = (dataset, entityData, submissionId, submissionDef, submiss // make some kind of source object const sourceDetails = { submission: { instanceId: submissionDef.instanceId }, - forceProcessed: forceOutOfOrderProcessing + forceProcessed: forceOutOfOrderProcessing, + createAsUpdate }; const sourceId = await Entities.createSource(sourceDetails, submissionDefId, event.id); const partial = new Entity.Partial(serverEntity.with({ conflict }), { @@ -359,8 +369,16 @@ const _updateEntity = (dataset, entityData, submissionId, submissionDef, submiss // Used by _updateVerison to figure out the intended base version in Central // based on the branchId, trunkVersion, and baseVersion in the submission -const _computeBaseVersion = (eventId, dataset, clientEntity, submissionDef, forceOutOfOrderProcessing = false) => async ({ Entities }) => { - if (!clientEntity.def.branchId) { +const _computeBaseVersion = (eventId, dataset, clientEntity, submissionDef, forceOutOfOrderProcessing = false, createAsUpdate = false) => async ({ Entities }) => { + if (clientEntity.def.baseVersion == null && createAsUpdate) { + // if no baseVersion is specified but we are updating and trying to find the base version + // we are probably in the special case of force-apply create-as-update. get the latest version. + + const latestEntity = await Entities.getById(dataset.id, clientEntity.uuid) + .then(getOrReject(Problem.user.entityNotFound({ entityUuid: clientEntity.uuid, datasetName: dataset.name }))); + return latestEntity.aux.currentVersion; + + } else if (!clientEntity.def.branchId) { // no offline branching to deal with, use baseVersion as is const condition = { version: clientEntity.def.baseVersion }; @@ -504,8 +522,26 @@ const _processSubmissionEvent = (event, parentEvent) => async ({ Audits, Dataset throw (err); } } - else if (entityData.system.create === '1' || entityData.system.create === 'true') - maybeEntity = await Entities._createEntity(dataset, entityData, submissionId, submissionDef, submissionDefId, event, parentEvent, forceOutOfOrderProcessing); + else if (entityData.system.create === '1' || entityData.system.create === 'true') { + // note i dont think (???) forceOutOfOrderProcessing will ever be true here? + try { + maybeEntity = await Entities._createEntity(dataset, entityData, submissionId, submissionDef, submissionDefId, event, parentEvent, forceOutOfOrderProcessing); + } catch (err) { + // There was a problem creating the entity + // If it is a uuid collision, check if the entity was created via an update + // in which case its ok to apply this create as an update + if (err.problemCode === 409.3) { + const rootDef = await Entities.getDef(dataset.id, entityData.system.id, new QueryOptions({ root: true })).then(o => o.orNull()); + if (rootDef && rootDef.aux.source.details.updateAsCreate && rootDef.aux.source.details.forceProcessed) { + maybeEntity = await Entities._updateEntity(dataset, entityData, submissionId, submissionDef, submissionDefId, event, forceOutOfOrderProcessing, true); + } else { + throw (err); + } + } else { + throw (err); + } + } + } // Check for held submissions that follow this one in the same branch if (maybeEntity != null) { diff --git a/test/integration/api/offline-entities.js b/test/integration/api/offline-entities.js index 191dfe7e9..a9b063024 100644 --- a/test/integration/api/offline-entities.js +++ b/test/integration/api/offline-entities.js @@ -936,7 +936,7 @@ describe('Offline Entities', () => { backlogCount.should.equal(0); })); - it('should apply an entity update as a create', testOfflineEntities(async (service, container) => { + it('should apply an entity update as a create [update-as-create]', testOfflineEntities(async (service, container) => { const asAlice = await service.login('alice'); const branchId = uuid(); const newUuid = uuid(); @@ -1003,7 +1003,7 @@ describe('Offline Entities', () => { backlogCount.should.equal(0); })); - it('should apply an entity update as a create followed by another update', testOfflineEntities(async (service, container) => { + it('should apply an entity update as a create followed by another update [update-as-create]', testOfflineEntities(async (service, container) => { const asAlice = await service.login('alice'); const branchId = uuid(); const newUuid = uuid(); @@ -1091,7 +1091,7 @@ describe('Offline Entities', () => { backlogCount.should.equal(0); })); - it.skip('should apply an entity update as a create, and then properly handle the delayed create', testOfflineEntities(async (service, container) => { + it('should apply an entity update as a create, and then properly handle the delayed create [create-as-update]', testOfflineEntities(async (service, container) => { const asAlice = await service.login('alice'); const branchId = uuid(); @@ -1126,8 +1126,8 @@ describe('Offline Entities', () => { body.currentVersion.data.should.eql({ status: 'checked in' }); body.currentVersion.label.should.eql('auto generated'); body.currentVersion.branchId.should.equal(branchId); + body.currentVersion.branchBaseVersion.should.equal(1); should.not.exist(body.currentVersion.baseVersion); - should.not.exist(body.currentVersion.branchBaseVersion); // No base version because this is a create, though maybe this should be here. should.not.exist(body.currentVersion.trunkVersion); }); @@ -1136,25 +1136,16 @@ describe('Offline Entities', () => { // First submission creates the entity, but this will be processed as an update await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') - .send(testData.instances.offlineEntity.two - .replace('branchId=""', `branchId="${branchId}"`) - ) + .send(testData.instances.offlineEntity.two) .set('Content-Type', 'application/xml') .expect(200); await exhaust(container); - // In the default behavior, attempting create on an entity that already exists causes a conflict error. - await asAlice.get('/v1/projects/1/forms/offlineEntity/submissions/two/audits') - .expect(200) - .then(({ body }) => { - body[0].details.errorMessage.should.eql('A resource already exists with uuid value(s) of 12345678-1234-4123-8234-123456789ddd.'); - }); - await asAlice.get(`/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd`) .expect(200) .then(({ body }) => { - body.currentVersion.version.should.equal(1); + body.currentVersion.version.should.equal(2); }); })); @@ -1434,6 +1425,42 @@ describe('Offline Entities', () => { }); })); + it('should mark a create-as-update', testOfflineEntities(async (service, container) => { + const asAlice = await service.login('alice'); + const branchId = uuid(); + + // Send update first + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.two + .replace('create="1"', 'update="1"') + .replace('branchId=""', `branchId="${branchId}"`) + .replace('two', 'two-update') + .replace('baseVersion=""', 'baseVersion="1"') + ) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + + // Force the update submission to be processed as a create + await container.Entities.processBacklog(true); + + // Send create in next to be applied as an update + await asAlice.post('/v1/projects/1/forms/offlineEntity/submissions') + .send(testData.instances.offlineEntity.two) + .set('Content-Type', 'application/xml') + .expect(200); + + await exhaust(container); + + await asAlice.get(`/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789ddd/versions`) + .expect(200) + .then(({ body }) => { + body[1].source.createAsUpdate.should.equal(true); + body[1].source.forceProcessed.should.equal(false); + }); + })); + it('should mark a regular update as not force processed', testOfflineEntities(async (service, container) => { const asAlice = await service.login('alice');