All You Need to Know about EdgeDB
Tags: node
Published: 2022-08-09 11:17:08

Today, non-relational, schemaless data models dominate the domain of databases. Non-relational databases are more developer-friendly and scale better than the relational databases of the past. However, it is harder for them to do complex tasks.

Now we have a new player in the game to address this issue: EdgeDB. EdgeDB is built on top of PostgreSQL and introduces a new conceptual model for representing data.

But before we dive into what EdgeDB is, how it compares to SQL and ORM, and how to create a Node.js app with EdgeDB, let's take a quick look at relational databases.

## What is a Relational Database?

The relational database originated in the 1970s, as IBM and Oracle took initial steps towards the concept of database tiers in applications. IBM adopted Structured Query Language, and later it became a de facto standard for relational databases.

Even though relational databases and SQL were standard database systems, they got a lot of criticism. SQL was accused of being:

- A large language
- Hard to compose
- Inconsistent in syntax and semantics
- Difficult to integrate well enough with an application language

EdgeDB fixes some of these issues.

## What is EdgeDB?

EdgeDB is the first open-source, graph-relational database designed as the successor to SQL and the relational paradigm.

EdgeDB uses the Graph Relational Model, where data is described and stored as strongly typed objects, and relationships link through objects.

It uses PostgreSQL under the hood, inheriting all the power of a relational database. EdgeDB stores and queries data using relational database techniques and requires a strict schema design.

## What is a Graph Relational Model?

The graph-relational model is built on top of the relational model, with some additional features. This new model helps EdgeDB overcome the conceptual and technical difficulties often encountered when a relational database is used in an application (object-relational impedance mismatch). EdgeDB also has the solid basis and performance of a relational database.

Let’s look at some terminology to understand the graph-relational model better.

| Relational Model | Graph Relational Model |
| ---------------- | ---------------------- |
| Table            | Object type            |
| Column           | Property/link          |
| Row              | Object                 |

Graph-relational databases extend the capabilities of an object-relational database in three main ways:

- **Object unique identity**

  All data objects are globally unique, immutable identifiers. So you don't have to specifically add Ids to your schemas. EdgeDB has a constraint that adds a unique id (UUID) upon insertion.

- **Object links**

  In a relational model, an attribute will have a specific set of primitive data types, and relationships are built via these attributes with foreign key constraints. But in the graph-relational model, objects have primitive data types and direct references to other objects (links). So you don't need to mess around with primary keys, foreign keys, and table joins. Objects are unique, with links to represent their relationships.

- **Cardinality**

  Cardinality specifies the number of values assigned to an attribute.

  In a traditional relational model, an attribute will only have a name and datatype, but a graph-relational model comes with this third component called cardinality. Cardinality has five different enums: `Empty`, `One`, `AtMostOne`, `AtLeastOne`, and `Many`.

## What Does EdgeDB Aim to Solve?

The goal of EdgeDB is to solve the hard design problems of relational models. EdgeDB performs better in modern SQL tasks like subqueries, advanced aggregation, and window functions, while adhering to ACID properties, performance, and reliability.

## Features of EdgeDB

Let's look at some of EdgeDB's features to see why it stands out:

- A declarative schema lets you express inheritance, computed properties, functions, complex constraints, and access controls.
- A migration system that automatically detects changes and compares diff in schemas.
- A rich-typed system with a native JavaScript/TypeScript query builder.
- A query language called EdgeQL.
- Support for several languages like Python, JavaScript/TypeScript/Deno, and Go.
- Provides a CLI tool beyond the REPL, allowing users to install, create, handle migrations, and manage databases locally (and soon in the cloud).

## EdgeDB Vs. SQL and ORM

Both Structured Query Language (SQL) and Object Relational Mapping (ORM) have their strengths and weaknesses. Let's see how EdgeDB fares against them in some key aspects:

