Faunadb: The Modern Database for Developers

Kinanee Samson - Aug 14 '23 - - Dev Community

Faunadb is a multi-paradigm relational database with a built-in document data model. Faunadb gives developers the benefit of working with relational and document databases inside a cloud API that can be accessed from almost any platform. Faunadb has its query language called FQL (Fauna Query Language) an expression-oriented query language that enables complex, precise manipulation and retrieval of documents. All FQL queries are transactional ensuring data integrity. FaunaDB is Multi-regional and scales up horizontally using active-active clusters while incorporating most modern application security features.

In today's post, we will take a glance at the following while we uncover what Faunadb is;

  • How it works
  • Create an account & database
  • Setup a local development
  • Running FQL queries

How Faunadb works?

Documents

Faunadb stores your data as JSON-like objects. This approach is similar to what you'd observe in MongoDB, Firestore, and other document-oriented databases. All documents have a timestamp and a unique Ref. A Document Ref is comprised of the name of the collection and a number that is inside a string. All documents are immutable by default and updates create new documents each with its timestamp allowing for time-lapse queries.

Collection

Collections just serve as a way to group related documents and they are similar to tables in SQL. Multiple schemaless documents can be stored inside a collection and when a collection is deleted, associated documents become inaccessible and get deleted asynchronously.

Reference

A reference is a compound value that serves as a unique ID for any document in a collection, A reference is comprised of a reference to the collection containing the document and a document ID which is a number. two documents in a database can share the same reference.

Indexes

Indexes allow for organization and easy retrieval of documents by attributes of the data. An index is just a lookup table that speeds up finding documents, You can query an index to find the documents inside as opposed to reading all documents inside a collection just to select a few.

Functions

The FQL provides built-in functions which can be used to run queries. We can compose multiple built-in functions to create User-defined functions (or UDFs) UDFs are used to combine frequently used functions into queries that can be stored and executed repeatedly.

Create an account

To create a Faunadb account you need to head over to the Fauna website. Click on Start for free then select Github as your auth provider, and authorize Faunadb from GitHub. If everything went well, your account should successfully be created for you and you should be redirected to your Faunadb dashboard.

Setup a database

Now you've successfully created an account, on your dashboard click on the create database button, next we need to give our database a name, select a region for your data, and select use demo-data to ensure that the database is created with some demo data for us. Go ahead and click on create and you should be redirected to the overview page for your database if everything went according to plan.

Project Setup

Open up an empty folder in your favorite editor and let's set up a NodeJS app with Faunadb. Run the following command to spin up a new node project

$ npm init --y
Enter fullscreen mode Exit fullscreen mode

Next, we need to install some basic extensions to help get the server started,
first, let's install Express to help create a basic server for us.

$ npm i express
Enter fullscreen mode Exit fullscreen mode

run the following command to install the Faunadb and dotenv extension.

$ npm i faunadb dotenv
Enter fullscreen mode Exit fullscreen mode

Now, we've successfully installed our app dependencies, go back to your Faunadb dashboard, and ensure you click on the newly created database. Click on the security tab and click on Create a new database key. Give the key a name and ensure that you set the role for the key to be server and proceed. If everything went according to plan you should see an API key generated for you, copy this key and store it in a dotenv file in your project directory.

Running FQL queries

Create a new javascript file with the name app.js. We will write and export the functions for interacting with Faunadb inside this module. The first thing we need to do is to create a collection so let's write the logic for that.

const { query, Client } = require('faunadb');
require('dotenv').config();

const client = new Client({
  secret: process.env.SECRETE_KEY,
});

//Create a collection
const createCollection = async (name) => {
  try {
    const collection = await client.query(
      query.CreateCollection({ name })
    );
    return [null, collection]
  } catch (error) {
    return [error, collection]
  }
}

module.exports = {
  createCollection,
}
Enter fullscreen mode Exit fullscreen mode

We imported the query object alongside the Client. The query object houses the methods we need for interacting with our Faunadb database. We then create a new instance of the Client and we need to authenticate our client using the secret key we created earlier. Now we have a client let's go ahead and start making queries. We have a function createCollection. This function accepts a single parameter name which will serve as the name of the collection we are creating. There's a try/catch block to wrap up the process of creating the collection and handling our errors gracefully. This will be our pattern for other functions we will create. Inside the try block we call await client.query and this is how you make a query using FQL.

This function accepts a query as its argument. To create a collection, we call query.CreateCollection and pass in an object with a name property as an argument to it. We store the result of the query which is the newly created collection inside a variable collection. We return an array with null as the first value and the collection as the second value. This will also be a pattern in this codebase, all helper functions must return an array object with the first value representing an error object and the second value being the return value from the function.

