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
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
run the following command to install the Faunadb and dotenv extension.
$ npm i faunadb dotenv
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,
}
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'));
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
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"
}
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"
}
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
}
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)
}
})
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"
}
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"
}
}
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,
}
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)
}
});
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"]
}
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
}
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,
}
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)
}
})
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"
}
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,
}
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)
}
})
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
}
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)
}
})
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.