- **Schema representation**

  EdgeDB has a declarative schema language to represent schemas. It uses .esdl files to define a schema, which is much easier to manage when compared with [DDL](https://oracletutorial.net/dml-ddl-commands-in-oracle.html#ddl-commands-in-oracle) used in SQL.

- **Migrations**

  With EdgeDB, migrations (.edgeql files) are created through the CLI. EdgeDB has an inbuilt system that compares schema changes with the current database. Therefore, it's much easier to manage migrations.

- **Query syntax**

  EdgeDB is built to address some of SQL’s most unintuitive design aspects, like eliminating joins. EdgeQL has better composability or ability to write nested statements with a lesser learning curve.

- **Result structure**

  The structure of results from a traditional SQL query is a list of scalar-valued tuples. You need to convert this to objects to use the data in your application, which requires some additional steps to your application logic. Both ORM and EdgeQL return structured objects as results of executing queries.

- **Language integration**

  With EdgeQL, you can write queries using plain strings. Its inbuilt query builder enables you to write EdgeQL queries with syntax highlighting, autocompletion, and auto-formatting.

- **Performance**

  With EdgeDB, your EdgeQL compiles with optimized PostgreSQL queries. Queries will execute in a single excursion.

  EdgeQL identifies JOIN-heavy queries and converts them to a set of subqueries, before finally aggregating the results. The performance of EdgeQL compared to SQL and ORM is also much better.

- **Power**

  EdgeDB schema definitions and the EdgeQL language are bound together, so your schema types can have computed fields, indexes, and constraints that resemble complex EdgeQL expressions. This makes EdgeDB a powerful solution.

## Architecture of EdgeDB

EdgeDB consists of a three-layer architecture: the client, server, and PostgreSQL server.

Between the client and EdgeDB server, we have the EdgeDB binary protocol layer, which inherits some properties of the Postgres binary protocol.

It will serialize EdgeQL data before moving toward the EdgeDB server. Then the serialized EdgeQL data will be parsed, compiled into SQL, and executed on the PostgreSQL server.

The EdgeDB server has an in-memory cache that caches compiled queries and prepared statements, and reduces database load when those queries execute. It uses the Postgres native binary protocol, which allows the EdgeDB server to communicate with the PostgreSQL server.

![EdgeDB Architecture](https://blog.appsignal.com/images/blog/2022-08/edgedb-architecture.png)

_Original image source: https://i.imgur.com/5DQjd7U.png_

The EdgeDB core and server are written in Python, along with some Rust extensions to make execution fast.

## Hands-on Project: Build a Node.js App with EdgeDB

Let's get our hands dirty by building an application with EdgeDB. For this demonstration, we'll create a small Pokémon REST API.

First, install EdgeDB and initialize the REST API project.

### Installing EdgeDB

EdgeDB supports the three major platforms (Windows, Mac, and Linux).

We will be using Windows in this example. Execute the following command in a PowerShell terminal:

```sh
$ iwr https://ps1.edgedb.com -useb | iex
```

For macOS and Linux, use:

```sh
$ curl https://sh.edgedb.com --proto '=https' -sSf1 | sh
```

### Initialize the Node.js Project

Now let's create a directory and initialize the Node project inside it.

```sh
$ mkdir edge-pokemon
$ cd edge-pokemon
$ npm init -y
```

Install dependencies. Since we are creating a REST API using Node, we will use the Express framework.

```sh
$ npm install express edgedb dotenv cors
$ npm install typescript concurrently nodemon @types/cors @types/express @types/node --save-dev
```

Since we are using TypeScript, let's define the TypeScript configuration file `tsconfig.json`. Generate it using the following command:

```sh
$ npx tsc --init
```

Now, let's add the attribute `"outDir": "./dist"` to the `tsconfig.json` file (where `./dist` is the directory that holds the compiled code).

Initialize the EdgeDB instance.

```sh
$ edgedb project init
```

The above command will create an `edgedb.toml` file and `dbschema` directory, which holds the schema, migrations, and configurations for your EdgeDB instances.

### Add the Schema to Your Node.js App

Now let's create our schema. Navigate to the default schema file under `dbschema/default.esdl`.

```graphql
module default {

    scalar type Result extending enum<Won, Lost, Tie>;

    type Pokemon {
        required property name -> str;
        required property description -> str;
        property height -> int64;
        property weight -> int64;
    }

    type Battle {
        property result -> Result;
        required link contender -> Pokemon;
        required link opponent -> Pokemon;
    }
}
```

Notice that we are not adding an id field, primary, or foreign keys here. Instead, we have built the relationship between Pokémon and Battle through the link. Each Battle object will have a link or relationship to a pokémon via the properties `contender` and `opponent`.

Now we'll build a migration file based on our schema.

```sh
$ edgedb migration create
```

This will generate a migration file under `dbschema/migrations/<migration_number>.esdl`, consisting of an EdgeQL query with some DDL commands like **CREATE TYPE**, **CREATE PROPERTY**, **CREATE LINK**. Run the migration using the following command.

```sh
$ edgedb migrate
```

Two objects will be generated — Pokémon and Battle. You can run the command `edgedb list types` to confirm this.

Now we can start to code our application's server. But first, let's use the query builder in our project for a code-first way to write fully-typed EdgeQL queries with TypeScript.

```sh
$ npx edgeql-js
```

Based on our schema, this will generate some types and JavaScript/TypeScript bindings for our EdgeDB instance under the directory `dbschema/edgeql-js/`.

Create the Express server by making a file called `index.ts` under the project's root.

```javascript
import express, { Express, Request, Response } from "express";
import dotenv from "dotenv";
dotenv.config();
import cors from "cors";

const app: Express = express();
const port = process.env.APP_PORT || 3000;

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.listen(port, () => {
  console.log(`[server]: Server is running at https://localhost:${port}`);
});
```

Define the endpoints and write queries with edgeql-js inside them. Let's start with the `/pokemon` and `/pokemons` endpoints.

```javascript
import * as edgedb from "edgedb";
import e from "./dbschema/edgeql-js";
const client = edgedb.createClient(); // initialize the EdgeDB connection

