Constrain: Object Constraints for Dart

Build Status

Introduction

Provides a constraint based Validation library inspired by Java Bean Validation but leveraging the superior language capabilities of Dart. In particular:

Warning: Runtime Mirrors Used.

Features

  • Class level constraints for cross field validation
  • Property constraints (also via getter)
  • Constraint Inheritence
  • Cascading validation
  • Constraint Groups
  • Constraints can be specified with: Dart functions matchers from the matchers library

Usage

Define Constraints On Your Objects

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 Inheritence

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()]);

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.

TODO

See open issues

Libraries

constrain
constraint
constraint.core_constraints
constraint.matchers
constraint.metadata
constraint.validate