Write a system that parses
EID
We have already worked on it to design a program validating EID
s using T.D.D
.
This time we will use a different approach.
The rules seen earlier still apply:
Positions | Meaning | Possible values |
---|---|---|
1 | Sex : 1 for Sloubi , 2 for Gagna , 3 for Catact |
1, 2 or 3 |
2, 3 | Last two digits of the year of birth (which gives the year to the nearest century) | From 00 to 99 |
4, 5, 6 | "Serial number": birth order | From 001 to 999 |
7, 8 | control key = complement to 97 of the number formed by the first 6 digits of the EID modulo 97 | From 01 to 97 |
Here is an example of such id:
We start by adding a property that checks round-tripping for EID
is working well: from(to(x)) = x
Your parser may look like this: String -> Either[ParsingError, EID]
for all (eid)
parseEID(eid.toString) == eid
Add vavr-test
to do so:
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.10.4</version>
</dependency>
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr-test</artifactId>
<version>0.10.4</version>
<scope>test</scope>
</dependency>
🔴 Specify the property
class EIDTests {
private Arbitrary<EID> validEID = null;
@Test
void roundTrip() {
Property.def("parseEID(eid.toString) == eid")
.forAll(validEID)
.suchThat(eid -> EID.parse(eid.toString()).contains(eid))
.check()
.assertIsSatisfied();
}
}
🟢Make it pass.
- Generate the
EID
class
- Then we generate the
parse
method:
public class EID {
public static Either<ParsingError, EID> parse(String potentialEID) {
return null;
}
}
- We now create a
ParsingError
record to be able to compile
public record ParsingError() {
}
- Then, we implement the
parse
method by hard-coding the result
public static Either<ParsingError, EID> parse(String potentialEID) {
return Either.right(new EID());
}
- We define how to generate valid EIDs in the test class
private final Arbitrary<EID> validEID = Arbitrary.of(new EID());
- We need to work on value equality for
EID
- We will use
lombok
for that purpose
- We will use
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
- We use the feature called
EqualsAndHashCode
@EqualsAndHashCode
public class EID {
public static Either<ParsingError, EID> parse(String potentialEID) {
return Either.right(new EID());
}
@Override
public String toString() {
return "";
}
}
Our property is now 🟢.
🔵 Anything to refactor?
Let's use this property to work on types
Let's create the
Sex
type
- We may use an
enum
for that- It is immutable by design
- We need to work on the
String
representation of it - Each data structure may contain its own parsing method
🔴 We add the Sex
generator in our property
sexGenerator
will randomly get a value from the Sex
value.
We design our EID
class to be instantiated with a Sex
value.
🟢 We generate the Sex
enum
public enum Sex { }
- We iterate on this type:
public enum Sex {
Sloubi('1'),
Gagna('2'),
Catact('3');
public final char value;
Sex(char value) {
this.value = value;
}
}
- We iterate on the type
EID
now to pass theSex
in constructor and use ot for the parsing part:
- We create the
parseSex
method:
// parsing method return an Either as well
public static Either<ParsingError, Sex> parseSex(char potentialSex) {
// we use vavr Pattern matching
return Match(potentialSex).of(
Case($('1'), right(Sloubi)),
Case($('2'), right(Gagna)),
Case($('3'), right(Catact)),
Case($(), left((new ParsingError("Not a valid sex"))))
);
}
// we add a reason in the ParsingError
public record ParsingError(String reason) {
}
- Then, we work on the
toString
methods
public enum Sex {
Sloubi('1'),
Gagna('2'),
Catact('3');
public final char value;
Sex(char value) {
this.value = value;
}
public static Either<ParsingError, Sex> parseSex(char potentialSex) {
// vavr Pattern matching
return Match(potentialSex).of(
Case($('1'), right(Sloubi)),
Case($('2'), right(Gagna)),
Case($('3'), right(Catact)),
Case($(), left((new ParsingError("Not a valid sex"))))
);
}
@Override
public String toString() {
return "" + value;
}
}
@EqualsAndHashCode
public class EID {
private final Sex sex;
public EID(Sex sex) {
this.sex = sex;
}
public static Either<ParsingError, EID> parse(String potentialEID) {
return Sex.parseSex(potentialEID.charAt(0))
.map(EID::new);
}
@Override
public String toString() {
return sex.toString();
}
}
Here is an example of values generated by the sexGenerator
: 1, 2, 1, 1, 2, 3, ...
Like for the Sex
type, we design the new type with its generator.
🔴 We start by creating a new valid year generator
private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt); // have a private constructor
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
private final Arbitrary<EID> validEID =
sexGenerator.map(EID::new)
// We need to use the yearGenerator here
.arbitrary();
🟢 To be able to use the yearGenerator
, we need to have a context to be able to map into it.
It is a mutable data structure that we enrich with the result of each generator. We create a Builder class for it:
// lombok annotations to simplify code
@With
@Getter
@AllArgsConstructor
public class EIDBuilder {
private final Sex sex;
private Year year;
public EIDBuilder(Sex sex) {
this.sex = sex;
}
}
- We now adapt the property to use this Builder
class EIDTests {
private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt); // have a private constructor
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
private Random random;
private final Arbitrary<EID> validEID =
sexGenerator.map(EIDBuilder::new)
.map(eidBuilder -> eidBuilder.withYear(yearGenerator.apply(random)))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear()))
.arbitrary();
@Test
void roundTrip() {
Property.def("parseEID(eid.toString) == eid")
.forAll(validEID)
.suchThat(eid -> EID.parse(eid.toString()).contains(eid))
.check()
.assertIsSatisfied();
}
}
- We have to adapt the
EID
class to handle theYear
in its construct- We will use the same Builder construct
@EqualsAndHashCode
public class EID {
private final Sex sex;
private final Year year;
public EID(Sex sex, Year year) {
this.sex = sex;
this.year = year;
}
public static Either<ParsingError, EID> parse(String potentialEID) {
return parseSex(potentialEID.charAt(0))
.map(EIDBuilder::new)
// we hard-code the year to be able to move on
.flatMap(eidBuilder -> right(eidBuilder.withYear(new Year(1))))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear()));
}
- we iterate on the code, and at the end of this stage it looks like this:
class EIDTests {
private final Random random = new Random();
private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt);
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
private final Arbitrary<EID> validEID =
sexGenerator.map(EIDBuilder::new)
.map(eidBuilder -> eidBuilder.withYear(yearGenerator.apply(random)))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear()))
.arbitrary();
@Test
void roundTrip() {
Property.def("parseEID(eid.toString) == eid")
.forAll(validEID)
.suchThat(eid -> EID.parse(eid.toString()).contains(eid))
.check()
.assertIsSatisfied();
}
}
@EqualsAndHashCode
public class EID {
private final Sex sex;
private final Year year;
public EID(Sex sex, Year year) {
this.sex = sex;
this.year = year;
}
public static Either<ParsingError, EID> parse(String potentialEID) {
return parseSex(potentialEID.charAt(0))
.map(EIDBuilder::new)
.flatMap(eidBuilder -> parseYear(potentialEID.substring(1, 3), eidBuilder))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear()));
}
private static Either<ParsingError, EIDBuilder> parseYear(String potentialYear, EIDBuilder builder) {
return Year.parseYear(potentialYear)
.map(builder::withYear);
}
@Override
public String toString() {
return sex.toString() + year;
}
}
@UtilityClass
public class StringExtensions {
// Extension methods are static methods with at least 1 parameter
// The first parameter type is the one we extend
public static Option<Integer> toInt(String potentialNumber) {
return isANumber(potentialNumber) // Use Option<Integer> -> equivalent to Optional since java 8
? some(Integer.parseInt(potentialNumber))
: none();
}
private static boolean isANumber(String str) {
return str != null && str.matches("[0-9.]+");
}
}
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class Year {
private final int value;
public Year(int value) {
this.value = value;
}
public static Year fromInt(int potentialYear) {
return parseYear(String.valueOf(potentialYear))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
public static Either<ParsingError, Year> parseYear(String potentialYear) {
return potentialYear
.toInt()
.filter(x -> x >= 0 && x <= 99)
.map(Year::new)
.toEither(new ParsingError("year should be between 0 and 99"));
}
@Override
public String toString() {
// years should be on 2 characters no matter what
return String.format("%02d", value);
}
}
We do exactly the same for the Serial Number
and end up with this code:
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class SerialNumber {
private final int value;
public SerialNumber(int value) {
this.value = value;
}
public static SerialNumber fromInt(int potentialSerialNumber) {
return parseSerialNumber(String.valueOf(potentialSerialNumber))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
public static Either<ParsingError, SerialNumber> parseSerialNumber(String potentialSerialNumber) {
return potentialSerialNumber
.toInt()
.filter(x -> x >= 1 && x <= 999)
.map(SerialNumber::new)
.toEither(new ParsingError("serial number should be between 1 and 999"));
}
@Override
public String toString() {
return String.format("%03d", value);
}
}
class EIDTests {
private final Random random = new Random();
private final Gen<Year> yearGenerator = Gen.choose(0, 99).map(Year::fromInt);
private final Gen<Sex> sexGenerator = Gen.choose(Sex.values());
private final Gen<SerialNumber> serialNumberGenerator = Gen.choose(1, 999).map(SerialNumber::fromInt);
private final Arbitrary<EID> validEID =
sexGenerator.map(EIDBuilder::new)
.map(eidBuilder -> eidBuilder.withYear(yearGenerator.apply(random)))
.map(eidBuilder -> eidBuilder.withSerialNumber(serialNumberGenerator.apply(random)))
.map(EIDBuilder::toEID)
.arbitrary();
@Test
void roundTrip() {
Property.def("parseEID(eid.toString) == eid")
.forAll(validEID)
.suchThat(eid -> EID.parse(eid.toString()).contains(eid))
.check()
.assertIsSatisfied();
}
}
🔵 Let's improve encapsulation by making constructors privates
- We force to instantiate objects only by using
parsing
methods
@AllArgsConstructor
@EqualsAndHashCode
public class EID {
private final Sex sex;
private final Year year;
private final SerialNumber serialNumber;
public static Either<ParsingError, EID> parse(String potentialEID) {
return parseSex(potentialEID.charAt(0))
.map(EIDBuilder::new)
.flatMap(eidBuilder -> parseYear(potentialEID.substring(1, 3), eidBuilder))
.flatMap(eidBuilder -> parseSerialNumber(potentialEID.substring(3, 6), eidBuilder))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear(), eidBuilder.getSerialNumber()));
}
private static Either<ParsingError, EIDBuilder> parseYear(String potentialYear, EIDBuilder builder) {
return Year.parseYear(potentialYear)
.map(builder::withYear);
}
private static Either<ParsingError, EIDBuilder> parseSerialNumber(String potentialSerialNumber, EIDBuilder eidBuilder) {
return SerialNumber.parseSerialNumber(potentialSerialNumber)
.map(eidBuilder::withSerialNumber);
}
@Override
public String toString() {
return sex.toString() + year + serialNumber;
}
}
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class SerialNumber {
private final int value;
private SerialNumber(int value) {
this.value = value;
}
public static SerialNumber fromInt(int potentialSerialNumber) {
return parseSerialNumber(String.valueOf(potentialSerialNumber))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
...
}
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class Year {
private final int value;
private Year(int value) {
this.value = value;
}
public static Year fromInt(int potentialYear) {
return parseYear(String.valueOf(potentialYear))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
public static Either<ParsingError, Year> parseYear(String potentialYear) {
return potentialYear
.toInt()
.filter(x -> x >= 0 && x <= 99)
.map(Year::new)
.toEither(new ParsingError("year should be between 0 and 99"));
}
...
}
Generated valid EID
looks like this now: 346951, 298179, 232373, 211404, 247064, ...
We add the validation of the key at the end of the parsing process:
public static Either<ParsingError, EID> parse(String potentialEID) {
return parseSex(potentialEID.charAt(0))
.map(EIDBuilder::new)
.flatMap(eidBuilder -> parseYear(potentialEID.substring(1, 3), eidBuilder))
.flatMap(eidBuilder -> parseSerialNumber(potentialEID.substring(3, 6), eidBuilder))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear(), eidBuilder.getSerialNumber()))
.flatMap(eid -> checkKey(potentialEID.substring(6), eid));
}
private static Either<ParsingError, EID> checkKey(String potentialKey, EID eid) {
return StringExtensions.toInt(potentialKey)
.filter(parsedKey -> Objects.equals(eid.key(), parsedKey))
.map(x -> eid)
.toEither(new ParsingError("invalid key"));
}
- we create the
key
generator and add it to thetoString
method
@Override
public String toString() {
return stringWithoutKey() + format("%02d", key());
}
private String stringWithoutKey() {
return sex.toString() + year + serialNumber;
}
private Integer key() {
return stringWithoutKey()
.toInt()
.map(x -> (97 - (x % 97)))
.get();
}
We now have a proper
EID
generator that allows us to check out round trip property
private final Arbitrary<EID> validEID =
sexGenerator.map(EIDBuilder::new)
.map(eidBuilder -> eidBuilder.withYear(yearGenerator.apply(random)))
.map(eidBuilder -> eidBuilder.withSerialNumber(serialNumberGenerator.apply(random)))
.map(EIDTests::toEID)
.arbitrary();
Our production code looks like this:
@AllArgsConstructor
@EqualsAndHashCode
@ExtensionMethod(StringExtensions.class)
public class EID {
private final Sex sex;
private final Year year;
private final SerialNumber serialNumber;
public static Either<ParsingError, EID> parse(String potentialEID) {
return parseSex(potentialEID.charAt(0))
.map(EIDBuilder::new)
.flatMap(eidBuilder -> parseYear(potentialEID.substring(1, 3), eidBuilder))
.flatMap(eidBuilder -> parseSerialNumber(potentialEID.substring(3, 6), eidBuilder))
.map(eidBuilder -> new EID(eidBuilder.getSex(), eidBuilder.getYear(), eidBuilder.getSerialNumber()))
.flatMap(eid -> checkKey(potentialEID.substring(6), eid));
}
private static Either<ParsingError, EIDBuilder> parseYear(String potentialYear, EIDBuilder builder) {
return Year.parseYear(potentialYear)
.map(builder::withYear);
}
private static Either<ParsingError, EIDBuilder> parseSerialNumber(String potentialSerialNumber, EIDBuilder builder) {
return SerialNumber.parseSerialNumber(potentialSerialNumber)
.map(builder::withSerialNumber);
}
private static Either<ParsingError, EID> checkKey(String potentialKey, EID eid) {
return StringExtensions.toInt(potentialKey)
.filter(parsedKey -> Objects.equals(eid.key(), parsedKey))
.map(x -> eid)
.toEither(new ParsingError("invalid key"));
}
@Override
public String toString() {
return stringWithoutKey() + format("%02d", key());
}
private String stringWithoutKey() {
return sex.toString() + year + serialNumber;
}
private Integer key() {
return stringWithoutKey()
.toInt()
.map(x -> (97 - (x % 97)))
.get();
}
}
public enum Sex {
Sloubi('1'),
Gagna('2'),
Catact('3');
public final char value;
Sex(char value) {
this.value = value;
}
public static Either<ParsingError, Sex> parseSex(char potentialSex) {
// vavr Pattern matching
return Match(potentialSex).of(
Case($('1'), right(Sloubi)),
Case($('2'), right(Gagna)),
Case($('3'), right(Catact)),
Case($(), left((new ParsingError("Not a valid sex"))))
);
}
@Override
public String toString() {
return "" + value;
}
}
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class Year {
private final int value;
private Year(int value) {
this.value = value;
}
public static Year fromInt(int potentialYear) {
return parseYear(String.valueOf(potentialYear))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
public static Either<ParsingError, Year> parseYear(String potentialYear) {
return potentialYear
.toInt()
.filter(x -> x >= 0 && x <= 99)
.map(Year::new)
.toEither(new ParsingError("year should be between 0 and 99"));
}
@Override
public String toString() {
return String.format("%02d", value);
}
}
@ExtensionMethod(StringExtensions.class)
@EqualsAndHashCode
public class SerialNumber {
private final int value;
private SerialNumber(int value) {
this.value = value;
}
public static SerialNumber fromInt(int potentialSerialNumber) {
return parseSerialNumber(String.valueOf(potentialSerialNumber))
.getOrElseThrow(err -> new IllegalArgumentException(err.reason()));
}
public static Either<ParsingError, SerialNumber> parseSerialNumber(String potentialSerialNumber) {
return potentialSerialNumber
.toInt()
.filter(x -> x >= 1 && x <= 999)
.map(SerialNumber::new)
.toEither(new ParsingError("serial number should be between 1 and 999"));
}
@Override
public String toString() {
return String.format("%03d", value);
}
}
For now:
- It is impossible to represent an
EID
in an invalid state- To construct one we need to inject
Sex
,Year
,SerialNumber
- Those structures can be instantiated only through
parsing
method
- To construct one we need to inject
- We have a semantic that expresses the concepts behind
EID
We now have a safe Domain Model
meaning if an EID
is successfully instantiated it is valid by design:
Is it enough to design good types like this?
- How does the
Type-Driven Development
approach used in this exercise help inensuring the correctness and robustness
of the EID parsing system? - How
could you apply the principles
of Type-Driven Development to other domains or projects you are working on? - In what other scenarios or systems might it be beneficial to
enforce encapsulation and immutability
through private constructors and parsing methods? - How might the concept of a
round-trip property
be useful in other areas of software development or data processing?