app.post("/pokemon", async (req: Request, res: Response) => {
  try {
    const query = e.insert(e.Pokemon, {
      name: req.body.name,
      description: req.body.description,
      height: req.body.height,
      weight: req.body.weight,
    });
    const result = await query.run(client);
    res.status(200).send(result);
  } catch (error) {
    console.error(error);
    res.status(500).send(error);
  }
});
```

In the above endpoint, you'll notice that we created a query object via edgeql-js by passing some parameters from the request object.

When you execute the above query, the data will persist under the Pokémon object type.

```javascript
app.get("/pokemons", async (_req: Request, res: Response) => {
  try {
    const query = e.select(e.Pokemon, (pokemon: any) => ({
      id: true,
      name: true,
      description: true,
      height: true,
      weight: true,
    }));
    const result = await query.run(client);
    res.status(200).send(result);
  } catch (error) {
    console.error(error);
    res.status(500).send(error);
  }
});
```

Here, we have written a query and selected some attributes or properties. You can pass attributes or properties along with boolean values to populate them.

Now let's move on to the special endpoints `/battle` and `/battles`, which deal with links (relationships with Pokémon objects).

```javascript
app.post("/battle", async (req: Request, res: Response) => {
  try {
    const query = e.insert(e.Battle, {
      contender: e.select(e.Pokemon, (pokemon) => ({
        filter: e.op(pokemon.id, "=", e.uuid(req.body.contender_id)),
      })),
      opponent: e.select(e.Pokemon, (pokemon) => ({
        filter: e.op(pokemon.id, "=", e.uuid(req.body.opponent_id)),
      })),
      result: req.body.result,
    });
    const result = await query.run(client);
    res.status(200).send(result);
  } catch (error) {
    console.error(error);
    res.status(500).send(error);
  }
});
```

We have some nested queries written for contender and opponent attributes that retrieve the Pokémon object. These Pokémon objects are used to make the relationship or link between the Pokémon and the Battle object types.

```javascript
app.get("/battles", async (_req: Request, res: Response) => {
  try {
    const query = e.select(e.Battle, (battle: any) => ({
      id: true,
      contender: { name: true },
      opponent: { name: true },
      result: true,
    }));
    const result = await query.run(client);
    res.status(200).send(result);
  } catch (error) {
    console.error(error);
    res.status(500).send(error);
  }
});
```

We use a select query in the above endpoint to fetch and populate the links data (relationships). Notice that we pass the values `name: true` for the **contender** and **opponent** attributes, which will fetch the name of the pokémon linked to the battle objects. In this manner, you can write type-safe queries with edgeql-js.

Now we can execute these queries through our Express application. But first, let's add some scripts under the `scripts` section of our `package.json` file.

```json
"scripts": {
    "build": "npx tsc",
    "start": "node dist/index.js",
    "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
},
```

Note that there are some special keywords (tools) like **[concurrently](https://www.npmjs.com/package/concurrently)** and **[nodemon](https://nodemon.io/)** in the `dev` script. These tools come in handy in the development phase. They allow us to execute several commands concurrently and automatically restart our application when a file change is detected in our project.

The `build` script will compile our TypeScript code to ES6 (based on the **target** attribute under **compilerOptions** in the `tsconfig.json` file). The `start` command begins the compiled version of the Express application.

Let's start the development server by executing the following script on the terminal from the project root directory.

```sh
$ npm run dev
```

This will start the Express project on `http://localhost:3000`. Test this application using [Postman](https://www.postman.com/), a tool that allows us to test API endpoints.

