Domain Specific Language (DSL)

A Few (Type) Notes up Front

(Some may question leading with type caveats; however, in developing and using this library, “let the types be your guide” is great advice. If the types line up, the chances of the query being correct are 90%+. If you are new to this sort of development – welcome; you're going to enjoy your stay!)

F# 6's implicit conversion and computation expression (CE) custom operator overload work made this project possible; I had attempted to create this library before, but could not make the types line up without a lot of compiler-subverting casting (which defeats the purpose of a strongly-typed DSL). However, there are a few places where we will need to apply type parameters.

When functions return obj, the actual return will need to be cast or boxed

On the concepts page, we discussed ReqlFunction1; this is one place we will need to cast or box:

// ...
    filter (fun row -> row["age"].Lt 30 :> obj)
// or
    filter (fun row -> row["age"].Lt 30 |> box)
// ...

When a list contains obj, the items will need to be cast or boxed

While we know that a string list is an implementation of obj list, the compiler sees them as different types. In this case, boxing is a terse way to make the transformation.

// ...
    getAll (items |> List.map box)
// Interestingly, this works without casting ("item" is any type)
    getAll [ item ]
// ...

There are also overloads of the filter and update operators which takes a list of field names and values to be filtered (based on equality) or updated. Casting the first item in the list to obj will satisfy the compiler; there is an example below.

Implicit parameter types may not be what you expect

Given the following function…

let findUser userId = rethink<User> {
    withTable "User"
    get userId
    resultOption; withRetryOptionDefault

...you may assume that userId is a string (or Guid, or a single-case discriminated union (DU), or whatever you use for IDs) - but this function's type is 'a -> IConnection -> Task<User option>. You may find yourself adding type annotations to functions such as this, to ensure that they only accept expected types. This is one of the “seams” between the F# world and the larger .NET world; isolating these in a data retrieval module will let that module fit nicely into the rest of the application

Using the DSL

About Computation Expressions and Builders

If you started with the concepts page and came here, you've seen a few examples, but none of them have dealt with the structure itself. rethink<'T> { } is an instance of a “computation expression (CE) builder.” Within a CE builder, keywords like let!, do!, match!, etc. are operators that call particular methods in that builder; builders also support custom operators (which don't look as excited). Every operator execution in the builder modifies the state of the CE, and once the expression is given all of its parameters, it is executed.

This fits into our “building up a query” process nicely (and is not a new concept; F# has a query builder as part of the language). If you play with the builder a little, you can see that, at many places, an IConnection is nowhere to be found.

// Type: `Table`
let justATable = rethink<User> { withTable "User" }
// Type: `Get`
let justAGet = rethink<User> { withTable "User"; get "anId" }

Referencing Tables

RethinkDB organizes tables in databases, and commands are applied to a particular database by starting the query with r.Db([name]). The DSL hides this; instead, to reference a table in a particular database, include that database in the table name. For example, to reference the “User” table in the “SiteAdmin” database, start the query with withTable "SiteAdmin.User".

Retrieving an Object by Its ID

In most .NET data retrieval systems, retrieving an item by its primary key (no matter what they may call that concept) will result in one of two outcomes – a fully populated object, or null. RethinkDB's C# driver is no different, but F# is!

// Type: string -> IConnection -> Task<User option>
let findUserById (userId : string) = rethink<User> {
    withTable "User"
    get userId
    resultOption; withRetryOptionDefault

Notice that we did not specify User option as the type of the expression, but we used resultOption instead of result. This operator determines if we have received null, and translates that to an Option result instead. Note, also, the withRetryOptionDefault; while CEs in F# 6 support overloading, they do not support overloading based on the current state of the CE. result in a rethink<'T> expression is of type Task<'T>, while resultOption in a rethink<'T> expression is Task<'T option> (a different type). There are versions of result / resultOption / withRetry[x] / withRetryOption[x] for Async<'T> and synchronous 'T as well.

(Remember that, if User is a record type, it will need the [<CLIMutable>] attribute; the C# driver will not be able to instantiate it without a no-argument constructor.)

Retrieving a List of Objects

There are fewer caveats here; assuming there is an “isActive” index on the users, here is how we would retrieve them, ordered by last name then first name:

// Type: IConnection -> Task<User list>
let findActiveUsers = rethink<User list> {
    withTable "User"
    getAll [ true ] "isActive"
    orderBy (fun row -> r.Array (row["lastName"], row["firstName"]) :> obj)
    result; withRetryDefault

The list may be empty, but it will not be null. This also demonstrates the function overload of the orderBy operator; indexes can be created for multiple columns using a similar function.

Inserting Data

Objects can be inserted one at a time, or in multiples. Inserts also have an “on conflict” optional argument that specifies what to do if a document with the given ID already exists in the table. The below example attempts to insert a user, specifying to update the existing record if it already exists; it also will throw an exception if there is an error, and it swallows the result.

// Type: User -> IConnection -> Task<unit>
let addUser (user : User) = rethink {
    withTable "User"
    insert user [ OnConflict Update ]
    write; withRetryDefault; ignoreResult

Updating Data

In ReQL, update can be called on a single record or a collection of records, including an entire table. The concepts page showed a complex update-with-function query; we'll go much more basic here, updating our user's first and last name using's update's (string * obj) list override; for this, we will also return true if we updated anything, or false if not.

open RethinkDb.Driver.Model

// Type: User -> IConnection -> Task<bool>
let updateUser (user : User) conn = backgroundTask {
    let! result = rethink<Result> {
        withTable "User"
        get user.id
        update [ "lastName",  user.lastName :> obj
                 "firstName", user.firstName
        // or
        // update {| lastName = user.lastName; firstName = user.firstName |}
        write; withRetryDefault conn
    return result.Replaced > 0UL

This is the first time we have explicitly specified the conn parameter in the function definition. I tend to prefer the curried / implied / point-free style, but feel free to sprinkle the connection parameter throughout your code if you like.

Deleting Data

Like update, delete can empty a table; be sure to get, getAll, filter, or however you need to identify the documents to be deleted before calling delete. For our example, we'll delete all inactive users, and return how many users we deleted.

// Type: IConnection -> Task<int>
let deleteInactiveUsers conn = backgroundTask {
    let! result = rethink<Result> {
        withTable "User"
        getAll [ false ] "isActive"
        write; withRetryDefault conn
    return int result.Deleted

Other Operations

This same pattern works for manipulating databases, tables, and indexes. dbList, tableList, and indexList all return string list, and the [x]create and [x]drop commands work with write, Results, and the like. The same [database].[table] pattern applies to these functions as well.