Functions

Welcome back, fellow space developer! We are continuing our FQL space journey in this five-part tutorial.

In this fourth part, we’re going to take a look at how to create custom functions that run in Fauna.

Introduction

We’ve seen in previous sections of this tutorial that FQL is much closer to a functional programming language than other querying languages, such as SQL or GraphQL.

Much like in any programming language, FQL also allows you to craft complex behaviors by creating functions. We’ve already seen multiple examples of anonymous functions using the Lambda function in your FQL queries. It’s also possible to encapsulate these custom behaviors into the database by creating user-defined functions (UDFs) that can be invoked from your queries, or even from other UDFs.

UDFs are somewhat similar to stored procedures found in other databases. Of course, the implementation and capabilities of UDFs in Fauna are very different because of the unique nature of FQL. For example, it’s common to use stored procedures to group SQL queries, or to reduce the results sent to the database clients. You wouldn’t really need to use an UDF in those situations since these can be accomplished in a regular FQL query executed from the application.

Why use UDFs?

I mean, other than because UDFs are super cool, there are a couple of reasons why you’d want to move logic into the database.

Avoid code duplication

If you have multiple clients (web, API, mobile, desktop, microservices) written in multiple programming languages, you probably want to avoid maintaining different versions of the same business logic. By moving some of that logic to the database, you can avoid code duplication, and thus all of the effort and confusion that code duplication usually causes.

Abstraction and decoupling of processes

As applications grow, you often need to abstract processes and their underlying data. This can be easily accomplished with UDFs. As an added benefit, the process is now decoupled from the rest of your logic. An outdated version of your application (e.g. web or mobile) could keep interacting with Fauna without knowing that an UDF has, in fact, been updated multiple times.

Consistency guarantees

By having a single version of some business logic running as close to the database as possible, you will ensure your data is consistent. FQL is very expressive which will make this task easier compared to traditional stored procedures written in SQL.

Security

UDFs allow you to encapsulate logic that consists of multiple reads and/or writes. This allows you to write security rules that provide access to this logic as one unit. A user can either execute the function or not, but not just part of the logic. This comes in handy when querying Fauna from a less secure environment such as a frontend application or a mobile client.

Your first function

We’ll start with a very simple function, just to see the basics.

Here’s the latest version of our spaceship document from the previous parts of the tutorial:

{
  "ref": Ref(Collection("Spaceships"), "266356873589948946"),
  "ts": 1592255653240000,
  "data": {
    "name": "Voyager",
    "pilot": Ref(Collection("Pilots"), "266350546751848978"),
    "type": "Rocket",
    "fuelType": "Plasma",
    "actualFuelTons": 7,
    "maxFuelTons": 10,
    "maxCargoTons": 25,
    "maxPassengers": 5,
    "maxRangeLightyears": 10,
    "position": {
      "x": 2234,
      "y": 3453,
      "z": 9805
    },
    "code": "VOYAGER",
    "colors": [
      "RED",
      "YELLOW"
    ]
  }
}

With this in mind, let’s create a function that receives a ship ID and returns an object:

CreateFunction({
  name: "GetSpaceship",
  body: Query(
    Lambda("shipId",
      Let(
        {
          shipDoc: Get(Ref(Collection("Spaceships"),Var("shipId")))
        },
        {
          id: Select(["ref","id"], Var("shipDoc")),
          name: Select(["data","name"], Var("shipDoc"))
        }
      )
  ))
})

If you’ve been following along with this tutorial, there shouldn’t be much of a mystery. We’ve previously covered the Lambda, Let, Select, and Var functions in detail.

As expected, the CreateFunction function creates a new function with the specified name and body.

We need to use the Query function because we want to define a Lambda that will be executed later, not actually execute the Lambda when creating the function.

This how we’d call the function:

Call(Function("GetSpaceship"), "266356873589948946")
  // NOTE: be sure to use the correct document ID for your
  // GetSpaceship function here
{
  id: "266356873589948946",
  name: "Voyager"
}

Of course, you could also use this function anywhere in your FQL queries.

Here’s an example demonstrating how you could use it in combination with a list of results:

