Skip to content

Intro to EdgeDB - The 10x ORM

Intro to EdgeDB - The 10x ORM

Intro to EdgeDB - The 10x ORM

I’ve written a couple of posts recently covering different TypeScript ORMs. One about Prisma, and another about Drizzle. ORM’s are a controversial topic in their own right - some people think they are evil, and others think they are great. I enjoy them quite a bit. They make it easy to interact with your databases. What is more important and magical for an application than data? SQL without an ORM is amazing as well, but there are some pain points with that approach. Today I’m excited to write about EdgeDB, which isn’t exactly an ORM or a database from my perspective (although they call themselves one). It is, however an incredibly impressive piece of technology that solves these common pain points in a pretty novel way.

So if it’s not an ORM or a database, what exactly is it?

I don’t think I can answer that in one or two sentences, so we will explore the various pieces that make up EdgeDB in this article. From a high-level standpoint, though, an interface/query language that sits in front of PostgreSQL. This may seem like some less important implementation detail, but in my eyes, it’s a feature and one of the most compelling selling points.

Data Modeling

EdgeDB advertises itself as a “graph-relational database”. The core components of EdgeDB data modeling are the schema, type system, and relationship definitions. A schema will consist of objects that contain various typed attributes and links that connect the objects. In SQL, a table is analogous to an Object, and a foreign-key is associated with a link.

Here’s what a simple schema in EdgeDB looks like

type User {
  required email: str {
    constraint exclusive;
  };
}

type Post {
  required content: str;
  required author: User;
}

There’s a few things to highlight here

  • We defined two different objects (tables) User and Post
  • Each object contains properties with their types defined
  • str is one of several scalar types (bool, int, float, json, datetime, etc)
  • the author property is a required link to the User object

Defining relations / associations

In our example above we defined a one-to-many relationship between a user and posts. All the relation types that you can define in traditional SQL are available. One interesting feature though is called backward links. These can be defined in your schema and it allows you to access related data from both sides of a relationship.

type User {
  multi likes: Tweet;
}

type Tweet {
  text: str;
  multi link likers := Tweet.<likes[is User];
}

likes are a many-to-many relationship between Tweet and User. With a backlink defined multi link likers := Tweet.&lt;likes[is User]; - we can access likes from a User and likers from a Tweet.

select User {
    name,
    likes: {
        text
    }
};

select Tweet {
    text,
    likers: {
        name
    }
};

That's how we can access these relations in our queries. You might be looking at these queries and thinking it looks a lot like GraphQL. This is why they call it a ‘Graph-relational’ database.

We’ve only scratched the surface of EdgeDB schema’s. Hopefully, I’ve at least managed to pique your interest.

Computed properties

Computed properties are a super powerful feature that can be added to your schema or queries. This example user schema creates a computed discriminator and username property. The discriminator uses an EdgeDB standard library function to generate a discriminator number and the username property is a combination between the name and discriminator properties.

type User {
  required name: str;
	discriminator := std::random(1000, 9999);
	username := .name ++ "#" ++ <str>.discriminator;
}

Globals and Access Policies

EdgeDB allows you to define global variables as part of your schema. The most common use case I’ve seen for this is to power the access policy feature.

You can define a global variable as part of your schema: global current_user: uuid;

With a global variable defined, you can provide the value as a sort of context from your application by providing it into your EdgeDB driver/client.

const client = createClient().withGlobals({
  current_user: '2141a5b4-5634-4ccc-b835-437863534c51',
});

You can then add access policies directly to your schema, for example, to provide fine-grained access control to blog posts in your blogging application.

type BlogPost {
    required title: str;
    required author: User;

    access policy author_has_full_access
      allow all
      using (global current_user    ?= .author.id
        and  global current_country ?= Country.Full) {
       errmessage := "User does not have full access";
      }
     access policy author_has_read_access
       allow select
       using (global current_user    ?= .author.id
         and  global current_country ?= Country.ReadOnly);
  }

Aside from access policies, you can use your global variables in your queries as well. For example, if you wanted a query to select the current user.

select User filter .id = global current_user;

Types and Sets

EdgeDB is very type-centric. All of your data is strongly typed. We’ve touched on some of the types already.

  • Scalars - There are a lot of scalar types available out of the box
  • Custom scalars - Custom scalar types are user-defined extensions of existing types
  • Enums - Are supported out of the box - enum&lt;Admin, Moderator, Member>
  • Arrays - Defined by passing the singular value type - array&lt;str>;
  • Tuples - In EdgeD, tuples can contain more than 2 elements and come in named and unnamed varieties - &lt;str, number>; tuple&lt;name: str, jersey_number: float64, active: bool>;

All queries return a Set which is a collection of values of a given type. In the query language, all values are Sets. Sets are a collection of values of a given type. A comma-separated list of values inside a set of {curly braces}. A query with no results will return an empty or singleton set. If we have no User values stored yet - select User returns {}.

