Skip to content

Commit

Permalink
Change record builder example to new syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
smores56 committed Sep 21, 2024
1 parent 5d4898e commit 69af6f8
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 106 deletions.
49 changes: 49 additions & 0 deletions examples/RecordBuilder/DateParser.roc
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
module [
ParserGroup,
ParserErr,
parseWith,
chainParsers,
buildSegmentParser,
]

ParserErr : [InvalidNumStr, OutOfSegments]

ParserGroup a := List Str -> Result (a, List Str) ParserErr

parseWith : (Str -> Result a ParserErr) -> ParserGroup a
parseWith = \parser ->
@ParserGroup \segments ->
when segments is
[] -> Err OutOfSegments
[first, .. as rest] ->
parsed = parser? first
Ok (parsed, rest)

chainParsers : ParserGroup a, ParserGroup b, (a, b -> c) -> ParserGroup c
chainParsers = \@ParserGroup first, @ParserGroup second, combiner ->
@ParserGroup \segments ->
(a, afterFirst) = first? segments
(b, afterSecond) = second? afterFirst

Ok (combiner a b, afterSecond)

buildSegmentParser : ParserGroup a -> (Str -> Result a ParserErr)
buildSegmentParser = \@ParserGroup parserGroup ->
\text ->
segments = Str.split text "-"
(date, _remaining) = parserGroup? segments

Ok date

expect
dateParser =
{ chainParsers <-
month: parseWith Ok,
day: parseWith Str.toU64,
year: parseWith Str.toU64,
}
|> buildSegmentParser

date = dateParser "Mar-10-2015"

date == Ok { month: "Mar", day: 10, year: 2015 }
34 changes: 0 additions & 34 deletions examples/RecordBuilder/IDCounter.roc

This file was deleted.

138 changes: 66 additions & 72 deletions examples/RecordBuilder/README.md
Original file line number Diff line number Diff line change
@@ -1,128 +1,122 @@

# Record Builder

