In the beginning of the book we see the main character Jonathan Harker, a young lawyer who is going to meet a client. The client is a rich man named Count Dracula who lives somewhere in Eastern Europe. Jonathan doesn’t yet know that Count Dracula is a vampire, so he’s enjoying the trip to a new part of Europe. The book begins with Jonathan writing in his journal as he travels. The parts that are good for a database are in bold:
3 May. Bistritz.—Left Munich at 8:35 P.M., on 1st May, arriving at Vienna early next morning; should have arrived at 6:46, but train was an hour late. Buda-Pesth seems a wonderful place, from the glimpse which I got of it from the train…
This is already a lot of information, and it helps us start to think about our database schema. The language used for EdgeDB is called EdgeQL, and is used to define, mutate, and query data. Inside it is SDL (schema definition language) that makes migration easy, and which we will learn in this book. So far our schema needs the following:
Some kind of City or Location type. These types that we can create are called object types, made out of properties and links. What properties should a City type have? Perhaps a name and a location, and sometimes a different name or spelling. Bistritz for example is in Romania and is now written Bistrița (note the ț - it’s Bistrița, not Bistrita), while Buda-Pesth is now written Budapest.
Some kind of Person type. We need it to have a name, and also a way to track the places that the person visited.
To make a type inside a schema, just use the keyword type
followed by the type name, then {}
curly brackets. Our Person
type will start out like this:
type Person {
}
That’s all you need to create a type, but there’s nothing inside there yet. Inside the brackets we add the properties for our Person
type. Use required property
if the type needs it, and just property
if it is optional.
type Person {
required property name -> str;
property places_visited -> array<str>;
}
With required property name
our Person
objects are always guaranteed to have a name - you can’t make a Person
object without it. Here’s the error message if you try:
MissingRequiredError: missing value for required property default::Person.name
A str
is just a string, and goes inside either single quotes: 'Jonathan Harker'
or double quotes: "Jonathan Harker"
. The \
escape character before a quote makes EdgeDB treat it like just another letter: 'Jonathan Harker\'s journal'
.
An array
is a collection of the same type, and our array here is an array of str
s. We want it to look like this: ["Bistritz", "Munich", "Buda-Pesth"]
. The idea is to easily search later and see which character has visited where.
places_visited
is not a required
property because we might later add minor characters that don’t go anywhere. Maybe one person will be the “innkeeper_in_bistritz” or something, and we won’t know or care about places_visited
for him.
Now for our City type:
type City {
required property name -> str;
property modern_name -> str;
}
This is similar, just properties with strings. The book Dracula was published in 1897 when spelling for cities was sometimes different. All cities have a name in the book (that’s why it’s required
), but some won’t need a different modern name. Munich is still Munich, for example. We are imagining that our game will link the city names to their modern names so we can easily place them on a map.
Easy EdgeDB’s instructions on migrations were written in 2020 and are somewhat out of date - they work, but are less convenient than the current options. As of May 2023 the preferred (and easiest) way to do a migration is by using the CLI commands at https://www.edgedb.com/docs/intro/quickstart. To start a project, just type ‘edgedb project init’. And every time you change your schema, just type ‘edgedb migration create’ followed by ‘edgedb migrate’. Easy EdgeDB’s chapters will be updated with the easier migration tools very soon!
Another recent addition to EdgeDB is the interactive UI. To use it in an initialized project, just type ‘edgedb ui’ and it will open up in your browser. The UI is a great way to view your schema and play around with your data while you are still learning the EdgeQL query language that this book teaches you.
And now back to the book…
We haven’t created our database yet, though. There are two small steps that we need to do first after installing EdgeDB. First we create a “project” that makes it easier to keep track of the schema and deal with migrations. Then we just open a console to our database by running edgedb
, which will connect us to the default database called “edgedb”. We’ll use that a lot for experimenting.
Sometimes it’s useful to create a whole new database to try something out. You can do that with the create database
keyword and our name for it:
create database dracula;
Then we type \c dracula
to connect to it. And you can type \c edgedb
to get back to the default one.
Lastly, we need to do a migration. This will give the database the structure we need to start interacting with it.
First you start them with start migration to {}
Inside this you add at least one module
, so your types can be accessed. A module is a namespace, a place where similar types go together. The part on the left side of the ::
is the name of the module, and the type inside is to the right. If you wrote module default
and then type Person
, the type Person
would be at default::Person
. So when you see a type like std::bytes
for example, this means the type bytes
inside std
(the standard library).
Then you add the types we mentioned above, and finish up the block by ending with a }
. Then outside of that, type populate migration
to add the data.
Finally, you type commit migration
and the migration is done.
Putting all of that together we get:
start migration to {
module default {
type Person {
required property name -> str;
property places_visited -> array<str>;
}
type City {
required property name -> str;
property modern_name -> str;
}
}
};
populate migration;
commit migration;
There are a lot of other commands beyond this, though we won’t need them for this book. You could bookmark these four pages for later use, however:
Admin commands: Creating user roles, setting passwords, configuring ports, etc.
CLI commands: Creating databases, roles, setting passwords for roles, connecting to databases, etc.
REPL commands: Mostly shortcuts for a lot of the commands we’ll be using in this book.
Various commands about rolling back transactions, declaring savepoints, and so on.
There are also a few places to download packages to highlight your syntax if you like. EdgeDB has these packages available for Atom, Visual Studio Code, Sublime Text, and Vim.
So here’s the City
type we just made:
type City {
required property name -> str;
property modern_name -> str;
}
The select
keyword is the main query command in EdgeDB, and you use it to see results based on the input that comes after it. Keywords in EdgeDB are case insensitive, so select
, SELECT
and SeLeCT
are all the same.
Let’s give select
a try with something really easy: just selecting a string.
select 'Jonathan Harker begins his journey.';
This returns {'Jonathan Harker begins his journey.'}
, no surprise there. Did you notice that it’s returned inside a {}
? The {}
means that it’s a set, and in fact everything in EdgeDB is a set (make sure to remember that). It’s also why EdgeDB doesn’t have null: where you would have null in other languages, EdgeDB just gives you an empty set: {}
. The advantage here is that sets always behave the same way even when they are empty, instead of all the unwelcome surprises that come with using null.
For the next select
queries, we will use some more operators that use the =
sign:
:=
is used to declare,
=
is used to check equality (not ==
),
!=
is the opposite of =
.
Let’s use :=
to assign a variable:
select jonathans_name := 'Jonathan Harker';
This just returns what we gave it: {'Jonathan Harker'}
. But this time it’s a string that we assigned called jonathans_name
that is being returned.
Now let’s do something with this variable. We can use the keyword select
to use this variable and then compare it to 'Count Dracula'
:
select jonathans_name := 'Jonathan Harker',
select jonathans_name != 'Count Dracula';
The output is {true}
. Of course, you can just write select 'Jonathan Harker' != 'Count Dracula'
for the same result. Soon we will do more complex operations with the variables that we assign with :=
.
Let’s get back to the schema. Later on we can think about adding time zones and locations for the cities for our imaginary game. But in the meantime, we will add some items to the database using insert
.
Don’t forget to separate each property by a comma, and finish the insert
with a semicolon. EdgeDB also prefers two spaces for indentation.
insert City {
name := 'Munich',
};
insert City {
name := 'Buda-Pesth',
modern_name := 'Budapest'
};
insert City {
name := 'Bistritz',
modern_name := 'Bistrița'
};
Note that a comma after the last item is optional - you can put it in or leave it out. Here we put a comma at the end sometimes and left it out at other times to show this.
Finally, the Person
insert would look like this:
insert Person {
name := 'Jonathan Harker',
places_visited := ["Bistritz", "Munich", "Buda-Pesth"],
};
But hold on a second. That insert won’t link it to any of the City
inserts that we already did. Here’s where our schema needs some improvement:
We have a Person
type and a City
type,
The Person
type has the property places_visited
with the names of the cities, but they are just strings in an array. It would be better to link this property to the City
type somehow.
So let’s not do that Person
insert. We’ll fix the Person
type soon by changing array<str>
from a property
to a multi link
to the City
type. This will actually join them together.
But first let’s look a bit closer at what happens when we use insert
.
As you can see, strings (str
) are fine with unicode letters like ț. Even emojis and special characters are just fine: you could even create a City
called ‘🤠’ or ‘(╯°□°)╯︵ ┻━┻’ if you wanted to.
EdgeDB also has a byte literal type that gives you the bytes of a string. This is mainly for raw data that humans don’t need to view such when saving to files. They must be characters that are 1 byte long.
You create byte literals by adding a b
in front of the string:
edgedb>
select b'Bistritz';
{b'Bistritz'}
And because the characters must be 1 byte, only ASCII works for this type. So the name in modern_name
as a byte literal will generate an error because of the ț
:
edgedb>
select b'Bistrița';
error: invalid bytes literal: character 'ț' is unexpected, only ascii chars are allowed in bytes literals ┌─ query:1:8 │ 1 │ select b'Bistrița'; │ ^ error
Every time you insert
an item, EdgeDB gives you a uuid
back. UUID stands for Universally Unique IDentifier, and is used widely to make identifiers that won’t be used anywhere else. It will look like this:
{default::Person {id: 462b29ea-ff3d-11eb-aeb7-b3cf3ba28fb9}}
It is also what shows up when you use select
to select a type. Just typing select
with a type will show you all the uuid
s for the type. Let’s look at all the cities we have so far:
select City;
This gives us three items:
{
default::City {id: 4ba1074e-ff3f-11eb-aeb7-cf15feb714ef},
default::City {id: 4bab8188-ff3f-11eb-aeb7-f7b506bd047e},
default::City {id: 4bacf860-ff3f-11eb-aeb7-97025b4d95af},
}
This only tells us that there are three objects of type City
. To see inside them, we can add property or link names to the query. This is called describing the shape of the data we want. We’ll select all City
types and display their modern_name
with this query:
select City {
modern_name,
};
Once again, you don’t need the comma after modern_name
because it’s at the end of the query.
You will remember that one of our cities (Munich) doesn’t have anything for modern_name
. But it still shows up as an “empty set”, because every value in EdgeDB is a set of elements, even if there’s nothing inside. Here is the result:
{
default::City {modern_name: {}},
default::City {modern_name: 'Budapest'},
default::City {modern_name: 'Bistrița'},
}
So there is some object with an empty set for modern_name
, while the other two have a name. This shows us that EdgeDB doesn’t have null
like in some languages: if nothing is there, it will return an empty set.
The first object is a mystery so we’ll add name
to the query so we can see that it’s the city of Munich:
select City {
name,
modern_name
};
This gives the output:
{
default::City {name: 'Munich', modern_name: {}},
default::City {name: 'Buda-Pesth', modern_name: 'Budapest'},
default::City {name: 'Bistritz', modern_name: 'Bistrița'},
}
If you just want to return a single part of a type without the object structure, you can use .
after the type name. For example, select City.modern_name
will give this output:
{'Budapest', 'Bistrița'}
This type of expression is called a path expression or a path, because it is the direct path to the values inside. And each .
moves on to the next path, if there is another one to follow.
You can also change property names like modern_name
to any other name if you want by using :=
after the name you want. Those names you choose become the variable names that are displayed. For example:
select City {
name_in_dracula := .name,
name_today := .modern_name,
};
This prints:
{
default::City {name_in_dracula: 'Munich', name_today: {}},
default::City {name_in_dracula: 'Buda-Pesth', name_today: 'Budapest'},
default::City {name_in_dracula: 'Bistritz', name_today: 'Bistrița'},
}
This will not change anything inside the schema - it’s just a quick variable name to use in a query.
By the way, .name
is short for City.name
. You can also write City.name
each time (that’s called the fully qualified name), but it’s not required.
So if you can make a quick name_in_dracula
property from .name
, can we make other things too? Indeed we can. For the moment we’ll just keep it simple but here is one example:
select City {
name_in_dracula := .name,
name_today := .modern_name,
oh_and_by_the_way := 'This is a city in the book Dracula'
};
And here is the output:
{
default::City {
name_in_dracula: 'Munich',
name_today: {},
oh_and_by_the_way: 'This is a city in the book Dracula',
},
default::City {
name_in_dracula: 'Buda-Pesth',
name_today: 'Budapest',
oh_and_by_the_way: 'This is a city in the book Dracula',
},
default::City {
name_in_dracula: 'Bistritz',
name_today: 'Bistrița',
oh_and_by_the_way: 'This is a city in the book Dracula',
},
}
This brings up an interesting discussion about type safety. EdgeDB is strongly typed, meaning that everything needs a type and it will not try to mix different types together. So if you write select 'Jonathan Harker' + 8;
it will simply refuse with an error: operator '+' cannot be applied to operands of type 'std::str' and 'std::int64'. But we didn't declare a type for
oh_and_by_the_way, so how did EdgeDB know that it was a
str`?
EdgeDB uses what is known as “type inference” to guess the type, meaning that it can usually figure out the type itself. That is what happens here: EdgeDB knows that we are creating a str
because we enclosed it in quotes. In other words, the type is still a concrete str
even if we didn’t specify that it was.
In fact, we can prove that these types are concrete right away: let’s just ask EdgeDB.
select 'Jonathan Harker' is str;
This returns {true}
. Let’s try another:
select 8 is int64;
This returns {true}
. Let’s try one more!
select 8 is int32;
This time it returns {false}
. As you can see, EdgeDB is selecting a concrete type every time even if we don’t specify a type.
There is a way to change one type to another that we will learn in the next chapter.
So now the last thing left to do is to change our property
in Person
called places_visited
to a link
. Right now, places_visited
gives us the names we want, but it makes more sense to link Person
and City
together. After all, the City
type has .name
inside it which is better to link to than rewriting everything inside Person
. We’ll change Person
to look like this:
type Person {
required property name -> str;
multi link places_visited -> City;
}
We wrote multi
in front of link
because one Person
should be able to link to more than one City
. The opposite of multi
is single
, which only allows one object to link to it. But single
is the default, so if you just write link
then EdgeDB will treat it as single
.
Now when we insert Jonathan Harker, he will be connected to the type City
. Don’t forget that places_visited
is not required
, so we can still insert with just his name to create him:
insert Person {
name := 'Jonathan Harker',
};
But this would only create a Person
type connected to the City
type but with nothing in it. Let’s see what’s inside:
select Person {
name,
places_visited
};
Here is the output: {default::Person {name: 'Jonathan Harker', places_visited: {}}}
But we want to have Jonathan be connected to the cities he has traveled to. We’ll change places_visited
when we insert
to places_visited := City
:
insert Person {
name := 'Jonathan Harker',
places_visited := City,
};
Now let’s see the places that Jonathan has visited. The code below is almost but not quite what we need:
select Person {
name,
places_visited
};
Here is the output:
{
default::Person {
name: 'Jonathan Harker',
places_visited: {
default::City {id: 4ba1074e-ff3f-11eb-aeb7-cf15feb714ef},
default::City {id: 4bab8188-ff3f-11eb-aeb7-f7b506bd047e},
default::City {id: 4bacf860-ff3f-11eb-aeb7-97025b4d95af},
},
},
}
Close! But we didn’t mention any properties inside City
so we just got the object id numbers. Now we just need to let EdgeDB know that we want to see the name
property of the City
type. To do that, add a colon and then put name
inside curly brackets.
select Person {
name,
places_visited: {
name
}
};
Success! Now we get the output we wanted:
{
default::Person {
name: 'Jonathan Harker',
places_visited: {
default::City {name: 'Munich'},
default::City {name: 'Buda-Pesth'},
default::City {name: 'Bistritz'},
},
},
}
Interestingly, Jonathan Harker has been inserted with a link to every city in the database. In other words, places_visited := City
means places_visited := every City type in the database
. Right now we only have three City
objects, so this is no problem yet. But later on we will have more cities and won’t be able to just write places_visited := City
for all the other characters. For that we will need filter
, which we will learn to use in the next chapter.
Note that if you have inserted “Johnathan Harker” multiple times, you will have multiple Person
objects with that name corresponding to each insert
command. This is OK for now. In Chapter 7 we will learn how to make sure the database doesn’t allow multiple copies of Person
with the same name.
Entering the code below returns an error. Try adding one character to make it return {true}
.
with my_name = 'Timothy',
select my_name != 'Benjamin';
Try inserting a City
called Constantinople, but now known as İstanbul.
Try displaying all the names of the cities in the database. (Hint: you can do it in a single line of code and won’t need {}
to do it)
Try selecting all the City
types along with their name
and modern_name
properties, but change .name
to say old_name
and change modern_name
to say name_now
.
Will typing SelecT City;
produce an error?
Up next: Jonathan Harker arrives in Romania.