Map(
  Paginate(Documents(Collection("Spaceships"))),
  Lambda(
    "shipRef",
    Call(Function("GetSpaceship"), Select(["id"], Var("shipRef")))
  )
)
{
  data: [
    {
      id: "266356873589948946",
      name: "Voyager"
    },
    {
      id: "266619264914424339",
      name: "Explorer IV"
    },
    {
      id: "267096263925694994",
      name: "Destroyer"
    },
    // etc...
  ]
}
The Documents function allows you to retrieve a list of references from all of the documents in a collection, without having to set up an index.

Creating transactions with Do

Unlike many document-oriented databases, Fauna provides ACID transactions. This essentially means that it guarantees the validity of a transaction, no matter what: power failure, server crash, gremlins, alien attack…​ Okay, maybe not in the case of an alien attack, but you get the idea.

Actually, transactions in Fauna are ACIDD (not an actual technical term) as they are also globally distributed.

The Do function

The Do function executes a list of FQL expressions sequentially to form a transaction. Changes committed to the database in each of the expressions are immediately available to the following expressions.

To verify this, let’s create a new collection first:

CreateCollection({name: "LaserColors"})

And then:

Do(
  // first create a document
  Create(Ref(Collection("LaserColors"), "123456"), {
    data: {
      name: "Pink"
    }
  }),
  // then update that same document
  Update(Ref(Collection("LaserColors"), "123456"), {
    data: {
      hex: "#ff5c9e"
    }
  })
)
{
  ref: Ref(Collection("LaserColors"), "123456"),
  ts: 1592364971590000,
  data: {
    name: "Pink",
    hex: "#ff5c9e"
  }
}

As you can see, the document created in the first expression is immediately available.

The Do function returns whatever the last command in the sequence returned, so we get the full document with the updated data.

Aborting functions and transactions

Obviously, whenever something fails, Fauna lets you know about it. You can also define when and how you want a transaction or a function to fail. This is done using the Abort function.

Let’s create a simple example:

Do(
  "Step 1",
  "Step 2",
  Abort("You shall not pass!"),
  "Step 3"
)
error: transaction aborted
You shall not pass!
position: ["do",2]

Now, if you were executing this (rather useless) query in your application, you’d be getting an exception.

In JavaScript, for example:

try {
  const result = await client.query(
    q.Do(
      "Step 1",
      "Step 2",
      q.Abort("You shall not pass!"),
      "Step 3"
    )
  );
} catch (error) {
  // do something with the error
}

As expected, this applies to UDFs too:

CreateFunction({
  name: "StopIt",
  body: Query(
    Lambda("bool",
      If(
        Var("bool"),
        Abort("Stopped!"),
        "Not stopped!"
      )
    )
  )
})

If we pass true to the UDF, the execution of the function is aborted and an exception is raised:

Call(Function("StopIt"), true)
Error: [
  {
    "position": [],
    "code": "call error",
    "description": "Calling the function resulted in an error.",
    "cause": [
      {
        "position": [
          "expr",
          "then"
        ],
        "code": "transaction aborted",
        "description": "Stopped!"
      }
    ]
  }
]

Warping across the galaxy

Let’s go through a more complex example to give you a better idea about how these concepts work together. We’re going to create a WarpToPlanet function to propel our ships, to infinity and beyond.

Step 1: Check if we have enough fuel

I have to admit that my celestial navigation math is a bit rusty, especially if wormholes are involved, so we’re just going to assume that a spaceship needs 5 tons of fuel to warp anywhere in the galaxy.

To know how much fuel a ship has left, we can use this property:

"actualFuelTons": 7

Let’s make a function that returns true if there is enough fuel to create a wormhole and travel through it:

CreateFunction({
  name: "HasEnoughFuelToWarp",
  body: Query(
    Lambda("shipRef",
      Let(
        {
          shipDoc: Get(Var("shipRef")),
          actualFuelTons: Select(["data","actualFuelTons"], Var("shipDoc"))
        },
        GTE(Var("actualFuelTons"), 5)
      )
  ))
})

This is a very straightforward Lambda:

  • First, we prepare the Let bindings that we need. In this case, we get the document and extract the actualFuelTons property from the document.

  • Second, we check that actualFuelTons is greater than or equal to 5.

To test it out, we only need to use a reference to our Voyager ship (which we know has 7 tons of fuel available):

Call(
  Function("HasEnoughFuelToWarp"),
  Ref(Collection("Spaceships"), "266356873589948946")
)
true

