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
andPost
- 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 theUser
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.<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<Admin, Moderator, Member>
- Arrays - Defined by passing the singular value type -
array<str>;
- Tuples - In EdgeD, tuples can contain more than 2 elements and come in named and unnamed varieties -
<str, number>;
tuple<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.
Changing values and links with update
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!