Inside the catch block we return an array too, matching the pattern for returning an array from helper functions, this time the first value is the error object thrown while the second argument is null. Then we use module.export to export the createCollection function. Now let's create a basic express server inside a new file index.js

// index.js

const express = require('express');
const { createCollection } = require('./app');

const app = express();
app.use(express.json());

app.post('/collection', async (req, res) => {
  const {name} = req.body;
  try {
    const [error, collection] = await createCollection(name);
    if (error) throw error
    if (collection) res.status(200).json(collection)
  } catch(error) {
    return res.status(400).json(error)
  }
})

app.listen(3000, () => console.log('App running on PORT 3000'));

Enter fullscreen mode Exit fullscreen mode

Inside the index.js file we import express then we import the createCollection function from app.js. We create a new express app and use the JSON parser on express as an app middleware. Next, we register a route and a handler for a post request. The route is /collection and inside the handler function, we destructure the name from the request body, and inside the try block we call await createCollection and pass in the name. We destructure the error and the collection. If there is an error then we just throw the error else if the collection is created successfully we send it back with the response.

In the catch block we just set the status of the response to 400 then we send the error back with the response then we listen on port 3000 for a connection. If you run this app with nodemon;

$ nodemon i index.js
Enter fullscreen mode Exit fullscreen mode

If everything went well you should see the message App running on PORT 3000 in your console. Let's make a query to the endpoint to test it out.

POST http://localhost:3000/collection
Content-Type: application/json

{
    "name": "todos"
}
Enter fullscreen mode Exit fullscreen mode

If you make this request you should get a response that looks like this;

{
  "ref": {
    "@ref": {
      "id": "todo",
      "collection": {
        "@ref": {
          "id": "collections"
        }
      }
    }
  },
  "ts": 1691589603965000,
  "history_days": 0,
  "name": "todo"
}
Enter fullscreen mode Exit fullscreen mode

Now we have a collection let's go ahead and add some data to it.

// app.js continued

const createTodo = async (note) => {
  try {
    const todo = await client.query(
      query.Create(
        query.Collection('Todos'),
        { data: { status: 'PENDING', note } }
      )
    );
    return [null, todo];
  } catch (error) {
    return [error, null];
  }
}

module.exports = {
  createCollection,
  createTodo
}
Enter fullscreen mode Exit fullscreen mode

The snippet above handles the process of creating a new Todo object. We have a function createTodo which accepts a string note as its argument. Inside a try block we call client.query and pass in a query to it. To create a new document you need to call query.Create then you pass a ref to the collection you want the document to belong to. The second argument the Create method accepts is a data object with a data property. The value for this data property is the data we want to store in the document. Our data will have a status property which will be PENDING and a note property which will serve as the title of the todo. Let's set up the handler for it.

//index.js continued

const {
  createCollection,
  createTodo,
} = require("/.app.js");


app.post('/todo', async (req, res) => {
  const {note} = req.body;
  try {
    const [error, todo] = await createTodo(note);
    if (error) throw error
    if (todo) res.status(200).json(todo)
  } catch(error) {
    return res.status(400).json(error)
  }
})
Enter fullscreen mode Exit fullscreen mode

We register a post-request handler on our app. The route is /todo and inside the handler, we destructure the note from the request body. Inside the try block we create a new todo and send it back with the response otherwise if there's an error we send that error back with a status of 400. Now let's test this endpoint.

POST http://localhost:3000/todo
Content-Type: application/json

{
    "note": "Cleaning"
}
Enter fullscreen mode Exit fullscreen mode

The response from the server should look like this;

{
  "ref": {
    "@ref": {
      "id": "372695699207225424",
      "collection": {
        "@ref": {
          "id": "todos",
          "collection": {
            "@ref": {
              "id": "collections"
            }
          }
        }
      }
    }
  },
  "ts": 1691689166285000,
  "data": {
    "status": "PENDING",
    "note": "Cycling"
  }
}
Enter fullscreen mode Exit fullscreen mode

Go ahead and create a couple of more todos for fun, now we need to set up an index. Indexes are central to FQL and they are going to be how we will retrieve data from FQL, let's set up an index to retrieve all the pending todos.

// app.js continued

// CREATE INDEX
const createIndex = async (
  name,
  collection,
  field
) => {
  try {
    let index;
    if (field) {
      index = await client.query(
        query.CreateIndex({
          name,
          source: query.Collection(collection),
          terms: [{ field }],
        })
      );
    } else {
      index = await client.query(
        query.CreateIndex({
          name,
          source: query.Collection(collection),
        })
      );
    }
    return [null, index];
  } catch (error) {
    console.log(error)
    return [error, null];
  }
}



