diff --git a/__tests__/helpers.js b/__tests__/helpers.js index df850e6..3c5bb81 100644 --- a/__tests__/helpers.js +++ b/__tests__/helpers.js @@ -24,5 +24,31 @@ const generateRuleConfig = (type, options) => ({ options, }); +// Function that checks if a string is a valid date in the format YYYY-MM-DD, YYYY-MM, or YYYY +// and converts it to a Date object +const isValidDate = (dateString) => { + const dateRegex = /^(\d{4})-(\d{2})-(\d{2})$/; + const yearMonthRegex = /^(\d{4})-(\d{2})$/; + const yearRegex = /^(\d{4})$/; + + if (dateRegex.test(dateString)) { + const [year, month, day] = dateString.split('-'); + return new Date(year, month - 1, day); + } + + if (yearMonthRegex.test(dateString)) { + const [year, month] = dateString.split('-'); + return new Date(year, month - 1); + } + + if (yearRegex.test(dateString)) { + const [year] = dateString.split('-'); + return new Date(year); + } + + return false; +}; + +exports.isValidDate = isValidDate; exports.getEntityGenerator = getEntityGenerator; exports.generateRuleConfig = generateRuleConfig; diff --git a/__tests__/predicate.test.js b/__tests__/predicate.test.js index a4feec0..f7fe6a0 100644 --- a/__tests__/predicate.test.js +++ b/__tests__/predicate.test.js @@ -3,142 +3,458 @@ const predicate = require('../predicate').default; const operators = require('../predicate').operators; const countOperators = require('../predicate').countOperators; +/** + * Should cover all variable types: + * - number + * - string + * - date + * - boolean + * - categorical + * - ordinal + * - scalar + */ + describe('predicate', () => { it('default', () => { expect(predicate(null)({ value: null, other: null })).toBe(false); }); describe('operators', () => { - it('GREATER_THAN', () => { - expect( - predicate(operators.GREATER_THAN)({ value: 1.5, other: 1 }), - ).toBe(true); - expect( - predicate(operators.GREATER_THAN)({ value: 2, other: 2 }), - ).toBe(false); + describe('GREATER_THAN', () => { + it('number', () => { + expect( + predicate(operators.GREATER_THAN)({ value: 1.5, other: 1 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN)({ value: 2, other: 2 }), + ).toBe(false); + }); + it('date', () => { + expect( + predicate(operators.GREATER_THAN)({ value: '2018-01-01', other: '2017-01-01' }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN)({ value: '2018-01-01', other: '2018-01-01' }), + ).toBe(false); + }); + + it('scalar', () => { + expect( + predicate(operators.GREATER_THAN)({ value: 0.5, other: 0.3 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN)({ value: 0.1, other: 0.2 }), + ).toBe(false); + }); }); - it('LESS_THAN', () => { - expect( - predicate(operators.LESS_THAN)({ value: 1, other: 1.5 }), - ).toBe(true); - expect( - predicate(operators.LESS_THAN)({ value: 2, other: 2 }), - ).toBe(false); + describe('LESS_THAN', () => { + it('number', () => { + expect( + predicate(operators.LESS_THAN)({ value: 1, other: 1.5 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN)({ value: 2, other: 2 }), + ).toBe(false); + }); + it('date', () => { + expect( + predicate(operators.LESS_THAN)({ value: '2017-01-01', other: '2018-01-01' }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN)({ value: '2018-01-01', other: '2018-01-01' }), + ).toBe(false); + }); + + it('scalar', () => { + expect( + predicate(operators.LESS_THAN)({ value: 0.3, other: 0.5 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN)({ value: 0.2, other: 0.1 }), + ).toBe(false); + }); }); - it('GREATER_THAN_OR_EQUAL', () => { - expect( - predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 1.5, other: 1 }), - ).toBe(true); - expect( - predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 2, other: 2 }), - ).toBe(true); - expect( - predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 2, other: 3 }), - ).toBe(false); + describe('GREATER_THAN_OR_EQUAL', () => { + it('number', () => { + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 1.5, other: 1 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 2, other: 2 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 2, other: 3 }), + ).toBe(false); + }); + it('date', () => { + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: '2018-01-01', other: '2017-01-01' }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: '2018-01-01', other: '2018-01-01' }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: '2018-01-01', other: '2019-01-01' }), + ).toBe(false); + }); + + it('scalar', () => { + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 0.5, other: 0.3 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 0.2, other: 0.2 }), + ).toBe(true); + expect( + predicate(operators.GREATER_THAN_OR_EQUAL)({ value: 0.1, other: 0.2 }), + ).toBe(false); + }); }); - it('LESS_THAN_OR_EQUAL', () => { - expect( - predicate(operators.LESS_THAN_OR_EQUAL)({ value: 1, other: 1.5 }), - ).toBe(true); - expect( - predicate(operators.LESS_THAN_OR_EQUAL)({ value: 2, other: 2 }), - ).toBe(true); - expect( - predicate(operators.LESS_THAN_OR_EQUAL)({ value: 3, other: 2 }), - ).toBe(false); + describe('LESS_THAN_OR_EQUAL', () => { + it('number', () => { + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 1, other: 1.5 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 2, other: 2 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 3, other: 2 }), + ).toBe(false); + }); + + it('date', () => { + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: '2017-01-01', other: '2018-01-01' }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: '2018-01-01', other: '2018-01-01' }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: '2019-01-01', other: '2018-01-01' }), + ).toBe(false); + }); + + it('scalar', () => { + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 0.3, other: 0.5 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 0.2, other: 0.2 }), + ).toBe(true); + expect( + predicate(operators.LESS_THAN_OR_EQUAL)({ value: 0.2, other: 0.1 }), + ).toBe(false); + }); }); - it('EXACTLY', () => { - expect( - predicate(operators.EXACTLY)({ value: 1, other: 1 }), - ).toBe(true); - expect( - predicate(operators.EXACTLY)({ value: 2, other: 1 }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: null, other: 0 }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: 'word', other: 'word' }), - ).toBe(true); - expect( - predicate(operators.EXACTLY)({ value: 'not word', other: 'word' }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: null, other: 'word' }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: true, other: true }), - ).toBe(true); - expect( - predicate(operators.EXACTLY)({ value: false, other: true }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: null, other: true }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: true, other: false }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: false, other: false }), - ).toBe(true); - expect( - predicate(operators.EXACTLY)({ value: null, other: false }), - ).toBe(false); - expect( - predicate(operators.EXACTLY)({ value: false, other: null }), - ).toBe(false); + describe('EXACTLY', () => { + it('number', () => { + expect( + predicate(operators.EXACTLY)({ value: 1, other: 1 }), + ).toBe(true); + expect( + predicate(operators.EXACTLY)({ value: 2, other: 1 }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: null, other: 0 }), + ).toBe(false); + }); + it('string', () => { + expect( + predicate(operators.EXACTLY)({ value: 'word', other: 'word' }), + ).toBe(true); + expect( + predicate(operators.EXACTLY)({ value: 'not word', other: 'word' }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: null, other: 'word' }), + ).toBe(false); + }); + it('boolean', () => { + expect( + predicate(operators.EXACTLY)({ value: true, other: true }), + ).toBe(true); + expect( + predicate(operators.EXACTLY)({ value: false, other: true }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: null, other: true }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: true, other: false }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: false, other: false }), + ).toBe(true); + expect( + predicate(operators.EXACTLY)({ value: null, other: false }), + ).toBe(false); + expect( + predicate(operators.EXACTLY)({ value: false, other: null }), + ).toBe(false); + }); + + it('categorical', () => { + expect( + predicate(operators.EXACTLY)({ value: ['f'], other: ['f'] }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: ['f'], other: ['f', 'm'] }), + ).toBe(false); + + // Order shouldn't matter + expect( + predicate(operators.EXACTLY)({ value: ['f', 'm'], other: ['f', 'm'] }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: ['m', 'f'], other: ['f', 'm'] }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: [1], other: [1] }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: [1], other: [1, 2] }), + ).toBe(false); + + /** + * Expect true when value is an array with a single item + * and varaiableValue is that single item + * + * Expect false if value is an array with multiple items + * and variableValue is a single item + * + * Expect false if value is an array with single item + * and variableValue is a different single item + * + * This checks that the categorical variable skip logic bugfix in predicate is working + */ + expect( + predicate(operators.EXACTLY)({ value: ['f'], other: 'f' }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: ['f'], other: 'm' }), + ).toBe(false); + + expect( + predicate(operators.EXACTLY)({ value: [1], other: 1 }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: [1], other: 2 }), + ).toBe(false); + + expect( + predicate(operators.EXACTLY)({ value: ['f', 'm'], other: 'f' }), + ).toBe(false); + + expect( + predicate(operators.EXACTLY)({ value: [1, 2], other: 1 }), + ).toBe(false); + }); + + it('ordinal', () => { + expect( + predicate(operators.EXACTLY)({ value: 'f', other: 'f' }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: 'f', other: 'm' }), + ).toBe(false); + + expect( + predicate(operators.EXACTLY)({ value: 1, other: 1 }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: 1, other: 2 }), + ).toBe(false); + + expect( + predicate(operators.EXACTLY)({ value: true, other: true }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: true, other: false }), + ).toBe(false); + }); + + it('scalar', () => { + expect( + predicate(operators.EXACTLY)({ value: 1, other: 1 }), + ).toBe(true); + expect( + predicate(operators.EXACTLY)({ value: 0.5, other: 1 }), + ).toBe(false); + }); + + it('date', () => { + expect( + predicate(operators.EXACTLY)({ value: '2012-05-18', other: '2012-05-18' }), + ).toBe(true); + + expect( + predicate(operators.EXACTLY)({ value: '2012-05-18', other: '2012-05-19' }), + ).toBe(false); + }); }); - it('NOT', () => { - expect( - predicate(operators.NOT)({ value: 1, other: 1 }), - ).toBe(false); - expect( - predicate(operators.NOT)({ value: 2, other: 1 }), - ).toBe(true); - expect( - predicate(operators.NOT)({ value: null, other: false }), - ).toBe(true); - expect( - predicate(operators.NOT)({ value: null, other: true }), - ).toBe(true); - expect( - predicate(operators.NOT)({ value: false, other: null }), - ).toBe(true); - expect( - predicate(operators.NOT)({ value: false, other: true }), - ).toBe(true); - expect( - predicate(operators.NOT)({ value: true, other: false }), - ).toBe(true); + describe('NOT', () => { + it('number', () => { + expect( + predicate(operators.NOT)({ value: 1, other: 1 }), + ).toBe(false); + expect( + predicate(operators.NOT)({ value: 2, other: 1 }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: null, other: 0 }), + ).toBe(true); + }); + + it('string', () => { + expect( + predicate(operators.NOT)({ value: 'word', other: 'word' }), + ).toBe(false); + expect( + predicate(operators.NOT)({ value: 'not word', other: 'word' }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: null, other: 'word' }), + ).toBe(true); + }); + + it('boolean', () => { + expect( + predicate(operators.NOT)({ value: true, other: true }), + ).toBe(false); + expect( + predicate(operators.NOT)({ value: false, other: true }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: null, other: true }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: true, other: false }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: false, other: false }), + ).toBe(false); + expect( + predicate(operators.NOT)({ value: null, other: false }), + ).toBe(true); + expect( + predicate(operators.NOT)({ value: false, other: null }), + ).toBe(true); + }); + + it('categorical', () => { + expect( + predicate(operators.NOT)({ value: ['f'], other: ['f'] }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: ['f'], other: ['f', 'm'] }), + ).toBe(true); + + // Order shouldn't matter + expect( + predicate(operators.NOT)({ value: ['f', 'm'], other: ['f', 'm'] }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: ['m', 'f'], other: ['f', 'm'] }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: [1], other: [1] }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: [1], other: [1, 2] }), + ).toBe(true); + }); + + it('ordinal', () => { + expect( + predicate(operators.NOT)({ value: 'f', other: 'f' }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: 'f', other: 'm' }), + ).toBe(true); + + expect( + predicate(operators.NOT)({ value: 1, other: 1 }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: 1, other: 2 }), + ).toBe(true); + + expect( + predicate(operators.NOT)({ value: true, other: true }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: true, other: false }), + ).toBe(true); + }); + + it('scalar', () => { + expect( + predicate(operators.NOT)({ value: 1, other: 1 }), + ).toBe(false); + expect( + predicate(operators.NOT)({ value: 0.5, other: 1 }), + ).toBe(true); + }); + + it('date', () => { + expect( + predicate(operators.NOT)({ value: '2012-05-18', other: '2012-05-18' }), + ).toBe(false); + + expect( + predicate(operators.NOT)({ value: '2012-05-18', other: '2012-05-19' }), + ).toBe(true); + }); }); - it('CONTAINS', () => { - expect( - predicate(operators.CONTAINS)({ value: 'word', other: 'wo' }), - ).toBe(true); - expect( - predicate(operators.CONTAINS)({ value: 'word', other: '^w' }), - ).toBe(true); - expect( - predicate(operators.CONTAINS)({ value: 'word', other: '^g' }), - ).toBe(false); + describe('CONTAINS', () => { + it('string', () => { + expect( + predicate(operators.CONTAINS)({ value: 'word', other: 'wo' }), + ).toBe(true); + expect( + predicate(operators.CONTAINS)({ value: 'word', other: '^w' }), + ).toBe(true); + expect( + predicate(operators.CONTAINS)({ value: 'word', other: '^g' }), + ).toBe(false); + }); }); - it('DOES_NOT_CONTAIN', () => { - expect( - predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: 'go' }), - ).toBe(true); - expect( - predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: '^g' }), - ).toBe(true); - expect( - predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: '^w' }), - ).toBe(false); + describe('DOES_NOT_CONTAIN', () => { + it('string', () => { + expect( + predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: 'go' }), + ).toBe(true); + expect( + predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: '^g' }), + ).toBe(true); + expect( + predicate(operators.DOES_NOT_CONTAIN)({ value: 'word', other: '^w' }), + ).toBe(false); + }); }); it('EXISTS', () => { diff --git a/predicate.js b/predicate.js index bcdf9cb..04aa6ad 100644 --- a/predicate.js +++ b/predicate.js @@ -6,18 +6,18 @@ const { // operators list const operators = { + GREATER_THAN: 'GREATER_THAN', + LESS_THAN: 'LESS_THAN', + GREATER_THAN_OR_EQUAL: 'GREATER_THAN_OR_EQUAL', + LESS_THAN_OR_EQUAL: 'LESS_THAN_OR_EQUAL', EXACTLY: 'EXACTLY', - INCLUDES: 'INCLUDES', - EXCLUDES: 'EXCLUDES', - EXISTS: 'EXISTS', - NOT_EXISTS: 'NOT_EXISTS', NOT: 'NOT', CONTAINS: 'CONTAINS', DOES_NOT_CONTAIN: 'DOES_NOT_CONTAIN', - GREATER_THAN: 'GREATER_THAN', - GREATER_THAN_OR_EQUAL: 'GREATER_THAN_OR_EQUAL', - LESS_THAN: 'LESS_THAN', - LESS_THAN_OR_EQUAL: 'LESS_THAN_OR_EQUAL', + EXISTS: 'EXISTS', + NOT_EXISTS: 'NOT_EXISTS', + INCLUDES: 'INCLUDES', + EXCLUDES: 'EXCLUDES', OPTIONS_GREATER_THAN: 'OPTIONS_GREATER_THAN', OPTIONS_LESS_THAN: 'OPTIONS_LESS_THAN', OPTIONS_EQUALS: 'OPTIONS_EQUALS', @@ -63,9 +63,32 @@ const predicate = operator => case countOperators.COUNT_LESS_THAN_OR_EQUAL: return value <= variableValue; case operators.EXACTLY: + // If value and variableValue are both arrays, sort them so that we can compare them + if (isArray(value) && isArray(variableValue)) { + return isEqual(value.sort(), variableValue.sort()); + } + + /** + * If value is an array, check if it is array with single item + * which == variableValue and return true + * + * This fixes a bug where categorical variable rules using exists/not were returning false + * because the value was an array with a single item, but the variableValue was not + * + * e.g. value = ['F'], variableValue = 'F' will return true + */ + if (isArray(value) && value.length === 1) { + return isEqual(value[0], variableValue); + } + return isEqual(value, variableValue); case countOperators.COUNT: return isEqual(value, variableValue); case operators.NOT: + // If value and variableValue are both arrays, sort them so that we can compare them + if (isArray(value) && isArray(variableValue)) { + return !isEqual(value.sort(), variableValue.sort()); + } + return !isEqual(value, variableValue); case countOperators.COUNT_NOT: return !isEqual(value, variableValue); case operators.CONTAINS: {