Paired with the query language types and set provides an incredibly powerful and expressive system for interacting with your data. If you thought TypeScript was cool, wait until you start writing EdgeQL! 🙂

EdgeQL

Now to the fun stuff: the query language. We’ll use a schema from the docs and start by looking at some of those queries and build on those.

The example schema has an abstract type Person with two sub-types based on it Hero and Villian. This is known as a polymorphic type in EdgeDB. The Movie type includes a 1:m association with Person

module default {
  abstract type Person {
    required name: str { constraint exclusive };
  }

  type Hero extending Person {
    secret_identity: str;
    multi villains := .<nemesis[is Villain];
  }

  type Villain extending Person {
    nemesis: Hero;
  }

  type Movie {
    required title: str { constraint exclusive };
    required release_year: int64;
    multi characters: Person;
  }
}

Selecting properties / data

Before we dig into some real queries we should just touch on how we select actual data from a query. It’s pretty obvious and GraphQL-like but worth mentioning. To specify properties to select, you attach a shape. This works for getting nested link / association data as well.

Based on our schema, here’s how we could select fields from Movie, including data from the collection of related characters.

select Movie {
	title,
	release_year,
	characters: {
		name
	}
};

There is also a feature called a splats that allows you to select all fields and/or all linked fields without specifying them individually.

# select all properties
select Movie {*}; 

# select all properties including linked properties
select Movie {**};

If you don’t specify any properties or splats, only id’s get returned select Movie; .

Adding some objects with insert

To get started, we can use insert to add objects to our database.

We’ll start big by looking at the nested insert example. This example is interesting because it shows the creation of two objects in a single query. You’ll notice the simplicity of the syntax. Even though this is the first EdgeQL query we’re looking at, in my experience, it’s like this across the board. I’ve found EdgeQL queries to be simple and intuitive to the point where I’ve been able to intuit how to accomplish things in my head without having to reference the docs or ask the AI.

This example adds a new Villian and a new Hero which gets assigned as a link or association to the nemesis field on our Villian. To accomplish this we see that we can nest queries by wrapping them in ().

insert Villain {
  name := "The Mandarin",
  nemesis := (insert Hero {
    name := "Shang-Chi",
    secret_identity := "Shaun"
  })
};

The next example is pretty similar, but instead of creating the linked property, we are select ing and adding several potential objects to the characters list of Movie since it is a multi link. This is a pretty complex query that is doing a lot of different things. It’s deceivingly succinct. To accomplish the same thing with SQL this would probably be about 3 different queries. This query finds the objects to add to the characters multi link by filtering on a collection of different strings to match against the name property.

insert Movie {
  title := "Spider-Man: No Way Home",
  release_year := 2021,
  characters := (
    select Person
    filter .name in {
      'Spider-Man',
      'Doctor Strange',
      'Doc Ock',
      'Green Goblin'
    }
  )
};

The last thing we’ll cover for insert is bulk inserts. This is particularly useful for things like seed scripts.

In this example, you can just imagine that you have a JSON array of objects with hero names that gets passed in as an argument to your query

with
  raw_data := <json>$data,
for item in json_array_unpack(raw_data) union (
  insert Hero { name := <str>item['name'] }
);

Querying data with select

We’ve already seen subqueries and a select in the last section where we found a collection of Person records with a filter. We’ll build on that and see what tools are available to us when it comes to querying data.

This one covers a lot of ground. Very similar to SQL we have order and limit, and offset operators to support sorting and pagination. Also there is a whole standard library of functions and operators like count that can be used in our queries. This example returns a collection of villian names, excluding the first and last result.

select Villain {name}
order by .name
offset 1
limit count(Villain) - 1;

Most commonly, you will want to filter by an id

select Villian {*} filter .id = <uuid>"6c22c502-5c03-11ee-99ff-cbacc3918129";

Here’s another common example filtering by datetime. Since we’re using a string value here we need to cast it to the EdgeDB datetime type.

select Movie {*}
filter
    Movie.release_date > <cal::local_datetime>'2020-01-01T00:00:00';

You get a pretty similar toolbox to SQL when it comes to filtering with your common operators and things. Combined with all the tools in the standard library, you can get pretty creative with it.

The update..filter..set statement is how we can update existing data with EdgeQL. set is followed by a shape with assignments of properties to be updated.

update Hero
filter .name = "Hawkeye"
set { name := "Ronin" };

You can replace links for an object

update movie
filter .title = "Black Widow"
set {
 characters := (
  select Person
  filter .name in { "Black Widow", "Yelena", "Dreykov" }
 )
};

or add additional ones

update Movie
filter .title = "Black Widow"
set {
 characters += (insert Villain {name := "Taskmaster"})
};

An even more interesting example is removing links matched on a type. Since Villian is a sub-type of Person , this query will remove all characters linked of the Villian type.

update Movie
filter .title = "Black Widow"
set {
 characters -= Villain # remove all villains
};

Deleting objects with delete

