Skip to content

Commit 6e042ea

Browse files
authored
[JSON Logic] Part 3: Computed string based values (#37)
1 parent 1e43bb0 commit 6e042ea

File tree

6 files changed

+705
-26
lines changed

6 files changed

+705
-26
lines changed

.eslintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"rules": {
1313
"jest/no-focused-tests": "error",
14+
"curly": ["error", "multi-line"],
1415
"arrow-body-style": 0,
1516
"default-case": 0,
1617
"import/order": [

src/jsonLogic.js

Lines changed: 293 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import jsonLogic from 'json-logic-js';
22

3+
import { buildYupSchema } from './yupSchema';
4+
35
/**
46
* Parses the JSON schema to extract the json-logic rules and returns an object
57
* containing the validation scopes, functions to retrieve the scopes, and evaluate the
@@ -9,15 +11,12 @@ import jsonLogic from 'json-logic-js';
911
* @returns {Object} An object containing:
1012
* - scopes {Map} - A Map of the validation scopes (with IDs as keys)
1113
* - getScope {Function} - Function to retrieve a scope by name/ID
12-
* - validate {Function} - Function to evaluate a validation rule
13-
* - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition
14-
* - applyComputedValueInField {Function} - Evaluate a computed value rule for a field
15-
* - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition
1614
*/
1715
export function createValidationChecker(schema) {
1816
const scopes = new Map();
1917

2018
function createScopes(jsonSchema, key = 'root') {
19+
const sampleEmptyObject = buildSampleEmptyObject(schema);
2120
scopes.set(key, createValidationsScope(jsonSchema));
2221
Object.entries(jsonSchema?.properties ?? {})
2322
.filter(([, property]) => property.type === 'object' || property.type === 'array')
@@ -28,6 +27,8 @@ export function createValidationChecker(schema) {
2827
createScopes(property, key);
2928
}
3029
});
30+
31+
validateInlineRules(jsonSchema, sampleEmptyObject);
3132
}
3233

3334
createScopes(schema);
@@ -40,6 +41,21 @@ export function createValidationChecker(schema) {
4041
};
4142
}
4243

44+
/**
45+
* Creates a validation scope object for a schema.
46+
*
47+
* Builds maps of validations and computed values defined in the schema's
48+
* x-jsf-logic section. Includes functions to evaluate the rules.
49+
*
50+
* @param {Object} schema - The JSON schema
51+
* @returns {Object} The validation scope object containing:
52+
* - validationMap - Map of validation rules
53+
* - computedValuesMap - Map of computed value rules
54+
* - validate {Function} - Function to evaluate a validation rule
55+
* - applyValidationRuleInCondition {Function} - Evaluate a validation rule used in a condition
56+
* - applyComputedValueInField {Function} - Evaluate a computed value rule for a field
57+
* - applyComputedValueRuleInCondition {Function} - Evaluate a computed value rule used in a condition
58+
*/
4359
function createValidationsScope(schema) {
4460
const validationMap = new Map();
4561
const computedValuesMap = new Map();
@@ -51,12 +67,25 @@ function createValidationsScope(schema) {
5167

5268
const validations = Object.entries(logic.validations ?? {});
5369
const computedValues = Object.entries(logic.computedValues ?? {});
70+
const sampleEmptyObject = buildSampleEmptyObject(schema);
5471

5572
validations.forEach(([id, validation]) => {
73+
if (!validation.rule) {
74+
throw Error(`[json-schema-form] json-logic error: Validation "${id}" has missing rule.`);
75+
}
76+
77+
checkRuleIntegrity(validation.rule, id, sampleEmptyObject);
78+
5679
validationMap.set(id, validation);
5780
});
5881

5982
computedValues.forEach(([id, computedValue]) => {
83+
if (!computedValue.rule) {
84+
throw Error(`[json-schema-form] json-logic error: Computed value "${id}" has missing rule.`);
85+
}
86+
87+
checkRuleIntegrity(computedValue.rule, id, sampleEmptyObject);
88+
6089
computedValuesMap.set(id, computedValue);
6190
});
6291

@@ -72,8 +101,13 @@ function createValidationsScope(schema) {
72101
const validation = validationMap.get(id);
73102
return validate(validation.rule, values);
74103
},
75-
applyComputedValueInField(id, values) {
104+
applyComputedValueInField(id, values, fieldName) {
76105
const validation = computedValuesMap.get(id);
106+
if (validation === undefined) {
107+
throw Error(
108+
`[json-schema-form] json-logic error: Computed value "${id}" doesn't exist in field "${fieldName}".`
109+
);
110+
}
77111
return validate(validation.rule, values);
78112
},
79113
applyComputedValueRuleInCondition(id, values) {
@@ -112,6 +146,12 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) {
112146
const { parentID = 'root' } = config;
113147
const validation = logic.getScope(parentID).validationMap.get(id);
114148

149+
if (validation === undefined) {
150+
throw Error(
151+
`[json-schema-form] json-logic error: "${field.name}" required validation "${id}" doesn't exist.`
152+
);
153+
}
154+
115155
return (yupSchema) =>
116156
yupSchema.test(
117157
`${field.name}-validation-${id}`,
@@ -123,26 +163,264 @@ export function yupSchemaWithCustomJSONLogic({ field, logic, config, id }) {
123163
);
124164
}
125165

166+
const HANDLEBARS_REGEX = /\{\{([^{}]+)\}\}/g;
167+
168+
/**
169+
* Replaces Handlebars templates in a value with computed values.
170+
*
171+
* Handles recursively replacing Handlebars templates "{{var}}" in strings
172+
* with computed values looked up from the validation logic.
173+
*
174+
* @param {Object} options - Options object
175+
* @param {*} options.value - The value to replace templates in
176+
* @param {Object} options.logic - The validation logic object
177+
* @param {Object} options.formValues - The current form values
178+
* @param {string} options.parentID - The ID of the validation scope
179+
* @param {string} options.name - The name of the field
180+
* @returns {*} The value with templates replaced with computed values
181+
*/
182+
function replaceHandlebarsTemplates({
183+
value: toReplace,
184+
logic,
185+
formValues,
186+
parentID,
187+
name: fieldName,
188+
}) {
189+
if (typeof toReplace === 'string') {
190+
return toReplace.replace(HANDLEBARS_REGEX, (match, key) => {
191+
return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName);
192+
});
193+
} else if (typeof toReplace === 'object') {
194+
const { value, ...rules } = toReplace;
195+
196+
if (Object.keys(rules).length > 1 && !value) {
197+
throw Error('Cannot define multiple rules without a template string with key `value`.');
198+
}
199+
200+
const computedTemplateValue = Object.entries(rules).reduce((prev, [key, rule]) => {
201+
const computedValue = logic.getScope(parentID).evaluateValidation(rule, formValues);
202+
return prev.replaceAll(`{{${key}}}`, computedValue);
203+
}, value);
204+
205+
return computedTemplateValue.replace(/\{\{([^{}]+)\}\}/g, (match, key) => {
206+
return logic.getScope(parentID).applyComputedValueInField(key.trim(), formValues, fieldName);
207+
});
208+
}
209+
return toReplace;
210+
}
211+
212+
/**
213+
* Builds computed attributes for a field based on jsonLogic rules.
214+
*
215+
* Processes rules defined in the schema's x-jsf-logic section to build
216+
* computed attributes like label, description, etc.
217+
*
218+
* Handles replacing handlebars templates in strings with computed values.
219+
*
220+
* @param {Object} fieldParams - The field configuration parameters
221+
* @param {Object} options - Options
222+
* @param {string} [options.parentID='root'] - ID of the validation scope
223+
* @returns {Function} A function to build the computed attributes
224+
*/
126225
export function calculateComputedAttributes(fieldParams, { parentID = 'root' } = {}) {
127-
return ({ logic, formValues }) => {
128-
const { computedAttributes } = fieldParams;
226+
return ({ logic, isRequired, config, formValues }) => {
227+
const { name, computedAttributes } = fieldParams;
129228
const attributes = Object.fromEntries(
130229
Object.entries(computedAttributes)
131-
.map(handleComputedAttribute(logic, formValues, parentID))
230+
.map(handleComputedAttribute(logic, formValues, parentID, name))
132231
.filter(([, value]) => value !== null)
133232
);
134233

135-
return attributes;
234+
return {
235+
...attributes,
236+
schema: buildYupSchema(
237+
{ ...fieldParams, ...attributes, required: isRequired },
238+
config,
239+
logic
240+
),
241+
};
136242
};
137243
}
138244

139-
function handleComputedAttribute(logic, formValues, parentID) {
245+
/**
246+
* Handles computing a single attribute value.
247+
*
248+
* Evaluates jsonLogic rules to build the computed value.
249+
*
250+
* @param {Object} logic - Validation logic
251+
* @param {Object} formValues - Current form values
252+
* @param {string} parentID - ID of the validation scope
253+
* @param {string} name - Name of the field
254+
* @returns {Function} Function to compute the attribute value
255+
*/
256+
function handleComputedAttribute(logic, formValues, parentID, name) {
140257
return ([key, value]) => {
141-
if (key === 'const')
142-
return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)];
143-
144-
if (typeof value === 'string') {
145-
return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues)];
258+
switch (key) {
259+
case 'description':
260+
return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })];
261+
case 'title':
262+
return ['label', replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })];
263+
case 'x-jsf-errorMessage':
264+
return [
265+
'errorMessage',
266+
handleNestedObjectForComputedValues(value, formValues, parentID, logic, name),
267+
];
268+
case 'x-jsf-presentation': {
269+
if (value.statement) {
270+
return [
271+
'statement',
272+
handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name),
273+
];
274+
}
275+
return [
276+
key,
277+
handleNestedObjectForComputedValues(value.statement, formValues, parentID, logic, name),
278+
];
279+
}
280+
case 'const':
281+
default:
282+
return [key, logic.getScope(parentID).applyComputedValueInField(value, formValues, name)];
146283
}
147284
};
148285
}
286+
287+
function handleNestedObjectForComputedValues(values, formValues, parentID, logic, name) {
288+
return Object.fromEntries(
289+
Object.entries(values).map(([key, value]) => {
290+
return [key, replaceHandlebarsTemplates({ value, logic, formValues, parentID, name })];
291+
})
292+
);
293+
}
294+
295+
/**
296+
* Builds a sample empty object for the given schema.
297+
*
298+
* Recursively builds an object with empty values for each property in the schema.
299+
* Used to provide a valid data structure to test jsonLogic validation rules against.
300+
*
301+
* Handles objects, arrays, and nested schemas.
302+
*
303+
* @param {Object} schema - The JSON schema
304+
* @returns {Object} Sample empty object based on the schema
305+
*/
306+
function buildSampleEmptyObject(schema = {}) {
307+
const sample = {};
308+
if (typeof schema !== 'object' || !schema.properties) {
309+
return schema;
310+
}
311+
312+
for (const key in schema.properties) {
313+
if (schema.properties[key].type === 'object') {
314+
sample[key] = buildSampleEmptyObject(schema.properties[key]);
315+
} else if (schema.properties[key].type === 'array') {
316+
const itemSchema = schema.properties[key].items;
317+
sample[key] = buildSampleEmptyObject(itemSchema);
318+
} else {
319+
sample[key] = true;
320+
}
321+
}
322+
323+
return sample;
324+
}
325+
326+
/**
327+
* Validates inline jsonLogic rules defined in the schema's x-jsf-logic-computedAttrs.
328+
*
329+
* For each field with computed attributes, checks that the variables
330+
* referenced in the rules exist in the schema.
331+
*
332+
* Throws if any variable in a computed attribute rule does not exist.
333+
*
334+
* @param {Object} jsonSchema - The JSON schema object
335+
* @param {Object} sampleEmptyObject - Sample empty object based on the schema
336+
*/
337+
function validateInlineRules(jsonSchema, sampleEmptyObject) {
338+
const properties = (jsonSchema?.properties || jsonSchema?.items?.properties) ?? {};
339+
Object.entries(properties)
340+
.filter(([, property]) => property['x-jsf-logic-computedAttrs'] !== undefined)
341+
.forEach(([fieldName, property]) => {
342+
Object.entries(property['x-jsf-logic-computedAttrs'])
343+
.filter(([, value]) => typeof value === 'object')
344+
.forEach(([key, item]) => {
345+
Object.values(item).forEach((rule) => {
346+
checkRuleIntegrity(
347+
rule,
348+
fieldName,
349+
sampleEmptyObject,
350+
(item) =>
351+
`[json-schema-form] json-logic error: fieldName "${item.var}" doesn't exist in field "${fieldName}.x-jsf-logic-computedAttrs.${key}".`
352+
);
353+
});
354+
});
355+
});
356+
}
357+
358+
/**
359+
* Checks the integrity of a jsonLogic rule by validating that all referenced variables exist in the provided data object.
360+
* Throws an error if any variable in the rule does not exist in the data.
361+
*
362+
* @example
363+
*
364+
* const rule = { "+": [{ "var": "iDontExist"}, 10 ]}
365+
* const badData = { a: 1 }
366+
* checkRuleIntegrity(rule, "add_ten_to_field", badData)
367+
* // throws Error(`"iDontExist" in rule "add_ten_to_field" does not exist as a JSON schema property.`)
368+
*
369+
*
370+
* @param {Object|Array} rule - The jsonLogic rule object or array to validate
371+
* @param {string} id - The ID of the rule (used in error messages)
372+
* @param {Object} data - The data object to check the rule variables against
373+
* @param {Function} errorMessage - Function to generate custom error message.
374+
* Receives the invalid rule part and should throw an error message string.
375+
*/
376+
function checkRuleIntegrity(
377+
rule,
378+
id,
379+
data,
380+
errorMessage = (item) =>
381+
`[json-schema-form] json-logic error: rule "${id}" has no variable "${item.var}".`
382+
) {
383+
Object.entries(rule ?? {}).map(([operator, subRule]) => {
384+
if (!Array.isArray(subRule) && subRule !== null && subRule !== undefined) return;
385+
throwIfUnknownOperator(operator, subRule, id);
386+
387+
subRule.map((item) => {
388+
const isVar = item !== null && typeof item === 'object' && Object.hasOwn(item, 'var');
389+
if (isVar) {
390+
const exists = jsonLogic.apply({ var: removeIndicesFromPath(item.var) }, data);
391+
if (exists === null) {
392+
throw Error(errorMessage(item));
393+
}
394+
} else {
395+
checkRuleIntegrity(item, id, data);
396+
}
397+
});
398+
});
399+
}
400+
401+
function throwIfUnknownOperator(operator, subRule, id) {
402+
try {
403+
jsonLogic.apply({ [operator]: subRule });
404+
} catch (e) {
405+
if (e.message === `Unrecognized operation ${operator}`) {
406+
throw Error(
407+
`[json-schema-form] json-logic error: in "${id}" rule there is an unknown operator "${operator}".`
408+
);
409+
}
410+
}
411+
}
412+
413+
const regexToGetIndices = /\.\d+\./g; // eg. .0., .10.
414+
415+
/**
416+
* Removes array indices from a json schema path string.
417+
* Converts paths like "foo.0.bar" to "foo.bar".
418+
* This allows checking if a variable exists in an array item schema without needing the specific index.
419+
*
420+
* @param {string} path - The json schema path potentially containing array indices
421+
* @returns {string} The path with array indices removed
422+
*/
423+
function removeIndicesFromPath(path) {
424+
const intermediatePath = path.replace(regexToGetIndices, '.');
425+
return intermediatePath.replace(/\.\d+$/, '');
426+
}

0 commit comments

Comments
 (0)