Step 2: Open the wormhole and warp

Now, let’s create a simple function to enable light speed on the ship by simply updating a bit of data on its document:

CreateFunction({
  name: "OpenWormholeAndWarp",
  body: Query(
    Lambda("shipRef",
      Update(
        Var("shipRef"),
        Let({
          shipDoc: Get(Var("shipRef")),
          actualFuelTons: Select(["data","actualFuelTons"], Var("shipDoc"))
        }, {
          data:{
            actualFuelTons: Subtract(Var("actualFuelTons"), 5)
          }
        })
      )
    )
  )
})

Easy, right? We’re just subtracting 5 from actualFuelTons using the Subtract function.

Let’s test this out on our Destroyer ship which currently has 11 tons of fuel:

{
  "ref": Ref(Collection("Spaceships"), "267096263925694994"),
  "ts": 1592513359750000,
  "data": {
    "name": "Destroyer",
    "actualFuelTons": 11
    // etc...
  }
}

To invoke the function, we just need a reference to the document of the ship:

Call(
  Function("OpenWormholeAndWarp"),
  Ref(Collection("Spaceships"), "267096263925694994")
)
{
  ref: Ref(Collection("Spaceships"), "267096263925694994"),
  ts: 1592513503470000,
  data: {
    name: "Destroyer",
    actualFuelTons: 6,
    // etc...
}

As expected, Destroyer now has 6 tons of fuel left.

Step 3: Write to the ship’s log

The admiral wouldn’t be too happy if we didn’t keep a log of what’s going on with our ships. We are going to create a function that creates a new log entry whenever a ship warps to a new planet.

First, we need a collection to store our logs:

CreateCollection({name: "ShipLogs"})

And a function to create a new document in that collection:

CreateFunction({
  name: "CreateLogEntry",
  body: Query(
    Lambda(["shipRef","planetRef","status"],
      Create(
        Collection("ShipLogs"),
        {
          data: {
            shipRef: Var("shipRef"),
            planetRef: Var("planetRef"),
            status: Var("status")
          }
        }
      )
    )
  )
})

Step 4: All together now

For our last step, let’s see how to combine all these functions to create the super ultimate WarpToPlanet function:

CreateFunction({
  name: "WarpToPlanet",
  body: Query(
    Lambda(["shipRef","planetRef"],
      If(
        Call(Function("HasEnoughFuelToWarp"), Var("shipRef")),
        Do(
          Call(Function("OpenWormholeAndWarp"), Var("shipRef")),
          Call(
            Function("CreateLogEntry"),
            [Var("shipRef"), Var("planetRef"), "WARPED_TO_PLANET"]
          ),
          Let(
            {
              planetDoc: Get(Var("planetRef")),
              planetName: Select(["data","name"],Var("planetDoc")),
              shipDoc: Get(Var("shipRef")),
              shipName: Select(["data","name"],Var("shipDoc")),
            },
            Concat(["Welcome ",Var("shipName")," to ",Var("planetName")])
          )
        ),
       Abort("Not enough fuel!")
      )
    )
  )
})

Let’s break this down:

  • The If function evaluates the result of the HasEnoughFuelToWarp function. If it returns true, it executes the Do function. If it returns false, it executes the Abort function.

  • The Do function executes a transaction, like we saw earlier.

  • The last expression of the transaction produces a welcome message when a ship arrives on a planet.

Finally, let’s test all of our hard work!

Let’s warp with Voyager to planet Vulkan:

Call(
  Function("WarpToPlanet"),
  [
    Ref(Collection("Spaceships"), "266356873589948946"),
    Ref(Collection("Planets"), "268706982578356743"),
  ]
)
Welcome Voyager to Vulkan

Bravo!

If we check our ship document, we can see the it only has 2 tons of fuel left:

{
  "ref": Ref(Collection("Spaceships"), "266356873589948946"),
  "ts": 1592518256580000,
  "data": {
    "name": "Voyager",
    "actualFuelTons": 2,
    // etc...
}

And there’s also a new document in the ShipLogs collection:

{
  "ref": Ref(Collection("ShipLogs"), "268707463485719047"),
  "ts": 1592518256580000,
  "data": {
    "shipRef": Ref(Collection("Spaceships"), "266356873589948946"),
    "planetRef": Ref(Collection("Planets"), "268706982578356743"),
    "status": "WARPED_TO_PLANET"
  }
}

Honestly, there’s not much to do on Vulkan and these Vulkans are quite boring.

Let’s go back to Earth:

Call(
  Function("WarpToPlanet"),
  [
    Ref(Collection("Spaceships"), "266356873589948946"),
    Ref(Collection("Planets"), "267081091831038483"),
  ]
)
Error: [
  {
    "position": [],
    "code": "call error",
    "description": "Calling the function resulted in an error.",
    "cause": [
      {
        "position": [
          "expr",
          "else"
        ],
        "code": "transaction aborted",
        "description": "Not enough fuel!"
      }
    ]
  }
]

Oh no! We don’t have enough fuel to warp to Earth! Well, at least our function works as expected.

Obviously, the logic of this example is extremely simple, but we’ve covered a number of important points related to UDFs.

First, to operate the WarpToPlanet function, our application doesn’t need to know anything about the fuel logic, or even about the structure of the related documents. It only needs to pass two references. When (not if) the implementation of the function changes, we won’t need to update any code in our application(s).

And second, to call the WarpToPlanet function our application needs to know about spaceships and planets, but it doesn’t need to know about the ShipLogs collection.

Data aggregation

Let’s see how to use UDFs to aggregate data from multiple documents.

In first section of this tutorial, the admiral tasked us with feeding his holo-map with the position of all spaceships. This worked fine, but now he’d like to be able to go backwards and forwards in time to better understand the movement of the ships.

Obviously, we need to store the position somehow, but the admiral won’t tolerate a slow holo-map, so it needs to be as fast as possible.

We saw in the tutorial section that reading a single document gives us the best performance. We also saw that this pattern presents some dangers, but since the recorded data never changes, and the number of ships is not very large, this is the perfect scenario for storing all of the data in a single document.

First, let’s create a new collection:

CreateCollection({name: "ShipPositionsHistory"})

And this would be the function:

CreateFunction({
  name: "RecordPositions",
  body: Query(
    Lambda("bool",
      Do(
        Create(
          Collection("ShipPositionsHistory"),
          Let({
            shipsDocuments: Map(
              Paginate(
                Documents(Collection("Spaceships"))),
                Lambda("shipRef", Get(Var("shipRef"))
              )
            ),
            positions: Map(
              Var("shipsDocuments"),
              Lambda("shipDocument",
                {
                  ref: Select(["ref"], Var("shipDocument")),
                  name: Select(["data","name"], Var("shipDocument")),
                  position: Select(
                    ["data","position"],
                    Var("shipDocument")
                  )
                }
              )
            )
          },{
            data: {
              timestamp: Now(),
              positions: Var("positions")
            }
          })
        ),
        "Positions recorded"
      )
    )
  )
})

Again, these are the same FQL commands that we’ve seen multiple times.

This function would first get an array of Spaceships documents (denoted with the variable shipsDocuments in the Let function). Then, it creates a new document within the ShipPositionsHistory collection with an array of ships and their positions.

We are performing this inside a transaction with a simple string as the last step. Otherwise, we’d be returning the complete result of the Create function to our application, which might slow things down a bit.

Now, we only need to trigger the function periodically:

Call(Function("RecordPositions"))
Positions recorded

If we check our ShipPositionsHistory collection, here is our first document:

{
  ref: Ref(Collection("ShipPositionsHistory"), "268613645148094983"),
  ts: 1592428784478000,
  data: {
    timestamp: Time("2020-06-17T21:19:44.239194Z"),
    positions: {
      data: [
        {
          ref: Ref(Collection("Spaceships"), "266356873589948946"),
          name: "Voyager",
          position: {
            x: 2234,
            y: 3453,
            z: 9805
          }
        },
        {
          ref: Ref(Collection("Spaceships"), "266619264914424339"),
          name: "Explorer IV",
          position: {
            x: 1134,
            y: 9453,
            z: 3205
          }
        },
        // etc...
      ]
    }
  }
}

Conclusion

So that’s it for today. Hopefully you learned something valuable!

In part 5 of the tutorial, we wrap it all up by going deeper into roles and permissions in Fauna.

Is this article helpful? 

Tell Fauna how the article can be improved:
Visit Fauna's forums or email docs@fauna.com

Thank you for your feedback!