**Note**: When you start the project for the first time, you might face a `MODULE_NOT_FOUND` error (`Cannot find module '/path/to/project/edge-pokemon/index.js'`). This is because the build folder or `./dist` has not yet been generated. You can avoid this by running `build` before `start`, or running `start` again.

First, we'll test `/pokemon`, which will create or save a pokémon. This is a **POST** endpoint, so we need to send body data in **x-www-form-urlencoded** form. Now add the parameters `name`, `description`, `height`, and `weight`.

![Create pokemon](https://blog.appsignal.com/images/blog/2022-08/create-pokemon.png)

When you test this endpoint, you will notice a unique `id` of a pokémon object is returned as the response. This is the default behavior of EdgeDB's `insert` API.

Next, let's test the `/pokemons`, which will return all pokémon created. This is a **GET** endpoint, so you will need to send a GET request to fetch data. You don't need to pass any parameters for this endpoint.

![Get Pokemon](https://blog.appsignal.com/images/blog/2022-08/get-pokemon.png)

This endpoint will send an array of pokémon data as the response.

Test the `/battle` endpoint where you will have to make a **POST** request to create a battle. For this, pass the parameters `contender_id` (pokémon id), `opponent_id` (pokémon id), and `result` (only one out of the string values **Won**, **Lost**, **Tie**).

![Create Battle](https://blog.appsignal.com/images/blog/2022-08/create-battle.png)

This endpoint will also return an id, the unique id for a battle object.

Finally, retrieve some battles by making a **GET** request to the `/battles` endpoint.

![Get Battles](https://blog.appsignal.com/images/blog/2022-08/get-battles.png)

This endpoint will send an array of pokémon battle data as the response.

[You can find the complete code for this in my GitHub repo](https://github.com/TRomesh/edgedb-pokemon.git). Feel free to clone the repo, play around with the demo project, and see how EdgeDB works.

## Wrap Up and Next Steps

In this post, we built a Node.js app using EdgeDB. We explored EdgeDB's cool features — its rich-typed system, feature-rich CLI, and good migration tool. We saw how EdgeDB supports major programming languages and gives great performance.

Version 1.0 of EdgeDB was recently released, and the roadmap towards version 2.0 looks promising. You can learn more through the awesome [EdgeDB documentation](https://www.edgedb.com/docs). There is also an active and engaged [EdgeDB community on Discord](https://discord.gg/umUueND6ag).

Happy coding!

**P.S. If you liked this post, [subscribe to our JavaScript Sorcery list](https://blog.appsignal.com/javascript-sorcery) for a monthly deep dive into more magical JavaScript tips and tricks.**

**P.P.S. If you need an APM for your Node.js app, go and [check out the AppSignal APM for Node.js](https://www.appsignal.com/nodejs).**