Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance: add entity version number and modify update entity API to work with version #951

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions lib/data/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ const streamEntityCsv = (inStream, properties) => {
props.push(...properties.map(p => ['def', 'data', p.name]));

// System properties
header.push(...[ '__createdAt', '__creatorId', '__creatorName', '__updates', '__updatedAt']);
props.push(...['createdAt', 'creatorId', 'aux.creator.displayName', 'aux.stats.updates', 'updatedAt'].map(e => e.split('.')));
header.push(...[ '__createdAt', '__creatorId', '__creatorName', '__updates', '__updatedAt', '__version']);
props.push(...['createdAt', 'creatorId', 'aux.creator.displayName', 'aux.stats.updates', 'updatedAt', 'def.version'].map(e => e.split('.')));

const entityStream = _entityTransformer(header, props);

Expand All @@ -184,8 +184,8 @@ const streamEntityCsv = (inStream, properties) => {

const streamEntityCsvAttachment = (inStream, properties) => {
// Identifiers
const header = [ 'name', 'label' ];
const props = ['uuid', 'def.label'].map(e => e.split('.'));
const header = [ 'name', 'label', 'version' ];
const props = ['uuid', 'def.label', 'def.version'].map(e => e.split('.'));

// User defined dataset properties
header.push(...properties.map(p => p.name));
Expand Down Expand Up @@ -214,7 +214,8 @@ const selectFields = (entity, properties, selectedProperties) => {
creatorId: entity.aux.creator.id.toString(),
creatorName: entity.aux.creator.displayName,
updates: entity.aux.stats.updates,
updatedAt: entity.updatedAt
updatedAt: entity.updatedAt,
version: entity.def.version
};

if (!selectedProperties || selectedProperties.has('__system')) {
Expand Down
1 change: 1 addition & 0 deletions lib/model/frames/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Entity.Def = Frame.define(
'sourceId', 'label', readable,
'creatorId', readable, 'userAgent', readable,
'data', readable, 'root',
'version', readable,
embedded('creator'),
embedded('source')
);
Expand Down
25 changes: 25 additions & 0 deletions lib/model/migrations/20230824-01-add-entity-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2023 ODK Central Developers
// See the NOTICE file at the top-level directory of this distribution and at
// https://github.com/getodk/central-backend/blob/master/NOTICE.
// This file is part of ODK Central. It is subject to the license terms in
// the LICENSE file found in the top-level directory of this distribution and at
// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
// including this file, may be copied, modified, propagated, or distributed
// except according to the terms contained in the LICENSE file.

const up = async (db) => {
await db.raw('ALTER TABLE entity_defs ADD COLUMN version INT4 NOT NULL DEFAULT 1');

// Sets the correct version number for existing entities
await db.raw(`
UPDATE entity_defs SET "version" = vt.rownumber
FROM (
SELECT ROW_NUMBER() OVER(PARTITION BY "entityId" ORDER BY id ) rownumber, id FROM entity_defs
)
vt WHERE vt.id = entity_defs.id
`);
};

const down = (db) => db.raw('ALTER TABLE entity_defs DROP COLUMN version');

module.exports = { up, down };
12 changes: 6 additions & 6 deletions lib/model/query/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ const { runSequentially } = require('../../util/promise');
////////////////////////////////////////////////////////////////////////////////
// ENTITY CREATE

const _defInsert = (id, root, creatorId, userAgent, label, json, sourceId = null) => sql`
insert into entity_defs ("entityId", "root", "sourceId", "creatorId", "userAgent", "label", "data", "current", "createdAt")
values (${id}, ${root}, ${sourceId}, ${creatorId}, ${userAgent}, ${label}, ${json}, true, clock_timestamp())
const _defInsert = (id, root, creatorId, userAgent, label, json, version, sourceId = null) => sql`
insert into entity_defs ("entityId", "root", "sourceId", "creatorId", "userAgent", "label", "data", "current", "createdAt", "version")
values (${id}, ${root}, ${sourceId}, ${creatorId}, ${userAgent}, ${label}, ${json}, true, clock_timestamp(), ${version})
returning *`;
const nextval = sql`nextval(pg_get_serial_sequence('entities', 'id'))`;

Expand All @@ -51,7 +51,7 @@ const createNew = (dataset, partial, subDef, sourceId, userAgentIn) => ({ one, c
const json = JSON.stringify(partial.def.data);

return one(sql`
with def as (${_defInsert(nextval, true, creatorId, userAgent, partial.def.label, json, sourceId)}),
with def as (${_defInsert(nextval, true, creatorId, userAgent, partial.def.label, json, 1, sourceId)}),
ins as (insert into entities (id, "datasetId", "uuid", "createdAt", "creatorId")
select def."entityId", ${dataset.id}, ${partial.uuid}, def."createdAt", ${creatorId} from def
returning entities.*)
Expand Down Expand Up @@ -195,7 +195,7 @@ const createEntitiesFromPendingSubmissions = (submissionEvents, parentEvent) =>
////////////////////////////////////////////////////////////////////////////////
// UPDATING ENTITIES

const createVersion = (dataset, entity, data, label, sourceId, userAgentIn = null) => ({ context, one }) => {
const createVersion = (dataset, entity, data, label, version, sourceId, userAgentIn = null) => ({ context, one }) => {
// dataset is passed in so the audit log can use its actee id
const creatorId = context.auth.actor.map((actor) => actor.id).orNull();
const userAgent = blankStringToNull(userAgentIn);
Expand All @@ -204,7 +204,7 @@ const createVersion = (dataset, entity, data, label, sourceId, userAgentIn = nul
const _unjoiner = unjoiner(Entity, Entity.Def.into('currentVersion'));

return one(sql`
with def as (${_defInsert(entity.id, false, creatorId, userAgent, label, json, sourceId)}),
with def as (${_defInsert(entity.id, false, creatorId, userAgent, label, json, version, sourceId)}),
upd as (update entity_defs set current=false where entity_defs."entityId" = ${entity.id}),
entities as (update entities set "updatedAt"=clock_timestamp()
where "uuid"=${entity.uuid}
Expand Down
19 changes: 14 additions & 5 deletions lib/resources/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// except according to the terms contained in the LICENSE file.

const { getOrNotFound, reject } = require('../util/promise');
const { isTrue, success } = require('../util/http');
const { isTrue, success, withEtag } = require('../util/http');
const { Entity } = require('../model/frames');
const Problem = require('../util/problem');
const { diffEntityData } = require('../data/entity');
Expand All @@ -30,7 +30,10 @@ module.exports = (service, endpoint) => {

await auth.canOrReject('entity.read', dataset);

return Entities.getById(dataset.id, params.uuid, queryOptions).then(getOrNotFound);

const entity = await Entities.getById(dataset.id, params.uuid, queryOptions).then(getOrNotFound);

return withEtag(entity.aux.currentVersion.version, () => entity);
}));

service.get('/projects/:projectId/datasets/:name/entities/:uuid/versions', endpoint(async ({ Datasets, Entities }, { params, auth, queryOptions }) => {
Expand Down Expand Up @@ -91,21 +94,27 @@ module.exports = (service, endpoint) => {
return Entities.getById(dataset.id, entity.uuid).then(getOrNotFound);
}));

service.patch('/projects/:id/datasets/:name/entities/:uuid', endpoint(async ({ Datasets, Entities }, { auth, body, params, query, userAgent }) => {
service.patch('/projects/:id/datasets/:name/entities/:uuid', endpoint(async ({ Datasets, Entities }, { auth, body, params, query, userAgent, headers }) => {

const dataset = await Datasets.get(params.id, params.name).then(getOrNotFound);

await auth.canOrReject('entity.update', dataset);

const entity = await Entities.getById(dataset.id, params.uuid).then(getOrNotFound);

if (!isTrue(query.force)) return reject(Problem.internal.notImplemented({ feature: '(updating an entity without ?force=true flag)' }));
const clientEntityVersion = headers['if-match'] && headers['if-match'].replaceAll('"', '');
const serverEntityVersion = entity.aux.currentVersion.version;

if (clientEntityVersion !== serverEntityVersion.toString() && !isTrue(query.force)) {
return reject(Problem.user.entityVersionConflict({ current: serverEntityVersion.toString(), provided: clientEntityVersion }));
}

const properties = await Datasets.getProperties(dataset.id);
const partial = Entity.fromJson(body, properties, dataset, entity);

const sourceId = await Entities.createSource();
return Entities.createVersion(dataset, entity, partial.def.data, partial.def.label, sourceId, userAgent);

return Entities.createVersion(dataset, entity, partial.def.data, partial.def.label, serverEntityVersion + 1, sourceId, userAgent);
}));

service.delete('/projects/:projectId/datasets/:name/entities/:uuid', endpoint(async ({ Datasets, Entities }, { auth, params, queryOptions }) => {
Expand Down
4 changes: 3 additions & 1 deletion lib/util/problem.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,11 @@ const problems = {

editingClosingOrClosed: problem(409.12, () => 'You are trying to edit a submission of a Form that is in Closing or Closed state. In this version of Central, you can only edit the submissions of Open Forms. We will fix this in a future version for Central.'),

enketoEditRateLimit: problem(409.13, () => 'You must wait one minute before trying to edit a submission a second time.')
enketoEditRateLimit: problem(409.13, () => 'You must wait one minute before trying to edit a submission a second time.'),

// 409.14 (entityViolation) was used in the past, but has been deprecated

entityVersionConflict: problem(409.15, ({ current, provided }) => `Current version of the Entity is '${current}' and you provided '${provided}'. Please correct the version number or pass '?force=true' in the URL to forcefully update the Entity.`)
},
internal: {
// no detail information, as this is only called when we don't know what happened.
Expand Down
40 changes: 20 additions & 20 deletions test/integration/api/datasets.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ describe('datasets and entities', () => {
.then(() => asAlice.get('/v1/projects/1/datasets/people/entities.csv')
.expect(200)
.then(({ text }) => {
text.should.equal('__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n');
text.should.equal('__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n');
})))));

it('should return only published properties', testService(async (service) => {
Expand All @@ -305,7 +305,7 @@ describe('datasets and entities', () => {
await asAlice.get('/v1/projects/1/datasets/people/entities.csv')
.expect(200)
.then(({ text }) => {
text.should.equal('__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n');
text.should.equal('__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n');
});

}));
Expand Down Expand Up @@ -366,9 +366,9 @@ describe('datasets and entities', () => {

const withOutTs = result.replace(isoRegex, '');
withOutTs.should.be.eql(
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n' +
'12345678-1234-4123-8234-123456789aaa,Jane (30),Jane,30,,5,Alice,0,\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,\n'
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' +
'12345678-1234-4123-8234-123456789aaa,Jane (30),Jane,30,,5,Alice,0,,1\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n'
);

}));
Expand Down Expand Up @@ -398,8 +398,8 @@ describe('datasets and entities', () => {

const withOutTs = result.replace(isoRegex, '');
withOutTs.should.be.eql(
'__id,label,first_name,the.age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,\n'
'__id,label,first_name,the.age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n'
);

}));
Expand Down Expand Up @@ -471,8 +471,8 @@ describe('datasets and entities', () => {

const withOutTs = result.replace(isoRegex, '');
withOutTs.should.be.eql(
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n' +
'12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),Robert,,,5,Alice,1,\n'
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' +
'12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),Robert,,,5,Alice,1,,2\n'
);

}));
Expand Down Expand Up @@ -501,8 +501,8 @@ describe('datasets and entities', () => {

const withOutTs = result.text.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g, '');
withOutTs.should.be.eql(
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,\n'
'__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n'
);

const etag = result.get('ETag');
Expand Down Expand Up @@ -1317,7 +1317,7 @@ describe('datasets and entities', () => {
.then(({ headers, text }) => {
headers['content-disposition'].should.equal('attachment; filename="goodone.csv"; filename*=UTF-8\'\'goodone.csv');
headers['content-type'].should.equal('text/csv; charset=utf-8');
text.should.equal('name,label,first_name,age\n12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88\n');
text.should.equal('name,label,version,first_name,age\n12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I noticed when trying to make a form that looked up the version, and based on what we're talking about in the team meeting on Aug 29, name and label have to be what they are, but should version be __version?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like version for the attachment csv

})))));

it('should return entities csv for testing', testService(async (service, container) => {
Expand All @@ -1338,7 +1338,7 @@ describe('datasets and entities', () => {

await service.get(`/v1/test/${token}/projects/1/forms/withAttachments/draft/attachments/goodone.csv`)
.expect(200)
.then(({ text }) => { text.should.equal('name,label,first_name,age\n12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88\n'); });
.then(({ text }) => { text.should.equal('name,label,version,first_name,age\n12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n'); });

}));

Expand Down Expand Up @@ -1370,8 +1370,8 @@ describe('datasets and entities', () => {
await asAlice.get('/v1/projects/1/forms/withAttachments/draft/attachments/goodone.csv')
.expect(200)
.then(({ text }) => {
text.should.equal('name,label,first_name,the.age\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88\n');
text.should.equal('name,label,version,first_name,the.age\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n');
});

}));
Expand Down Expand Up @@ -1432,8 +1432,8 @@ describe('datasets and entities', () => {
.then(r => r.text);

result.should.be.eql(
'name,label,first_name,age\n' +
'12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),Robert,\n'
'name,label,version,first_name,age\n' +
'12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),2,Robert,\n'
);

}));
Expand Down Expand Up @@ -1512,8 +1512,8 @@ describe('datasets and entities', () => {
.expect(200);

result.text.should.be.eql(
'name,label,first_name,age\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88\n'
'name,label,version,first_name,age\n' +
'12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n'
);

const etag = result.get('ETag');
Expand Down Expand Up @@ -1916,7 +1916,7 @@ describe('datasets and entities', () => {
await asAlice.get('/v1/projects/1/datasets/people/entities.csv')
.expect(200)
.then(({ text }) => {
text.should.equal('__id,label,__createdAt,__creatorId,__creatorName,__updates,__updatedAt\n');
text.should.equal('__id,label,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n');
});
}));

Expand Down
33 changes: 26 additions & 7 deletions test/integration/api/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ describe('Entities API', () => {

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.expect(200)
.then(({ body: person }) => {
.then(({ body: person, headers }) => {
headers.etag.should.be.eql('"1"');

person.should.be.an.Entity();
person.should.have.property('currentVersion').which.is.an.EntityDef();
person.currentVersion.should.have.property('data').which.is.eql({
Expand All @@ -219,7 +221,6 @@ describe('Entities API', () => {
});
}));


it('should not mince the object properties', testEntities(async (service, container) => {
const asAlice = await service.login('alice');
const asBob = await service.login('bob');
Expand Down Expand Up @@ -279,6 +280,14 @@ describe('Entities API', () => {
});
});
}));

it('should return 304 not changed ', testEntities(async (service) => {
const asAlice = await service.login('alice');

await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.set('If-None-Match', '"1"')
.expect(304);
}));
});

describe('GET /datasets/:name/entities/:uuid/versions', () => {
Expand Down Expand Up @@ -829,13 +838,22 @@ describe('Entities API', () => {
.expect(403);
}));

it('should reject force=true flag is not provided', testEntities(async (service) => {
// TODO: change logic around force flag and enforcing certain kinds of updates
it('should reject if version or force flag is not provided', testEntities(async (service) => {
const asAlice = await service.login('alice');
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.expect(501)
.expect(409)
.then(({ body }) => {
body.code.should.equal(501.1);
body.code.should.equal(409.15);
});
}));

it('should reject if version does not match', testEntities(async (service) => {
const asAlice = await service.login('alice');
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.set('If-Match', '"0"')
.expect(409)
.then(({ body }) => {
body.code.should.equal(409.15);
});
}));

Expand Down Expand Up @@ -905,7 +923,8 @@ describe('Entities API', () => {
const asAlice = await service.login('alice');
const newData = { age: '77', first_name: 'Alan' };

await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?force=true')
await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc')
.set('If-Match', '"1"')
.send({
data: { age: '77', first_name: 'Alan' }
})
Expand Down
Loading