Skip to content

Instantly share code, notes, and snippets.

@oussamahamdaoui
Created March 13, 2023 00:59
Show Gist options
  • Save oussamahamdaoui/5a30e07e36a33d416c3ae71bb8d53b24 to your computer and use it in GitHub Desktop.
Save oussamahamdaoui/5a30e07e36a33d416c3ae71bb8d53b24 to your computer and use it in GitHub Desktop.
Parser combinators for data validation Part 1 and 2
/**
* Context
* @typedef {object} Context
* @property {boolean} isError
* @property {string} errorMessage
* @property {number} index
* @property {any} result
* @property {any} srcObj
* @property {string} path
*
*/
/**
* @param {string} path
* @param {object} obj
* @returns {object}
*/
const getValueAt = (path, obj)=>{
if(path === '') return obj;
const keys = path.split('.').filter(e=>e !== '');
return keys.reduce((acc, key)=> {
return acc[key];
}, obj);
}
/**
*
* @param {string} path
* @returns {string}
*/
const printPath = (path)=>{
if(path === '') return '';
return `at path ${path.slice(1)}`
}
class Rule {
/**
*
* @param {(c:Context)=>Context} ruleValidationFn
*/
constructor(ruleValidationFn) {
this.ruleValidationFn = ruleValidationFn;
}
/**
*
* @param {(any)=>any} mapFn
* @returns {Rule}
*/
map(mapFn) {
return new Rule((ctx) => {
const nextCtx = this.ruleValidationFn(ctx);
if (nextCtx.isError) {
return {
...ctx,
isError: true,
errorMessage: nextCtx.errorMessage,
};
};
return {
...nextCtx,
result: mapFn(nextCtx.result),
}
});
}
mapError(mapFn) {
return new Rule((ctx) => {
const nextCtx = this.ruleValidationFn(ctx);
if (nextCtx.isError) {
return {
...ctx,
isError: true,
errorMessage: mapFn(nextCtx.errorMessage, nextCtx.path, nextCtx.index)
};
};
return {
...nextCtx,
}
});
}
/**
*
* @param {(any)=>Context} chainFn
* @returns {Rule}
*/
chain(chainFn) {
return new Rule((ctx) => {
let nextCtx = this.ruleValidationFn(ctx);
let nextRule = chainFn(nextCtx.result);
nextCtx = nextRule.run(nextCtx);
return nextCtx;
});
}
/**
*
* @param {Rule} rule
* @returns {Rule}
*/
is(rule){
return this.chain(()=>rule);
}
/**
*
* @param {Context} ctx
* @returns {Context}
*/
run(ctx) {
if (ctx.isError) return ctx;
return this.ruleValidationFn(ctx);
}
test(obj){
return this.run({
srcObj: obj,
index: 0,
path:"",
isError: false,
errorMessage: "",
})
}
}
/**
*
* @param {string} str
* @returns {Rule}
*/
const str = (str) => new Rule((ctx) => {
const value = getValueAt(ctx.path, ctx.srcObj);
if (typeof value !== 'string') {
return {
...ctx,
isError: true,
errorMessage: `Expected string but got ${typeof value} ${printPath(ctx.path)}`,
}
}
if (value.slice(ctx.index).startsWith(str)) {
return {
...ctx,
index: ctx.index + str.length,
result: str,
}
};
return {
...ctx,
isError: true,
errorMessage: `Expected ${str} but got ${value.slice(ctx.index, str.length)} ${printPath(ctx.path)}`,
}
});
/**
*
* @param {RegExp} reg
* @returns {Rule}
*/
const regex = (reg) => new Rule((ctx) => {
const value = getValueAt(ctx.path, ctx.srcObj);
if (typeof value !== 'string') {
return {
...ctx,
isError: true,
errorMessage: `Expected string but got ${typeof value} ${printPath(ctx.path)}`,
}
}
const match = value.slice(ctx.index).match(reg);
if (match) {
return {
...ctx,
index: ctx.index + match[0].length,
result: match[0],
errorMessage: '',
}
}
let pretty = value.slice(ctx.index, 20);
pretty = (value.length > 20) ? (pretty + '...') : pretty;
return {
...ctx,
isError: true,
errorMessage: `${pretty} did not match ${reg} ${printPath(ctx.path)}`,
}
});
/**
*
* @param {...Rule} rules
* @returns {Rule}
*/
const sequenceOf = (...rules) => new Rule((ctx) => {
const results = [];
let nextContext = ctx;
for (rule of rules) {
nextContext = rule.run(nextContext);
if (!nextContext.isError) {
results.push(nextContext.result);
} else {
break;
}
};
return {
...nextContext,
result: results,
};
});
/**
*
* @param {...Rule} rules
* @returns {Rule}
*/
const choice = (...rules) => new Rule((ctx) => {
let errorMessages = [];
for (let rule of rules) {
const nextCtx = rule.run(ctx);
if (!nextCtx.isError) {
return nextCtx;
} else {
errorMessages.push(nextCtx.errorMessage);
}
}
return {
...ctx,
isError: true,
errorMessage: ['No match found', ...errorMessages],
}
});
/**
*
* @param {Rule} rule
* @returns {Rule}
*/
const many = (rule) => new Rule((ctx) => {
const res = [];
let nextCtx = ctx;
while (!nextCtx.isError) {
let s = rule.run(nextCtx);
if (!s.isError) {
res.push(s.result);
nextCtx = s;
} else {
break;
}
}
return {
...nextCtx,
result: res,
}
});
/**
*
* @generator
* @function GeneratorFn
* @yields {Rule}
* @returns {any}
*/
/**
*
* @param {GeneratorFn} generatorFn
* @returns
*/
const context = (generatorFn) => {
return new Rule((ctx) => ctx).chain(() => {
const iterator = generatorFn();
const runStep = (nextValue) => {
const response = iterator.next(nextValue);
if (response.done) {
return new Rule((ctx) => {
return {
...ctx,
result: response.value,
};
});
}
return response.value.chain(runStep);
};
return runStep();
});
}
/**
*
* @param {Rule} rule
* @returns {Rule}
*/
const lookAhead = (rule) => new Rule((ctx) => {
const va = rule.run(ctx);
if (va.isError) {
return {
...ctx,
result: undefined,
}
}
return {
...ctx,
result: va.result,
};
});
/**
*
* @param {string} key
* @returns {Rule}
*/
const hasKey = (key)=>{
return new Rule((ctx)=>{
const value = getValueAt(ctx.path, ctx.srcObj);
if(value !== Object(value)){
return {
...ctx,
isError:true,
errorMessage:`Expected object but found ${typeof value} ${printPath(ctx.path)}`,
}
}
if(!value.hasOwnProperty(key)){
return {
...ctx,
isError:true,
errorMessage:`Expected key ${key} ${printPath(ctx.path)}`,
}
}
return {
...ctx,
index: 0, // we reset the index when we nest
path: `${ctx.path}.${key}`,
result: ctx.srcObj[key],
}
});
}
/**
*
* @param {...Rule} rules
* @returns {Rule}
*/
const and = (...rules) => {
return new Rule((ctx)=>{
let nextCtx;
const result = [];
for(const rule of rules){
// we keep the same context for every rule
nextCtx = rule.run(ctx);
if(nextCtx.isError){
break;
}
result.push(nextCtx.result);
}
return {
...ctx,
isError: nextCtx.isError,
errorMessage: nextCtx.errorMessage,
result,
}
});
}
const isBoolean = new Rule((ctx)=>{
const value = getValueAt(ctx.path, ctx.srcObj);
if(Boolean(value) === value){
return {
...ctx,
result: value,
}
}
return {
...ctx,
isError:true,
errorMessage:`Expected boolean but found ${typeof value} ${printPath(ctx.path)}`
}
});
const isNumber = new Rule((ctx)=>{
const value = getValueAt(ctx.path, ctx.srcObj);
if(Number(value) === value){
return {
...ctx,
result: value,
}
}
return {
...ctx,
isError:true,
errorMessage:`Expected number but found ${typeof value} ${printPath(ctx.path)}`
}
});
const isArray = new Rule((ctx)=>{
const value = getValueAt(ctx.path, ctx.srcObj);
if(Array.isArray(value)){
return {
...ctx,
result: value,
}
}
return {
...ctx,
isError:true,
errorMessage:`Expected array but found ${typeof value} ${printPath(ctx.path)}`
}
});
const isObject = new Rule((ctx)=>{
const value = getValueAt(ctx.path, ctx.srcObj);
if(value !== Object(value)){
return {
...ctx,
isError:true,
errorMessage:`Expected object but found ${typeof value} ${printPath(ctx.path)}`,
}
}
return {
...ctx,
result:value,
}
});
/**
*
* @param {Rule} rule
* @returns {Rule}
*/
const arraySome = (rule)=> isArray.chain((arr)=>new Rule((ctx)=>{
const ret = arr.findIndex((_, i)=>{
return rule.run({
...ctx,
index:0,
path: `${ctx.path}.${i}`,
}).isError === false;
});
if(ret !== -1){
return {
...ctx,
result: arr[ret],
}
}
return {
...ctx,
isError:true,
errorMessage: `No value matching the rule was found ${printPath(ctx.path)}`,
}
}));
/**
*
* @param {Rule} rule
* @returns {Rule}
*/
const arrayOf = (rule)=> isArray.chain((arr)=>new Rule((ctx)=>{
let message = '';
const ret = arr.findIndex((_, i)=>{
const res = rule.run({
...ctx,
index:0,
path: `${ctx.path}.${i}`,
});
message = res.errorMessage;
return res.isError !== false;
});
if(ret === -1){
return {
...ctx,
result: arr,
}
}
return {
...ctx,
isError:true,
errorMessage: `The value ${printPath(ctx.path)} didn't match the rule message: ${message}`,
}
}));
/**
*
* @param {string} errorMessage
* @param {(a:number, b?:number)=>boolean} op
* @returns {(n?:number)=>Rule}
*/
const NumberCompare = (op, errorMessage) => (n) => isNumber.chain((value)=>{
return new Rule((ctx)=> {
if(op(value, n)){
return {
...ctx,
result: value,
}
} else {
return {
...ctx,
isError:true,
errorMessage:`Number ${value} should be ${errorMessage}${n ? ` ${n}`:''} ${printPath(ctx.path)}`,
}
}
});
});
const gt = NumberCompare((a, b)=> a > b, 'greater than');
const lt = NumberCompare((a, b)=> a < b, 'less than');
const gte = NumberCompare((a, b)=> a >= b, 'greater than or equal to');
const lte = NumberCompare((a, b)=> a <= b, 'less than or equal to');
const neq = NumberCompare((a, b)=> a !== b, 'different than');
const eq = NumberCompare((a, b)=> a === b, 'equal to');
const isInteger = NumberCompare((a)=> {
return Number.isInteger(a)
}, 'be an integer')();
const printAdditionalKeys = (arr)=>{
if(arr.length === 0) return '';
return `The keys: ${arr.join(', ')} are forbiden in the object.`;
}
const printMissingKeys = (arr)=>{
if(arr.length === 0) return '';
return `The keys: ${arr.join(',')} are missing from the object. `;
}
/**
*
* @param {...string} keys
* @returns {Rule}
*/
const onlyKeys = (...targetKeys) => isObject.chain((obj)=>new Rule((ctx)=>{
const keys = Object.keys(obj);
if(targetKeys.length !== keys.length || keys.find((k, i)=> k !==targetKeys[i])){
const missingKeys = targetKeys.filter(k => !keys.includes(k));
const additionalKeys = keys.filter(k => !targetKeys.includes(k));
return {
...ctx,
isError:true,
errorMessage: `${printMissingKeys(missingKeys)}${printAdditionalKeys(additionalKeys)} ${printPath(ctx.path)}`
}
}
return {
...ctx,
}
}))
/////
let test;
const arrayWith = arraySome;
const userName = regex(/^[\p{L} ]+$/ui);
const age = choice(gt(10), isInteger).mapError((e)=> e);
const isVip = isBoolean;
const user = and(
hasKey('name').is(userName),
hasKey('age').is(age),
hasKey('vip').is(isVip),
hasKey('firends').is(arrayOf(userName)),
onlyKeys(
"name",
"vip",
"age",
"someOtherKey",
"firends"
),
)
test = user.test({
'name':"Alice Doe",
"someOtherKey": "123",
"vip": false,
'age': .1,
"firends":["Joe"],
});
console.log(test);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment