constrain 0.2.3 constrain: ^0.2.3 copied to clipboard
Object Constraints for Dart
Constrain: Object Constraints for Dart #
Introduction #
Provides a constraint based Validation library inspired by Java Bean Validation but leveraging the superior language capabilities of Dart. In particular:
- much more powerful annotations
- functions and
- an extensive matcher library
Warning: Runtime Mirrors Used.
Features #
- Class level constraints for cross field validation
- Property constraints (also via getter)
- Constraint Inheritance
- Cascading validation
- Constraint Groups
- Constraints can be specified with:
- Dart functions
- matchers from the matchers library
- Detailed constraint violation model
- with json support
- Constraints on function parameters and returns
- Constraints on method parameters and returns with inheritance
- A core set of common constraints such as Min, Max and Pattern
Usage #
Key Concept - Mandatoriness #
One of the most important concepts to remember is that all constraints on a property (other than @NotNull
) are only applied to a property when its value is not null.
So for example, you may have a constraint that defines what makes an email address valid. You can apply this to optional properties as well as mandatory ones. So for example if you have a mandatory home email and an optional work email you may have something like
class Contacts {
@NotNull()
@Ensure(isValidEmail)
String homeEmail;
@Ensure(isValidEmail)
String workEmail;
}
In effect constraints are applied as follows (illustrative only):
- if the
@NotNull
constraint is present apply that - if the value is not null apply all other constraints
So in the case of Contacts
the homeEmail
property must have a value and it must satisfy isValidEmail
. However, workEmail
is valid if it is null, but if it does have a value it must also satisfy isValidEmail
.
Define Constraints On Your Objects #
Note: It is recommended that you use one of the core constraints when one exists for your need. This will allow you to take advantage of packages that may be built in the future such as code generators from JSON schema that support them. See the section on Core Constraints below
The following (rather contrived) example illustrates several features of constraints, which are described below.
class Primate {
@Ensure(isPositive)
int age;
}
@Ensure(eitherOlderThan20OrHasAtLeastTwoAddresses,
description: 'Must be either older than twenty or have at least two adresses')
class Person extends Primate {
@Ensure(isBetween10and90)
int get age => super.age;
@NotNull()
@Ensure(allStreetsStartWith15, group: const Detailed())
List<Address> addresses;
@Ensure(cantBeYourOwnParent,
description: "A person cannot be their own parent")
Set<Person> parents;
String toString() => 'Person[age: $age, addressses: $addresses]';
}
class Address {
@Ensure(streetIsAtLeast10Characters)
String street;
String toString() => 'Address[street: $street]';
}
Matcher Constraints
The first constraint we see is on the Primate's age property
@Ensure(isPositive)
int age;
isPositive is a const matcher that comes with the matcher library. In short a primate's age will satisfy this constraint if it is greater than 0.
Using matchers is a common way to specify constraints. When matcher based constraints are violated they provide details about what went wrong.
Constraint Inheritance
If you look at the Person class you will see that it extends Primate. This means that it will automatically inherit the age property and the isPositive constraint on it. That is, Persons will also be subject to this constraint.
You can also see that Person has a getter on age as follows
@Ensure(isBetween10and90)
int get age => super.age;
It simply redirects to the primate's age and exists soley so that we can further constrain age (admittedly with a rather silly constraint). isBetween10and90 is another matcher but this time not a const so we must use a function to wrap it as follows
Matcher isBetween10and90() =>
allOf(greaterThanOrEqualTo(10), lessThanOrEqualTo(90));
Note there is no NotNull constraint on age. This means it is allowed to be null and the other constraints (isPositive and isBetween10and90) will only be applied if it is non null.
Next we can see that the addresses property has two constraints
@NotNull()
@Ensure(allStreetsStartWith15, group: const Detailed())
List<Address> addresses;
NotNull
NotNull indicates a property is mandatory.
allStreetsStartWith15 illustrates two more features.
Constraint Groups
Firstly it specifies a group called Detailed. This means that this constraint will only be validated when that group is validated (as covered in the Validation section below).
Boolean Function Constraints
Secondly, it is an example of a boolean expression based constraint
bool allStreetsStartWith15(List<Address> addresses) =>
addresses.every((a) => a.street == null || a.street.startsWith("15"));
In addition to matchers, you can also use plain ol' Dart code for your constraints. Note: as Dart does not have null safe path expressions you need to check each segment or risk an NPE
Note that even though this constraint depends only on a single field of the Address class it is not defined on the Address class's street property. The reason is, that it is not intended to be true for all uses of Address, just those that are owned by Persons. Keep this in mind when you decide where constraints should live.
The parents property illustrates yet another two features
@Ensure(cantBeYourOwnParent,
description: "A person cannot be their own parent")
Set<Person> parents;
Constraint Descriptions
Firstly, it contains a description named argument. This controls how the constraint will be referred to (e.g. when it is violated).
Boolean Expressions with Owner
Secondly, it is another form of boolean function constraint
bool cantBeYourOwnParent(Set<Person> parents, Person person) =>
!parents.contains(person);
Note the second argument person. This is the Person object that owns the parents field being validated. As you can see, this was needed to express this constraint. Most constraints don't need it but it's very useful at times.
Class Based Constraints
If we jump back to the Person class you will notice a constraint on the class itself
@Ensure(eitherOlderThan20OrHasAtLeastTwoAddresses,
description: 'Must be either older than twenty or have at least two adresses')
This is where you put cross field constraints. In other words, constraints that require more than one field of the class to express.
Matcher eitherOlderThan20OrHasAtLeastTwoAddresses() =>
anyOf(hasAge(greaterThan(20)),
hasAddresses(hasLength(greaterThanOrEqualTo("two"))));
Note that class based constraints are also inherited.
Cascading
Lastly, we come to the Address class and the constraint on street
@Ensure(streetIsAtLeast10Characters)
String street;
There is nothing terribly interesting about the constraint itself. What's interesting is in the context of validating a Person.
In order for the addresses property of Person to be considered valid it requires that each Address object is also valid. This means that the street property of each address must be at least 10 characters in length.
Validate your Constrained Objects #
Now you can create instances of your objects and validate them.
final Person person = new Person()
..age = -22
..addresses = [new Address()..street = "16 blah st"];
Validator v = new Validator();
Set<ConstraintViolation> violations = v.validate(person);
print(violations);
This prints
Constraint violated at path Symbol("addresses").Symbol("street")
Expected: an object with length of a value greater than or equal to <10>
Actual: '15 x st'
Which: has length of <9>
Constraint violated at path Symbol("parents")
A person cannot be their own parent
Constraint violated at path Symbol("age")
Expected: (a value greater than or equal to <10> and a value less than or equal to <90>)
Actual: <-22>
Which: is not a value greater than or equal to <10>
Constraint violated at path Symbol("age")
Expected: a positive value
Actual: <-22>
Which: is not a positive value
Constraint violated at path
Must be either older than twenty or have at least two adresses
Expected: (Person with age that a value greater than <20> or Person with addresses that an object with length of a value greater than or equal to 'two')
Actual: Person:<Person[age: -22, addressses: [Address[street: 15 x st]]]>
Note, depending on the audience you may not simply print the violations like this. Just like in Java the ConstraintViolation class is a structured object so in addition to a message you can get lots of details about exactly what was violated where.
When integrating with UI frameworks like polymer, you would typically use the structured information to provide better error messages. Specifying Constraint descriptions provide you complete control the wording of a constraint and is typically what you would want to show to the user.
Validating Groups
The model contained a single group called Detailed that was applied to the addresses property.
It was excluded from validation in the previous example which was validating against the DefaultGroup
To include this constraint too specify the groups as follows
final violations = v.validate(person, groups: [const Detailed()]);
Core Constraints #
Core constraints are useful to simplify adding common constraints and also for integrating with external constraint defintions (for example JSON Schema, XML Schema, HTML / Polymer Input Fields).
Constrain provides a core set of constraints. Currently this includes
Min and Max
@Min(10.2)
@Max(40.7, isInclusive: false)
double foo;
Min and Max can be applied to any Comparable
that has a meaningful sense of ordering and can be made a const
This includes all num
s (int
, double
).
Unfortunately DateTime doesn't have const construcutor. New constraints will likely be created for the DateTime equivalents of Min and Max in the future (like Before
and After
)
Both Min
and Max
provide a bool
property called isInclusive
.
Pattern
@Pattern(r'[\w]+-[\d]+')
String id;
Pattern allows you to constrain a String
field with anything that implements the Pattern
class. By default it assumes you give it a RegExp
and does the conversion (because RegExp
does not have a const
constructor)
You can prevent the conversion to RegExp with th isRegExp
parameter
@Pattern('a plain ol string', isRegExp: false)
String id;
Note: dart:core defines a class called Pattern
. Using constrain
s Pattern will result in a warning that it is hiding the dart:core version. To get rid of this warning you need to add import 'dart:core' hide Pattern;
or else import constrain with a prefix like import 'package:constrain/constrain.dart' as c;
.
To avoid this name clash Pattern
will likely be renamed in the future
JSON Encoding #
The rich model for constraint violations can be converted to JSON, for example to send it between a server and client.
The detailed information allows clients to be intelligent about how they report the errors to the user
final Person person = new Person()
..age = -22
..addresses = [new Address()..street = "16 blah st"];
Validator v = new Validator();
Set<ConstraintViolation> violations = v.validate(person);
print(JSON.encode(violations));
prints (abbreviated)
[
{
"constraint": {
"type": "Ensure",
"description": null,
"group": "DefaultGroup"
},
"message": "Constraint violated at path age\nExpected: (a value greater than or equal to <10> and a value less than or equal to <90>)\n Actual: <-22>\n Which: is not a value greater than or equal to <10>\n",
"rootObject": {
"type": "Person",
"value": {
"age": -22,
"parents": null,
"addresses": [
{
"street": "16 blah st"
}
]
}
},
"leafObject": {
"type": "Person",
"value": {
"age": -22,
"parents": null,
"addresses": [
{
"street": "16 blah st"
}
]
}
},
"invalidValue": {
"type": "int",
"value": -22
},
"propertyPath": "age",
"details": {
"expected": "(a value greater than or equal to <10> and a value less than or equal to <90>)",
"actual": "<-22>",
"mismatchDescription": "is not a value greater than or equal to <10>"
},
"reason": null
},
......
Function Constraints #
You can add constraints to function parameters (positional and named) and return values
@NotNull() String bar(@NotNull() int blah, String foo) => '$blah';
Similarly you can add constraints to class methods. Constraints will be inherited from:
- super classes
- interfaces
- mixins
class Foo {
String bar(@NotNull() int blah, String foo) => '$blah';
}
class Blah extends Object with Foo {
@NotNull() String bar(@Ensure(isBetween10and90) int blah,
@NotNull() String foo) => '$blah';
}
Validating Parameters
validator.validateFunctionParameters(bar, [1, "foo"])
or for methods
validator.validateFunctionParameters(new Foo().bar, [1, "foo"])
Validating Returns
validator.validateFunctionReturn(new Foo().bar, "some return value")
Details #
Constraints #
The Constraint Class
All constraints must implement (typically indirectly) the Constraint
class. It's key method is
void validate(T value, ConstraintValidationContext context);
It is passed the value to be validated and a context. If the constraint is violated then it creates a ConstraintViolation
by calling the contexts addViolation method
void addViolation({String reason, ViolationDetails details});
optionally providing details and a reason.
Typically you will not use Constraint directly or subclass it directly. The only two subtypes currently (and likely to remain that way) are:
- NotNull and
- Ensure
If you do directly subtype Constraint then you will need to deal with a possible null value being passed to validate. This is not the case with Ensure.
NotNull
The NotNull constraint indicates the field is mandatory. It is somewhat special as it directly subclasses Constraint.
Ensure
Ensure is the main constraint subclass that the vast majority of constraints are likely to use.
Ensure delegates the actual validation to it's validator object. Note that the validator will only be called if the value passed to Ensure's validate method is not null.
The validator can be any of the following:
1. A ConstraintValidator function
The ConstraintValidator function is the same signature as the validate method on Constraint. It is defined as
typedef void ConstraintValidator(dynamic value,
ConstraintValidationContext context);
This is best used when you want control over the creation of the ConstraintViolation object. Note the owner of the value is available via the context
2. A SimpleConstraintValidator function
This is a simplified form of validator function which is just a boolean expression indicating whether the constraint is valid.
typedef bool SimpleConstraintValidator(dynamic value);
This is typically used in preference to ConstraintValidator.
3. A SimplePropertyConstraintValidator function
Contains the owner of the value as an additional argument.
typedef bool SimplePropertyConstraintValidator(dynamic value, dynamic owner);
4. A Matcher
Any const matcher can be used such as isEmpty.
5. A function that returns a Matcher
Specically a function that adheres to
typedef Matcher MatcherCreator();
This is the more common way to use matchers as most matchers take an argument and are accessed via function. These cannot currently be const in Dart so the workaround is to use a function that returns the matcher.
Constraint Groups
ConstraintGroups are used to restrict which constraints are validated during a validation. The ConstraintGroup is defined as
abstract class ConstraintGroup {
bool implies(ConstraintGroup other);
}
Matching for ConstraintGroups is done via the implies method. This method should return true to indicate that the constraint should be validated.
To define your own simple group extend SimpleConstraintGroup and make sure you inclued a const constructor.
class Detailed extends SimpleConstraintGroup {
const Detailed();
}
To compose a group out of other groups extend CompositeConstraintGroup
class GroupOneAndTwo extends CompositeGroup {
const GroupOneAndTwo() : super(const [const GroupOne(), const GroupTwo()]);
}
Looking up Constraints
If you want to do something fancier, for example, integrate with some library (like Polymer, Json Schema etc.) then you may need to directly work with the constraints.
final resolver = new TypeDescriptorResolver();
final typeDescriptor = resolver.resolveFor(Person);
// now you can tranverse the descriptor to get all the constraints on this class and as transitively reachable.
Further Reading #
TODO #
See open issues