module.exports = {
  createCollection,
  createTodo,
  createIndex,
}
Enter fullscreen mode Exit fullscreen mode

First, we define the createIndex function. The function takes three arguments and returns an array with two elements. Inside the try block, we declare a variable called index. This variable will store the created index. Then we check if the field argument is not undefined. If it is not undefined, the function creates an index on the specified field. Otherwise, the function creates an index on the entire collection. We use the client.query() method to create the index. The query.CreateIndex() method creates a new index. The name argument specifies the name of the index. The source argument specifies the collection that the index will be created on. The terms argument specifies the fields that the index will be created on. Then we export the function out of this module. Let's set up a handler for this.

// index.js continued

const {
  createIndex
} = require('./app');

app.post('/index', async (req, res) => {
  const {name,collection,field} = req.body;
  try {
    const [error, index] = await createIndex(
      name,
      collection,
      field ?? null 
    );
    if (error) throw error
    if (index) res.status(200).json(index)
  } catch(error) {
    return res.status(400).json(error)
  }
});

Enter fullscreen mode Exit fullscreen mode

I have attached a handler function to the /index route, that will be executed when a POST request is made to that route. The route handler function first parses the request body and extracts the name, collection, and field parameters. The name parameter specifies the name of the index to create. The collection parameter specifies the name of the collection that the index will be created on. The field parameter specifies the field that the index will be created on. If this parameter is omitted, the index will be created on the entire collection.

The function then calls the createIndex function to create the index. The createIndex function returns an array with two elements: the first element is the error message, and the second element is the index. If the createIndex function returns an error message, the handler function throws an error. The error will be sent back to the client as a 400 Bad Request response. If the createIndex function returns the index, the handler function sends the index back to the client as a 200 OK response. Let's test this endpoint.

POST http://localhost:3000/index
Content-Type: application/json

{
  "name": "todos_by_status",
  "collection": "todos",
  "field": ["data", "status"]
}
Enter fullscreen mode Exit fullscreen mode

The response should look like this;

{
  "ref": {
    "@ref": {
      "id": "todos_by_status",
      "collection": {
        "@ref": {
          "id": "indexes"
        }
      }
    }
  },
  "ts": 1691691134550000,
  "active": true,
  "serialized": true,
  "name": "todos_by_status",
  "source": {
    "@ref": {
      "id": "todos",
      "collection": {
        "@ref": {
          "id": "collections"
        }
      }
    }
  },
  "partitions": 8
}
Enter fullscreen mode Exit fullscreen mode

Now let's set up a function to retrieve all the pending todos;

// app.js continued

const getTodoByIndex = async (
  index,
  value
) => {
  try {
    const todos = await client.query(
      query.Map(
        query.Paginate(
          query.Match(
            query.Index(index),
            value
          )
        ),
        query.Lambda(x => query.Get(x))
      )
    );
    return [null, todos]
  } catch (error) {
    return [error, null]
  }
}

module.exports = {
  createCollection,
  createTodo,
  createIndex,
  getTodoByIndex,
}
Enter fullscreen mode Exit fullscreen mode

The snippet above first defines a function called getTodoByIndex. The function takes two arguments: the index argument specifies the name of the index, and the value argument specifies the value that the index will be matched against. The function then uses the client.query() method to execute the query. The client variable is a reference to the FaunaDB client. The query.Map() method creates a new map. The query.Paginate() method paginates the results of the query.

The query.Match() method matches the documents in the collection that match the specified index and value. The query.Lambda() method creates a lambda function. The lambda function will be executed for each document that is returned by the query. The query.Get() method gets the document from the database. The function returns an array with two elements: the first element is the error message, and the second element is the array of documents that were returned by the query. Let's set up a handler function to retrieve the pending todo.

// index.js continued
const {
  getTodoByIndex
} = require("./app");

app.get('/todos/index',  async (req, res) => {
  const {index, value} = req.body
  try {
    const [error, todo] = await getTodoByIndex(
      index,
      value
    );
    if (error) throw error
    if (todo) res.status(200).json(todo)
  } catch(error) {
    return res.status(400).json(error)
  }
})

Enter fullscreen mode Exit fullscreen mode

We have attached a route handler function to the /todos/index route, that will be executed when a GET request is made to that route. The function first parses the request body and extracts the index and value parameters. The index parameter specifies the name of the index, and the value parameter specifies the value that the index will be matched against. The function then calls the getTodoByIndex function to retrieve the todos.
If the getTodoByIndex function returns an error message, the handler function throws an error. The error will be sent back to the client as a 400 Bad Request response. If the getTodoByIndex function returns an array of documents, the handler function sends the array of documents back to the client as a 200 OK response. Let's test this endpoint.

