Reputation: 44376
I'm putting a security layer in front of a document-oriented and I needed a reasonably abstract way for the application to define what new documents and what updates to existing documents are legal for a particular user.
The particular issues are -- The documents are defined (at least in transit) as JSON objects, so the rules may be hierarchical and so the rule-engine has to work recursively. For example, the Employee object may have a sub-object called Compensation and that sub-object has a field called PayPeriod that must be one of 'weekly', 'biweekly', or 'monthly'. -- it runs Node.js and some rules need to read from input (for example, to read more user data from a database) , so it has to run in continuation style.
So what I came up with is this: each rule is a function that takes the current value, the proposed new value, and a callback that is invoked with the value to be used. That value can be one of the two inputs or some third value calculated by the rule. Here's one rule:
var nonEmpty = function(proposedValue, existingValue, callback) {
callback( (proposedValue.length > 0) ? proposedValue : existingValue);
};
This rule would only allow you to set or replaces this field with a non-zero-length value. Of course, that only makes sense for string values (ignore lists for the moment, so we need a rule to enforce string-ness):
var isString = function(proposedValue, existingValue, callback) {
callback( ( typeof(proposedValue) === 'string') ? proposedValue : existingValue);
};
In fact, that seems like a common sort of problem, so I wrote a rule generator:
var ofType = function(typeName) {
return function(proposedValue, existingValue, callback) {
callback( ( typeof(proposedValue) === typeName) ? proposedValue : existingValue);
};
};
var isString = ofType('string')
but I need a way to string rules together:
var and = function(f1, f2) {
return function(proposedValue, existingValue, callback) {
f1(proposedValue, existingValue,
function(newProposedValue) {
f2(newProposedValue, existingValue, callback);
});
};
};
var nonEmptyString = and(isString, nonEmpty);
So the rule for an adminstrator to update an Employee record might be:
limitedObject({
lastName : nonEmptyString,
firstName : nonEmptyString,
compensation : limitedObject({
payPeriod : oneOf('weekly', 'biweekly', 'monthly'),
pay : numeric
}
})
limitedObject
(like ofType
) is a rule-generating function, and it that only allows the fields specified in its argument and applies the given rule to the values of those fields.
So I wrote all this, and it works like a charm. All my bugs turned out to be errors in the unit tests! Well, almost all of them. Anyway, if you've read this far, here is my question:
I've been febrilely studying monads and my reading inspired me to solve the problem this way. But, is this truly monadic?
(Possible answers: "Yes", "No, but that's okay because monads aren't really the right approach for this problem", and "No, and here's what needs to change". Fourth possibilities also welcome.)
Upvotes: 4
Views: 237
Reputation: 139840
No, this does not appear to be monadic. What you've defined appears to be a mini-DSL of rule combinators, where you have simple rules like ofType(typeName)
and ways of combining rules into bigger rules like and(rule1, rule2)
.
In order to have a monad, you need some notion of context in which you can put any value. You also need the following operations:
wrap(x)
for putting any value into some default context.map(f, m)
for applying a function f
to transform the value within m
without altering the context.flatten(mm)
for flattening two layers of context into one.These operations must satisfy certain "obvious" laws:
Adding a layer of context on the outside and collapsing gives you back what you started with.
flatten(wrap(m)) == m
Adding a layer of context on the inside and collapsing gives you back what you started with.
flatten(map(wrap, m)) == m
If you have a value with three layers of context, it does not matter whether you collapse the two inner or the two outer layers first.
flatten(flatten(mmm)) == flatten(map(flatten, mmm))
It is also possible to define a monad in terms of wrap
as above and another operation bind
, however this is equivalent to the above, as you can define bind
in terms of map
and flatten
, and vice versa.
function bind(f, m) { return flatten(map(f, m)); }
# or
function map(f, m) { return bind(function(x) { return wrap(f(x)); }, m); }
function flatten(mm) { return bind(function(x) { return x; }, mm); }
It is not clear what the notion of context would be here, how you would turn any value into a rule. Thus the question of how to flatten two layers of rules makes even less sense.
I don't think a monad is a suitable abstraction here.
However there it is easy to see that your and
does form a monoid with the always-succeeding rule (shown below) as the identity element.
function anything(proposedValue, existingValue, callback) {
callback(proposedValue);
}
Upvotes: 3