This example demonstrates the Record Builder pattern in Roc. This pattern leverages the functional programming concept of [applicative functors](https://lucamug.medium.com/functors-applicatives-and-monads-in-pictures-784c2b5786f7), to provide a flexible method for constructing complex types.
Record builders are a syntax sugar for sequencing actions and collecting the intermediate results as fields in a record. All you need to build a record is a `map2`-style function that takes two values of the same type and combines them using a provided combiner fnuction. There are many convenient APIs we can build with this simple syntax.

## The Basics

Let's assume we want to develop a module that supplies a type-safe yet versatile method for users to obtain user IDs that are guaranteed to be sequential. The record builder pattern can be helpful here.
Let's assume we want to develop a module that parses any text with segments delimited by dashes. The record builder pattern can help us here to parse each segment in their own way, and short circuit on the first failure.

> Note: it is possible to achieve this sequential ID mechanism with simpler code but more "real world" record builder examples may be too complex to easily understand the mechanism. If you want to contribute, we would love to have a real world record builder example that is well explained.
> Note: it is possible to parse dash-delimited text in a specific format with simpler code. However, generic APIs built with record builders can be much simpler and readable than any such specific implementation.
### Defining Types
## Defining Types

```roc
ID : U32
ParserGroup a := List Str -> Result (a, List Str) ParserErr
```
We define a type alias `ID` which we set to a 32 bit unsigned integer.
A type alias improves readability. Otherwise we'd have a bunch of functions that work with `U32`
where you'd need to look at the context to figure out what the type actually represents.
If you ever want to switch your ID's to use a `U64`, you would only need to change one line!

We want to protect our ID counter, other modules should not be able to alter it, otherwise two users may end up with the same ID!
For this protection we use an [opaque type](https://www.roc-lang.org/tutorial#opaque-types) that will also accumulate our state:
```roc
IDCount state := (ID, state)
```
This type takes a type variable `state`. In our case `state` will be either a record or a function that produces a record.
`:=` is used to define an opaque type.
`(ID, state)` is a [tuple](https://www.roc-lang.org/examples/Tuples/README.html) of an `ID`(=`U32`) and our `state` type variable.
We start by defining a `ParserGroup`, which is a [parser combinator](https://en.wikipedia.org/wiki/Parser_combinator) that takes in a list of string segments to parse, and returns parsed data as well as the remaining, unparsed segments. All of the parsers that render to our builder's fields are `ParserGroup` values, and get chained together into one big `ParserGroup`.

### End Goal
You'll notice that record builders all tend to deal with a single wrapping type, as we can only combine said values with our `map2`-style function if all combined values are the same type. On the plus side, this allows record builders to work with a single value, two fields, or ten, allowing for great composability.

## End Goal

It's useful to visualize our desired result. The record builder pattern we're aiming for looks like:

```roc
expect
{ aliceID, bobID, trudyID } =
initIDCount {
aliceID: <- incID,
bobID: <- incID,
trudyID: <- incID,
} |> extractState
aliceID == 1 && bobID == 2 && trudyID == 3
dateParser : ParserGroup Date
dateParser =
{ chainParsers <-
month: parseWith Ok,
day: parseWith Str.toU64,
year: parseWith Str.toU64,
}
|> buildSegmentParser
date = dateParser "Mar-10-2015"
date == Ok { month: "Mar", day: 10, year: 2015 }
```

This generates a record with fields `aliceID`, `bobID`, and `trudyID`, all possessing sequential IDs (= `U32`). Note the slight deviation from the conventional record syntax, using a `: <-` instead of `:`, this is the Record Builder syntax.
This generates a record with fields `month`, `day`, and `year`, all possessing specific parts of the provided date. Note the slight deviation from the conventional record syntax, with the `chainParsers <-` at the top, which is our `map2`-style function.

### Under the Hood
## Under the Hood

The record builder pattern is syntax sugar which converts the preceding into:

```roc
expect
{ aliceID, bobID, trudyID } =
initIDCount (\aID -> \bID -> \cID -> { aliceID: aID, bobID: bID, trudyID: cID })
|> incID
|> incID
|> incID
|> extractState
aliceID == 1 && bobID == 2 && trudyID == 3
dateParser : ParserGroup Date
dateParser =
chainParsers
(parseWith Ok)
(chainParsers
(parseWith Str.toU64)
(parseWith Str.toU64)
(\day, year -> (day, year))
)
(\month, (day, year) -> { month, day, year })
```
To make this work, we will define the functions `initIDCount`, `incID`, and `extractState`.

## Initial Value

Let's start with `initIDCount`:
In short, we chain together all pairs of field values with the `map2` combining function, pairing them into tuples until the final grouping of values is structured as a record.

```roc
initIDCount : state -> IDCount state
initIDCount = \advanceF ->
@IDCount (0, advanceF)
```
`initIDCount` initiates the `IDCount state` value with the `ID` (= `U32`) set to `0` and stores the advanceF function, which is wrapped by `@IDCount` into our opaque type.
To make the above possible, we'll need to define the `parseWith` function that turns a parser into a `ParserGroup`, and the `chainParsers` function that acts as our `map2` combining function.

## Applicative
## Defining Our Functions

`incID` is defined as:
Let's start with `parseWith`:

```roc
incID : IDCount (ID -> state) -> IDCount state
incID = \@IDCount (currID, advanceF) ->
nextID = currID + 1
@IDCount (nextID, advanceF nextID)
parseWith : (Str -> Result a ParserErr) -> ParserGroup a
parseWith = \parser ->
@ParserGroup \segments ->
when segments is
[] -> Err OutOfSegments
[first, .. as rest] ->
parsed = parser? first
Ok (parsed, rest)
```

`incID` unwraps the argument `@IDCount (currID, advanceF)`; calculates a new state value `nextID = currID + 1`; applies this new value to the provided advanceF function `@IDCount (nextID, advanceF nextID)`; returning a new `IDCount` value.

If you haven't seen this pattern before, it can be difficult to grasp. Let's break it down and follow the type of `state` at each step in our builder pattern.
This parses the first segment available, and returns the parsed data along with all remaining segments not yet parsed. We could already use this to parse a single-segment string without even using a record builder, but that wouldn't be very useful. Let's see how our `chainParsers` function will manage combining two `ParserGroup`s in serial:

```roc
initIDCount (\aID -> \bID -> \cID -> { aliceID: aID, bobID: bID, trudyID: cID }) # IDCount (ID -> ID -> ID -> { foo: ID, bar: ID, trudyID: ID })
|> incID # IDCount (ID -> ID -> { aliceID: ID, bobID: ID, trudyID: ID })
|> incID # IDCount (ID -> { aliceID: ID, bobID: ID, trudyID: ID })
|> incID # IDCount ({ aliceID: ID, bobID: ID, trudyID: ID })
|> extractState # { aliceID: ID, bobID: ID, trudyID: ID }
```
chainParsers : ParserGroup a, ParserGroup b, (a, b -> c) -> ParserGroup c
chainParsers = \@ParserGroup first, @ParserGroup second, combiner ->
@ParserGroup \segments ->
(a, afterFirst) = first? segments
(b, afterSecond) = second? afterFirst
Above you can see the type of `state` is advanced at each step by applying an `ID` value to the function. This is also known as an applicative pipeline, and can be a flexible way to build up complex types.
Ok (combiner a b, afterSecond)
```

## Unwrap
Just parse the two groups, and then combine their results? That was easy!

Finally, `extractState` unwraps the `IDCount` value and returns our record.
Finally, we'll need to wrap up our parsers into one that breaks a string into segments and then applies our parsers on said segments. We can call it `buildSegmentParser`:

```roc
extractState : IDCount state -> state
extractState = \@IDCount (_, finalState) -> finalState
buildSegmentParser : ParserGroup a -> (Str -> Result a ParserErr)
buildSegmentParser = \@ParserGroup parserGroup ->
\text ->
segments = Str.split text "-"
(date, _remaining) = parserGroup? segments
Ok date
```

In our case, we don't need the `ID` count anymore and just return the record we have built.
Now we're ready to use our parser as much as we want on any input text!

## Full Code

```roc
file:IDCounter.roc
file:DateParser.roc
```

## Output

Code for the above example is available in `IDCounter.roc` which you can run like this:
Code for the above example is available in `DateParser.roc` which you can run like this:

```sh
% roc test IDCounter.roc

0 failed and 1 passed in 698 ms.
0 failed and 1 passed in 190 ms.
```

0 comments on commit 69af6f8

Please sign in to comment.