GET http://localhost:3000/todos/index
Content-Type: application/json

{
  "index": "todos_by_status",
  "value": "PENDING"
}
Enter fullscreen mode Exit fullscreen mode

We should get back an array of todos, let's create a handler function to retrieve a single todo;

// app.js continued

const getTodo = async (id) => {
  try {
    const todo = await client.query(query.Get(
      query.Ref(
        query.Collection("todos"),
        id
      )
    ));
    return [null, todo];
  } catch (error) {
    return [error, null];
  }
}


module.exports = {
  createCollection,
  createTodo,
  createIndex,
  getTodoByIndex,
  getTodo,
}
Enter fullscreen mode Exit fullscreen mode

We have defined a function called getTodo that gets a todo document from the todos collection. The function takes one argument: the id argument which specifies the ID of the document to get. The function first uses the client.query() method to execute the query. The query.Get() method gets the document from the database. The query.Ref() method creates a reference to the document.

The query.Collection() method specifies the collection that the document is in. The id argument specifies the ID of the document. The function returns an array with two elements: the first element is the error message, and the second element is the document that was returned by the query. Let's set up a handler function for retrieving a todo

// index.js continued
const {getTodo} = require('./app');

app.get('/todo/:id', async (req, res) => {
  const {id} = req.params;
  try {
    const [error, todo] = await getTodo(id);
    if (error) throw error
    if (todo) res.status(200).json(todo)
  } catch(error) {
    return res.status(400).json(error)
  }
})
Enter fullscreen mode Exit fullscreen mode

We have a route handler function that gets a todo document from the todos collection by ID. The function is attached to the /todo/:id route, so it will be executed when a GET request is made to that route. The function first parses the request parameters and extracts the id parameter. The id parameter specifies the ID of the document to get. The function then calls the getTodo function to get the todo. If the getTodo function returns an error message, the handler function throws an error. The error will be sent back to the client as a 400 Bad Request response. If the getTodo function returns a document, the handler function sends the document back to the client as a 200 OK response. Finally, let's set up a helper function to update a todo.

// * UPDATE TODO
const updateTodo = async (id, status) => {
  try {
    const todo = await client.query(
      query.Update(
        query.Ref(query.Collection("Todos"), id),
        { data: { status: status } },
      )
    )
    return [null, todo];
  } catch (error) {
    return [error, null]
  }
}

module.exports = {
  createCollection,
  createTodo,
  createIndex,
  getTodo,
  getTodoByIndex,
  updateTodo
}

Enter fullscreen mode Exit fullscreen mode

I have created a function called updateTodo that updates a todo document in the todos collection. The function takes two arguments: the id argument specifies the ID of the document to update, and the status argument specifies the new status of the document. The query.Update() method updates the document. The query.Ref() method creates a reference to the document. The query.Collection() method specifies the collection that the document is in. The id argument specifies the ID of the document. The data object specifies the data that will be updated in the document. In this case, the status property is being updated. The function returns an array with two elements: the first element is the error message, and the second element is the document that was updated. Let's set up a route handler function for this;

// index.js continued

const {updateTodos} = require('./app');

app.patch('/todo/:id',  async (req, res) => {
  const {id} = req.params;
  const {status} = req.body;
  try {
    const [error, todo] = await updateTodo(id, status);
    if (error) throw error
    if (todo) res.status(200).json(todo)
  } catch(error) {
    return res.status(400).json(error)
  }
})
Enter fullscreen mode Exit fullscreen mode

We have a created route handler function that updates the status of a todo document in the todos collection. The function is attached to the /todo/:id route, so it will be executed when a PATCH request is made to that route. The function first parses the request parameters and extracts the id and status parameters. The id parameter specifies the ID of the document to update, and the status parameter specifies the new status of the document. The function then calls the updateTodo function to update the todo.

If the updateTodo function returns an error message, the handler function throws an error. The error will be sent back to the client as a 400 Bad Request response. If the updateTodo function returns a document, the handler function sends the document back to the client as a 200 OK response.

I'm going to stop here and allow you to figure out how to retrieve all the documents in a collection and also how to delete a document from a collection. There are lots of things we skipped because of the time factor, Faunadb has a graphQL API for interacting with your data. We are not limited to using Faunadb on the backend, we can also use it in frontend apps like a react or svelte project. We just barely touched Lambda functions in FQL, we just brushed the surface of FQL and each of these concepts requires a separate post on its own. Leave your thoughts in the comment section below about Faunadb, FQL and the next concept we should cover.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .