constrain 0.2.8

Constrain: Object Constraints for Dart

Build Status Pub Version

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 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):

  1. if the @NotNull constraint is present apply that
  2. 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 nums (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 constrains 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

0.2.8

  • added logging
  • increased lower bound of sdk version

0.2.7

  • allow description and group to be overridden for core constraints

0.2.6

  • increase upper bound on quiver package

0.2.5

  • fix issue with reflecting on generic classes

0.2.1

  • ported to option version >1.0.0
  • unittest replaced with test

0.2.0+2

  • increased upper bound of quiver dependency

0.2.0+1

  • increased upper bound of collections dependency

0.2.0

  • Ignore private members

Note: private members cannot be accessed (when in different libraries). This release is only backwards incompatible if you put constraints on private members

0.1.4+3

  • Fixed bug when running dart2js. Thanks to Robert Akerblom-Andersson for the contribution

0.1.4+2

  • Fixed bug in core constraints that stopped them validating without a group. Thanks to Robert Akerblom-Andersson for the contribution

0.1.4+1

  • Exported Pattern from constrain.dart

0.1.4

  • Added Min, Max and Pattern as core constraints

0.1.3

  • Added ConstrainedFunctionProxy as a reflective wrapper for validating functions

0.1.2

  • Added support for constraining functions and methods
    • both parameters and return values can be constrained and validated
    • methods inherit constraints from super classes, interfaces and mixins
    • parameters and return values will be deeply validated, including all constraints on type
    • for example
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';
}

0.1.1+1

  • Bug Fix. Ignore static fields and methods

0.1.1

  • JSON support. Constraint violations can be converted to JSON, for example to send to the client

0.1.0+1

  • Fixed bugs with mirrors

0.1.0

Implemented most features. Should be highly useable now. Added

  • Class based constraints
  • handling of collections
  • validator functions
  • groups
  • much more

0.0.1

  • Basic strawman

1. Depend on it

Add this to your package's pubspec.yaml file:

dependencies:
  constrain: "^0.2.8"

2. Install it

You can install packages from the command line:

$ pub get

Alternatively, your editor might support 'pub get'. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:

import 'package:constrain/constrain.dart';

Platforms

Server
Web

About

Object Constraints for Dart

Author

Email andersmholmgren@gmail.com Anders Holmgren

Homepage

bitbucket.org/andersmholmgren/constrain

Documentation

www.dartdocs.org/documentation/constrain/0.2.8/

Source code (hyperlinked)

www.crossdart.info/p/constrain/0.2.8/

Uploader

andersmholmgren@gmail.com

License

BSD

Published

Apr 10, 2016

Share