Deleting is pretty straight forward. Using the delete command you can just filter for the objects that you would like to remove.

delete Hero
filter .name = 'Iron Man';

When the EdgeQL pieces fall into place

As you become more familiar with the EdgeQL query language chances are you’ll start writing very complex queries fluently because everything just makes sense once you’ve learned the building blocks.

Domain and business concerns

I don’t think they explicitly mention this as a goal anywhere but it’s something that I picked up on pretty quickly. EdgeDB nudges you to move more of what might have traditionally been application logic into your database layer. This is a topic that can bring a lot of division since even things like foreign keys and constraints in SQL are frowned upon in some circles. EdgeDB goes as far as providing constraints, global variables, contexts, and authorization support built into the database. I think that the ability to bake some of these concerns into your EdgeDB Schema is great. The way you model your schema and database in EdgeDB map to your domain in a much more intuitive way where domain concerns don’t really feel out of place there.

Database Clients and Query Builders and Generators

We’ve covered a lot so far to highlight what EdgeDB is and how to handle common use cases with the query language. To use it in your project though, you will need a client/driver library. There are clients available in several different languages. The one that they clearly have put the most investment into is the TypeScript query builder. We’ll briefly look at both options: simple driver/client and query builder. Whichever you end up choosing you will need to instantiate a driver and make sure you have a connection to your database instance configured.

Basic client

Although the TS query builder is very popular and pretty amazing, I couldn’t get away from just writing EdgeQL queries. In my application, I composed queries using template strings, and it worked great. The clients all have a set of methods available for passing in EdgeQL queries and parameters.

querySingle is a method for queries where you are only expecting a single result. If your query will have multiple results you would use query instead. There is also a queryRequiredSingle which will throw an error if no results are found. There are some other methods available as well including one for running queries in a transaction

import * as edgedb from "edgedb";

const client = edgedb.createClient();

async function main() {
  const result = await client.querySingle(`
    select Movie {
      title,
      actors: {
        name,
      }
    } filter .title = <str>$title
  `, { title: "Iron Man 2" });

  console.log(JSON.stringify(result, null, 2));
}

The first argument is the query, and the second is a map of parameters. In this example we include the title parameter and it is accessed in our query via $title.

TypeScript query builder

If you have a TypeScript app and type-safety is important, you might prefer using the query builder. It is a pretty incredible feat of TypeScript magic initially developed by the same developer behind the popular library Zod. We can’t cover it in very much depth here but we’ll look at an example just to have an idea of what the query builder looks like in an application.

import * as edgedb from "edgedb";
import e from "./dbschema/edgeql-js";

const client = edgedb.createClient();

async function main() {
  // result will be inferred based on the query
  const result = await e
    .select(e.Movie, () => ({
      title: true,
      actors: () => ({ name: true }),
      filter_single: { title: "Iron Man 2" },
    }))
    .run(client);

  console.log(JSON.stringify(result, null, 2));
}

The query builder is able to infer the result type automatically. It knows which fields you’ve selected, it knows that the result will be a single item.

Query generator

There are generators for queries and types. So even if you opt out of using the query builder you can still have queries that are strongly typed. It’s nice to have this option if you want to just write your queries as EdgeQL in .edgeql files.

└── queries
    └── getUser.edgeql
    └── getUser.query.ts    <-- generated file

We end up with an exported function named getUser that is strongly typed.

import { getUser } from "./queries/getUser.query";

const user = await getUser(client, newUser); // GetUserReturns

Tools and Utilities

The team at EdgeDB puts a big emphasis on developer experience. It shows up all over the place. We’ve already seen some utilities with the generators that are available. There are some other tools available as well that help complete the entire experience.

EdgeDB CLI

The first and most important tool to mention is the CLI. If you’ve started using EdgeDB then you’ve most likely already installed and used it. The CLI is pretty extensive. It includes commands for things like migrations, managing EdgeDB versions and installations, managing projects and local/cloud database instances, dumps and restores, a repl, and more. The CLI makes managing EdgeDB a breeze.

Admin UI

The CLI includes a command to launch an admin UI for any project or database. The Admin UI includes a awesome interactive diagram of your database schema, a repl for running queries, and a table to inspect and make changes to the data stored in your database.

Summary

Adopting newer database technology is a tough sales pitch. Replacing your application’s database technology at any point in its lifecycle is not a problem that anyone wants to have. This is one of the reasons why EdgeDB being a superset of PostgreSQL is a huge feature in my opinion. The underlying database technology is tried and true, and EdgeDB is open-source. Based on this, I would feel confident using EdgeDB if it aligned well from a technical and business perspective.

We’ve covered a lot of ground in this post. EdgeDB is feature-packed and powerful. Databases is a tough nut to crack, and I commend the team for all their hard work to help continue pushing forward one of the most important components of almost any application. I’m typically pretty conservative when it comes to databases, but EdgeDB took a great approach, in my opinion. I recommend at least giving it a try. You might catch the EdgeDB bug like I did!