Skip to content

Commit

Permalink
Enhance: complex type / groups can be '$select'ed (#717)
Browse files Browse the repository at this point in the history
* Enhance: complex type / groups can be '$select'ed

* added a couple of unit tests
  • Loading branch information
sadiqkhoja authored Dec 12, 2022
1 parent b885e22 commit 8e21629
Show file tree
Hide file tree
Showing 4 changed files with 350 additions and 24 deletions.
100 changes: 80 additions & 20 deletions lib/formats/odata.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,38 +440,92 @@ 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')) {
if (!(property === '__id' || (table === 'Submissions' && systemFields.has(property))))
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);
}
}
}
Expand All @@ -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
};

140 changes: 139 additions & 1 deletion test/data/xml.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,109 @@ module.exports = {
<bind nodeset="/data/hometown" type="string"/>
</model>
</h:head>
</h:html>`
</h:html>`,

groupRepeat: `<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa" xmlns:odk="http://www.opendatakit.org/xforms" xmlns:orx="http://openrosa.org/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<h:head>
<h:title>groupRepeat</h:title>
<model odk:xforms-version="1.0.0">
<instance>
<data id="groupRepeat">
<text/>
<child_repeat jr:template="">
<name/>
<address>
<city/>
<country/>
</address>
</child_repeat>
<child_repeat>
<name/>
<address>
<city/>
<country/>
</address>
</child_repeat>
<meta>
<instanceID/>
</meta>
</data>
</instance>
<bind nodeset="/data/text" type="string"/>
<bind nodeset="/data/child_repeat/name" type="string"/>
<bind nodeset="/data/child_repeat/address/city" type="string"/>
<bind nodeset="/data/child_repeat/address/country" type="string"/>
<bind jr:preload="uid" nodeset="/data/meta/instanceID" readonly="true()" type="string"/>
</model>
</h:head>
<h:body>
<input ref="/data/text">
<label>text</label>
</input>
<group ref="/data/child_repeat">
<label>Children</label>
<repeat nodeset="/data/child_repeat">
<input ref="/data/child_repeat/name">
<label>Child's name</label>
</input>
<group ref="/data/child_repeat/address">
<label>group</label>
<input ref="/data/child_repeat/address/city">
<label>City</label>
</input>
<input ref="/data/child_repeat/address/country">
<label>Country</label>
</input>
</group>
</repeat>
</group>
</h:body>
</h:html>`,

nestedGroup: `<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:jr="http://openrosa.org/javarosa" xmlns:odk="http://www.opendatakit.org/xforms" xmlns:orx="http://openrosa.org/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<h:head>
<h:title>nestedGroup</h:title>
<model odk:xforms-version="1.0.0">
<instance>
<data id="nestedGroup">
<text/>
<hospital>
<name/>
<hiv_medication>
<have_hiv_medication/>
</hiv_medication>
</hospital>
<meta>
<instanceID/>
</meta>
</data>
</instance>
<bind nodeset="/data/text" type="string"/>
<bind nodeset="/data/hospital/name" type="string"/>
<bind nodeset="/data/hospital/hiv_medication/have_hiv_medication" type="string"/>
<bind jr:preload="uid" nodeset="/data/meta/instanceID" readonly="true()" type="string"/>
</model>
</h:head>
<h:body>
<input ref="/data/text">
<label>text</label>
</input>
<group ref="/data/hospital">
<label>Hospital</label>
<input ref="/data/hospital/name">
<label>What is the name of this hospital?</label>
</input>
<group ref="/data/hospital/hiv_medication">
<label>HIV Medication</label>
<input ref="/data/hospital/hiv_medication/have_hiv_medication">
<label>Does this hospital have HIV medication?</label>
</input>
</group>
</group>
</h:body>
</h:html>`
},
instances: {
simple: {
Expand Down Expand Up @@ -464,6 +566,42 @@ module.exports = {
<name>John</name>
<age>40</age>
</data>`
},
groupRepeat: {
one: `<data xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms" id="groupRepeat">
<text>xyz</text>
<child_repeat>
<name>John</name>
<address>
<city>Toronto</city>
<country>Canada</country>
</address>
</child_repeat>
<child_repeat>
<name>Jane</name>
<address>
<city>New York</city>
<country>US</country>
</address>
</child_repeat>
<meta>
<instanceID>uuid:2be07915-2c9c-401a-93ea-1c8f3f8e68f6</instanceID>
</meta>
</data>`
},
nestedGroup: {
one: `<data xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms" id="nestedGroup">
<text>xyz</text>
<hospital>
<name>AKUH</name>
<hiv_medication>
<have_hiv_medication>Yes</have_hiv_medication>
</hiv_medication>
</hospital>
<meta>
<instanceID>uuid:f7908274-ef70-4169-90a0-e1389ab732ff</instanceID>
</meta>
</data>`
}
}
};
Expand Down
81 changes: 80 additions & 1 deletion test/integration/api/odata.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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', () => {
Expand Down
Loading

0 comments on commit 8e21629

Please sign in to comment.