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

feat: Data quality measures #420

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
16 changes: 15 additions & 1 deletion src/classes/model-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,34 @@ const ModelNode = class {

getPath(...fields) {
const path = [];
const tree = [];
let node = this;
do {
if (typeof node.arrayIndex !== 'undefined') {
path.unshift(node.arrayIndex);
tree.unshift({
arrayIndex: node.arrayIndex,
});
}
path.unshift(node.name);
tree.unshift({
name: node.name,
type: node.model.type,
});
node = node.parentNode;
} while (node !== null);
for (const field of fields) {
if (typeof field !== 'undefined') {
path.push(field);
tree.push({
name: field,
});
}
}
return jp.stringify(path).replace(/([\][])\1+/g, '$1');
return {
string: jp.stringify(path).replace(/([\][])\1+/g, '$1'),
tree,
};
}

static checkInheritRule(rule, field) {
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
const defaultRules = require('./rules');
const {
validate, isRpdeFeed,
validate, validateWithMeasures, addMeasures, isRpdeFeed,
} = require('./validate');
const Rule = require('./rules/rule');
const ValidationError = require('./errors/validation-error');
Expand All @@ -18,6 +18,8 @@ function createValidator() {
isRpdeFeed,
Rule,
validate,
validateWithMeasures,
addMeasures,
ValidationError,
ValidationErrorCategory,
ValidationErrorType,
Expand Down
6 changes: 6 additions & 0 deletions src/measures/exclusion-modes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const ExclusionMode = Object.freeze({
ALL: 'all',
ANY: 'any',
});

module.exports = ExclusionMode;
6 changes: 6 additions & 0 deletions src/measures/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* eslint-disable global-require */

module.exports = [
require('./profiles/common'),
require('./profiles/accessibility'),
];
50 changes: 50 additions & 0 deletions src/measures/profile-processor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// After validation is complete, and errors have been generated, the following set of profiles are applied to the errors array.
// The results of this are rendered to a separate "profileMeasures" array, which are available to:
// - The test suite's HTML output
// - The validator (as a tab or collapsable box on the right-hand side?), potentially linking to the errors themselves below (which would then be available in the visualiser)
// - The status page

// Open questions:
// - Do we need to think about combining parent and child in the feed within the test suite for a more accurate assessment of e.g. the `url`?

const ExclusionMode = require('./exclusion-modes');
const profiles = require('.');

const ProfileProcessor = class {
static matchTree(targetFields, tree) {
const { name } = tree.slice(-1)[0];
const { type } = tree.slice(-2)[0];
for (const [targetType, targetField] of Object.entries(targetFields)) {
if (targetType === type && targetField.includes(name)) return true;
}
return false;
}

static doesErrorMatchExclusion(error, exclusion) {
return exclusion.errorType.includes(error.type)
&& (
(exclusion.targetFields && this.matchTree(exclusion.targetFields, error.pathTree))
|| (exclusion.targetPaths
&& exclusion.targetPaths.some((path) => path.test(error.path)))
);
}

static doErrorsMatchExclusionsInMeasure(errors, measure) {
const matchingExclusions = measure.exclusions.filter((exclusion) => errors.some((error) => this.doesErrorMatchExclusion(error, exclusion)));
return measure.exclusionMode === ExclusionMode.ALL ? matchingExclusions.length === measure.exclusions.length : matchingExclusions.length > 0;
}

static calculateMeasures(errors) {
const results = {};
for (const profile of profiles) {
const profileResult = {};
for (const measure of profile.measures) {
profileResult[measure.name] = this.doErrorsMatchExclusionsInMeasure(errors, measure) ? 0 : 1;
}
results[profile.identifier] = profileResult;
}
return results;
}
};

module.exports = ProfileProcessor;
21 changes: 21 additions & 0 deletions src/measures/profiles/accessibility.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ValidationErrorType = require('../../errors/validation-error-type');

module.exports = {
name: 'Accessibility',
identifier: 'accessibility',
measures: [
{
name: 'Has accessibilitySupport',
exclusions: [ // Logic: if no errors are present for any of the paths (paths are matched with endsWith)
{
errorType: [
ValidationErrorType.MISSING_RECOMMENDED_FIELD,
],
targetPaths: [
/\.accessibilitySupport/,
],
},
],
},
],
};
175 changes: 175 additions & 0 deletions src/measures/profiles/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
const ValidationErrorType = require('../../errors/validation-error-type');
const ExclusionMode = require('../exclusion-modes');

module.exports = {
name: 'Common use',
identifier: 'common',
measures: [
{
name: 'Has a leader name',
exclusions: [ // Logic: if no errors are present for any of the paths (paths are matched with endsWith), for any exclusion
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD,
],
targetPaths: [
/\.leader\.name$/,
],
},
],
},
{
name: 'Has a name',
description: 'The name of the opportunity is essential for a participant to understand what the activity',
exclusions: [ // Logic: if no errors are present for any of the target fields, for any exclusion
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD,
],
targetFields: {
Event: ['name'],
FacilityUse: ['name'],
IndividualFacilityUse: ['name'],
CourseInstance: ['name'],
EventSeries: ['name'],
HeadlineEvent: ['name'],
SessionSeries: ['name'],
Course: ['name'],
},
},
],
},
{
name: 'Has a description',
exclusions: [ // Logic: if no errors are present for any of the target fields, for any exclusion
{
errorType: [
ValidationErrorType.MISSING_RECOMMENDED_FIELD,
],
targetFields: {
Event: ['description'],
FacilityUse: ['description'],
IndividualFacilityUse: ['description'],
CourseInstance: ['description'],
EventSeries: ['description'],
HeadlineEvent: ['description'],
SessionSeries: ['description'],
Course: ['description'],
},
},
],
},
{
name: 'Has a postcode or lat/long',
exclusions: [
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD,
],
targetFields: {
Place: ['geo', 'address'],
},
},
],
},
{
name: 'Has a date in the future',
exclusions: [
{
errorType: [
ValidationErrorType.DATE_IN_THE_PAST, // TODO: Add this rule, outputs a warning for dates in the past
],
},
],
},
{
name: 'Activity List ID matches',
exclusions: [
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD,
],
targetFields: {
Event: ['activity'],
CourseInstance: ['activity'],
EventSeries: ['activity'],
HeadlineEvent: ['activity'],
SessionSeries: ['activity'],
Course: ['activity'],
},
},
{
errorType: [
ValidationErrorType.ACTIVITY_NOT_IN_ACTIVITY_LIST,
ValidationErrorType.USE_OFFICIAL_ACTIVITY_LIST,
],
},
],
},
{
name: 'Session has name, description or matching activity',
exclusionMode: ExclusionMode.ALL, // ALL: all exclusions must be present to discount the item; ANY (default): Any exclusions discount the item if present
exclusions: [
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD,
],
targetFields: {
Event: ['name'],
FacilityUse: ['name'],
IndividualFacilityUse: ['name'],
CourseInstance: ['name'],
EventSeries: ['name'],
HeadlineEvent: ['name'],
SessionSeries: ['name'],
Course: ['name'],
},
},
{
errorType: [
ValidationErrorType.MISSING_RECOMMENDED_FIELD,
],
targetFields: {
Event: ['description'],
FacilityUse: ['description'],
IndividualFacilityUse: ['description'],
CourseInstance: ['description'],
EventSeries: ['description'],
HeadlineEvent: ['description'],
SessionSeries: ['description'],
Course: ['description'],
},
},
{
errorType: [
ValidationErrorType.ACTIVITY_NOT_IN_ACTIVITY_LIST,
ValidationErrorType.USE_OFFICIAL_ACTIVITY_LIST,
],
},
],
},
{
name: 'Has has a unique URL (e.g. for booking)',
exclusions: [ // Logic: if no errors are present for any of the target fields, for any exclusion
// Note that the required field logic is applied before errors outputted, therefore the below applies all inheritance logic etc implicitly
{
errorType: [
ValidationErrorType.MISSING_REQUIRED_FIELD, // TODO: Add a rule that checks that the url is unique within the set of URLs given on the page (?), of perhaps using a hashmap of the @id
],
targetFields: {
Event: ['url'],
FacilityUse: ['url'],
Slot: ['url'],
IndividualFacilityUse: ['url'],
CourseInstance: ['url'],
EventSeries: ['url'],
HeadlineEvent: ['url'],
SessionSeries: ['url'],
ScheduledSession: ['url'],
Course: ['url'],
},
},
],
},
],
};
2 changes: 2 additions & 0 deletions src/rules/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class Rule {
const error = Object.assign(
extra,
{
path: extra.path.string,
pathTree: extra.path.tree,
rule: this.meta.name,
category: rule.category,
type: rule.type,
Expand Down
21 changes: 20 additions & 1 deletion src/validate-spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const nock = require('nock');
const { validate } = require('./validate');
const { validate, validateWithMeasures } = require('./validate');
const ValidationErrorSeverity = require('./errors/validation-error-severity');
const ValidationErrorType = require('./errors/validation-error-type');
const DataModelHelper = require('./helpers/data-model');
Expand Down Expand Up @@ -690,4 +690,23 @@ describe('validate', () => {
expect(result[0].severity).toBe(ValidationErrorSeverity.NOTICE);
});
});

describe('validateWithMeasures()', () => {
it('should include', async () => {
const event = { ...validSessionSeries };

delete event.name;

const { errors, profileMeasures } = await validateWithMeasures(event, options);

expect(errors.length).toBe(1);

expect(errors[0].type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD);
expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE);
expect(errors[0].path).toBe('$.name');

expect(profileMeasures.profiles.common['Has a name']).toEqual(0);
expect(profileMeasures.profiles.common['Has a description']).toEqual(1);
});
});
});
Loading