Created
March 13, 2023 00:59
-
-
Save oussamahamdaoui/5a30e07e36a33d416c3ae71bb8d53b24 to your computer and use it in GitHub Desktop.
Parser combinators for data validation Part 1 and 2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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