diff --git a/lib/formats/odata.js b/lib/formats/odata.js
index bbafd68a3..8b5a3afa4 100644
--- a/lib/formats/odata.js
+++ b/lib/formats/odata.js
@@ -440,10 +440,66 @@ const singleRowToOData = (fields, row, domain, originalUrl, query) => {
});
};
+// Create a tree of form fields
+// Where each node has reference to its parent and children
+// Returns an object with key=path and value=fieldNode
+// so that we can quickly get the field node instead of traversing tree from top to bottom
+// Assumption: formFields are in order
+const getFieldTree = (formFields) => {
+ const tree = {};
+
+ for (let i = 0; i < formFields.length; i += 1) {
+ const node = { value: formFields[i], children: [], parent: null };
+ tree[`${node.value.path.split('/').map(sanitizeOdataIdentifier).join('/')}`] = node;
+ }
+
+ for (const i of Object.keys(tree)) {
+ const node = tree[i];
+ const parentPath = node.value.path.match(/(^.*)\//)[1].split('/').map(sanitizeOdataIdentifier).join('/');
+
+ if (tree[parentPath]) {
+ node.parent = tree[parentPath];
+ node.parent.children.push(node);
+ }
+ }
+
+ return tree;
+};
+
+// Returns children recursively
+const getChildren = (field) => {
+ const result = new Set();
+ const stack = [];
+ stack.push(field);
+
+ while (stack.length > 0) {
+ const node = stack.pop();
+ node.children.forEach(c => {
+ if (c.value.type === 'structure') {
+ stack.push(c);
+ }
+ result.add(c.value);
+ });
+ }
+ return result;
+};
+
// Validates $select query parameter including metadata properties and returns list of FormFields
const filterFields = (formFields, select, table) => {
const filteredFields = new Set();
- const fieldMap = formFields.reduce((map, field) => ({ ...map, [`${field.path.split('/').map(sanitizeOdataIdentifier).join('/')}`]: field }), {});
+ const fieldTree = getFieldTree(formFields);
+
+ let path = '';
+
+ // For subtables we have to include parents fields
+ if (table !== 'Submissions') {
+ for (const tableSegment of table.replace(/Submissions\./, '').split('.')) {
+ path += `/${tableSegment}`;
+ if (!fieldTree[path]) throw Problem.user.notFound();
+ filteredFields.add(fieldTree[path].value);
+ }
+ }
+
for (const property of select.split(',').map(p => p.trim())) {
// validate metadata properties. __system/.. properties are only valid for Submission table
if (property.startsWith('__id') || property.startsWith('__system')) {
@@ -451,27 +507,25 @@ const filterFields = (formFields, select, table) => {
throw Problem.user.propertyNotFound({ property });
} else {
- let path = '';
+ const field = fieldTree[`${path}/${property}`];
+ if (!field) throw Problem.user.propertyNotFound({ property });
- // For subtables we have to include parents fields
- if (table !== 'Submissions') {
- for (const tableSegment of table.replace(/Submissions\./, '').split('.')) {
- path += `/${tableSegment}`;
- if (!fieldMap[path]) throw Problem.user.notFound();
- filteredFields.add(fieldMap[path]);
- }
- }
+ filteredFields.add(field.value);
// we have to include parents fields in the result to handle grouped fields
- const propSegments = property.split('/');
- for (let i = 0; i < propSegments.length; i+=1) {
- path += `/${propSegments[i]}`;
- const field = fieldMap[path];
- if (!field) throw Problem.user.propertyNotFound({ property });
- // it's ok to include field with repeat type so that user can have navigation link
- // but child field of a repeat field is not supported
- if (field.type === 'repeat' && i < propSegments.length - 1) throw Problem.user.unsupportedODataSelectField({ property });
- filteredFields.add(field);
+ let node = field.parent;
+ while (node && !filteredFields.has(node.value)) { // filteredFields set already has the subtables
+ // Child field of a repeat field is not supported
+ if (node.value.type === 'repeat') throw Problem.user.unsupportedODataSelectField({ property });
+
+ filteredFields.add(node.value);
+ node = node.parent;
+ }
+
+ // Include the children of structure/group
+ // Note: This doesn't expand 'repeat' fields
+ if (field.value.type === 'structure') {
+ getChildren(field).forEach(filteredFields.add, filteredFields);
}
}
}
@@ -489,5 +543,11 @@ const filterFields = (formFields, select, table) => {
const selectFields = (query, table) => (fields) => (query.$select && query.$select !== '*' ? filterFields(fields, query.$select, table) : fields);
-module.exports = { odataXmlError, serviceDocumentFor, edmxFor, rowStreamToOData, singleRowToOData, selectFields, getTableFromOriginalUrl };
+module.exports = {
+ odataXmlError, serviceDocumentFor, edmxFor,
+ rowStreamToOData, singleRowToOData,
+ selectFields, getTableFromOriginalUrl,
+ // exporting for unit tests
+ getFieldTree, getChildren
+};
diff --git a/test/data/xml.js b/test/data/xml.js
index e02d7063c..4df69c23a 100644
--- a/test/data/xml.js
+++ b/test/data/xml.js
@@ -354,7 +354,109 @@ module.exports = {
-`
+`,
+
+ groupRepeat: `
+
+
+ groupRepeat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+
+ nestedGroup: `
+
+
+ nestedGroup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
},
instances: {
simple: {
@@ -464,6 +566,42 @@ module.exports = {
John
40
`
+ },
+ groupRepeat: {
+ one: `
+ xyz
+
+ John
+
+ Toronto
+ Canada
+
+
+
+ Jane
+
+ New York
+ US
+
+
+
+ uuid:2be07915-2c9c-401a-93ea-1c8f3f8e68f6
+
+ `
+ },
+ nestedGroup: {
+ one: `
+ xyz
+
+ AKUH
+
+ Yes
+
+
+
+ uuid:f7908274-ef70-4169-90a0-e1389ab732ff
+
+ `
}
}
};
diff --git a/test/integration/api/odata.js b/test/integration/api/odata.js
index e244c7843..151b893a8 100644
--- a/test/integration/api/odata.js
+++ b/test/integration/api/odata.js
@@ -1,7 +1,7 @@
const { testService } = require('../setup');
const { sql } = require('slonik');
const testData = require('../../data/xml');
-const { dissocPath } = require('ramda');
+const { dissocPath, identity } = require('ramda');
// NOTE: for the data output tests, we do not attempt to extensively determine if every
// internal case is covered; there are already two layers of tests below these, at
@@ -1251,6 +1251,56 @@ describe('api: /forms/:id.svc', () => {
});
}))));
+ it('should return toplevel rows with group properties', testService(async (service) => {
+ const asAlice = await withSubmissions(service, identity);
+
+ await asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions?$select=meta')
+ .expect(200)
+ .then(({ body }) => {
+ body.should.eql({
+ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/withrepeat.svc/$metadata#Submissions',
+ value: [
+ { meta: { instanceID: 'rthree' } },
+ { meta: { instanceID: 'rtwo' } },
+ { meta: { instanceID: 'rone' } }
+ ]
+ });
+ });
+ }));
+
+ it('should return toplevel row with nested group properties', testService(async (service) => {
+ const asAlice = await service.login('alice', identity);
+
+ await asAlice.post('/v1/projects/1/forms?publish=true')
+ .send(testData.forms.nestedGroup)
+ .set('Content-Type', 'text/xml')
+ .expect(200);
+
+ await asAlice.post('/v1/projects/1/forms/nestedGroup/submissions?deviceID=testid')
+ .send(testData.instances.nestedGroup.one)
+ .set('Content-Type', 'text/xml')
+ .expect(200);
+
+ await asAlice.get('/v1/projects/1/forms/nestedGroup.svc/Submissions?$select=text,hospital')
+ .expect(200)
+ .then(({ body }) => {
+ body.should.eql({
+ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/nestedGroup.svc/$metadata#Submissions',
+ value: [
+ {
+ text: 'xyz',
+ hospital: {
+ name: 'AKUH',
+ hiv_medication: {
+ have_hiv_medication: 'Yes',
+ },
+ },
+ },
+ ]
+ });
+ });
+ }));
+
it('should return subtable results with selected properties', testService((service) =>
withSubmissions(service, (asAlice) =>
asAlice.get('/v1/projects/1/forms/withrepeat.svc/Submissions.children.child?$select=__id,name')
@@ -1270,6 +1320,35 @@ describe('api: /forms/:id.svc', () => {
}]
});
}))));
+
+ it('should return subtable results with group properties', testService(async (service) => {
+ const asAlice = await service.login('alice', identity);
+
+ await asAlice.post('/v1/projects/1/forms?publish=true')
+ .send(testData.forms.groupRepeat)
+ .set('Content-Type', 'text/xml')
+ .expect(200);
+
+ await asAlice.post('/v1/projects/1/forms/groupRepeat/submissions?deviceID=testid')
+ .send(testData.instances.groupRepeat.one)
+ .set('Content-Type', 'text/xml')
+ .expect(200);
+
+ await asAlice.get('/v1/projects/1/forms/groupRepeat.svc/Submissions.child_repeat?$select=address')
+ .expect(200)
+ .then(({ body }) => {
+ body.should.eql({
+ '@odata.context': 'http://localhost:8989/v1/projects/1/forms/groupRepeat.svc/$metadata#Submissions.child_repeat',
+ value: [
+ { address: { city: 'Toronto', country: 'Canada' } },
+ { address: { city: 'New York', country: 'US' } }
+ ]
+ });
+ });
+ }));
+
+
+
});
describe('/draft.svc', () => {
diff --git a/test/unit/formats/odata.js b/test/unit/formats/odata.js
index 8de63f3c1..6eb99fdf9 100644
--- a/test/unit/formats/odata.js
+++ b/test/unit/formats/odata.js
@@ -1,4 +1,5 @@
const appRoot = require('app-root-path');
+const { getFieldTree, getChildren } = require('../../../lib/formats/odata');
const streamTest = require('streamtest').v2;
// eslint-disable-next-line import/no-dynamic-require
const { serviceDocumentFor, edmxFor, rowStreamToOData, singleRowToOData, selectFields } = require(appRoot + '/lib/formats/odata');
@@ -6,6 +7,7 @@ const { serviceDocumentFor, edmxFor, rowStreamToOData, singleRowToOData, selectF
const { fieldsFor, MockField } = require(appRoot + '/test/util/schema');
// eslint-disable-next-line import/no-dynamic-require
const testData = require(appRoot + '/test/data/xml');
+const should = require('should');
// Helpers to deal with repeated system metadata generation.
const submitter = { id: 5, displayName: 'Alice' };
@@ -1094,9 +1096,9 @@ describe('odata message composition', () => {
(() => selectFields({ $select: 'address' }, 'Submissions')(fields)).should.throw('Could not find a property named \'address\'');
}));
- it('should not throw error if __id is requested for non-submissions table', () => fieldsFor(testData.forms.simple)
+ it('should not throw error if __id is requested for non-submissions table', () => fieldsFor(testData.forms.withrepeat)
.then((fields) => {
- (() => selectFields({ $select: '__id' }, 'Submissions.Children')(fields)).should.not.throw();
+ (() => selectFields({ $select: '__id' }, 'Submissions.children')(fields)).should.not.throw();
}));
it('should not throw error if system properties are requested for submissions table', () => fieldsFor(testData.forms.simple)
@@ -1135,5 +1137,52 @@ describe('odata message composition', () => {
filteredFields.length.should.equal(2);
}));
});
+
+ describe('getFieldTree', () => {
+ it('should make the tree', () => {
+ const fields = [
+ new MockField({ path: '/home', name: 'home', type: 'structure' }),
+ new MockField({ path: '/home/address', name: 'address', type: 'structure' }),
+ new MockField({ path: '/home/address/country', name: 'country', type: 'text' }),
+ new MockField({ path: '/home/address/province', name: 'province', type: 'text' }),
+ new MockField({ path: '/home/address/city', name: 'city', type: 'text' })
+ ];
+
+ const tree = getFieldTree(fields);
+ should(tree['/home'].parent).be.null();
+ tree['/home'].children.map(c => c.value.name).should.be.eql(['address']);
+
+ tree['/home/address'].parent.value.name.should.be.eql('home');
+ tree['/home/address'].children.map(c => c.value.name).should.be.eql(['country', 'province', 'city']);
+
+ tree['/home/address/country'].parent.value.name.should.be.eql('address');
+ tree['/home/address/country'].children.should.be.eql([]);
+
+ tree['/home/address/province'].parent.value.name.should.be.eql('address');
+ tree['/home/address/province'].children.should.be.eql([]);
+
+ tree['/home/address/city'].parent.value.name.should.be.eql('address');
+ tree['/home/address/city'].children.should.be.eql([]);
+ });
+ });
+
+ describe('getChildren', () => {
+ it('should return all children recursively', () => {
+ const fields = [
+ new MockField({ path: '/home', name: 'home', type: 'structure' }),
+ new MockField({ path: '/home/address', name: 'address', type: 'structure' }),
+ new MockField({ path: '/home/address/country', name: 'country', type: 'text' }),
+ new MockField({ path: '/home/address/province', name: 'province', type: 'text' }),
+ new MockField({ path: '/home/address/city', name: 'city', type: 'text' })
+ ];
+
+ const tree = getFieldTree(fields);
+
+ const children = getChildren(tree['/home']);
+
+ Array.from(children.values()).map(c => c.name).should.be.eql(['address', 'country', 'province', 'city']);
+ });
+ });
+
});