A growing map, more opportunities, more problems
Time elapsed: 12y
It didn’t take long for this part of the world to become connected again. Every new telegraph tower reduces message travel time from half a day to just a few minutes, and once distant locations are now starting to feel like a part of your own backyard.
It is certainly quite the experience to now be in regular contact with the city of Redmont across the bay. You and its mayor have become quite close, despite having never met each other! It truly is remarkable.
The telegraph service is being used more and more as time goes on. People are even able to request information that your team finds in the database and sends back.
This is usually a good thing. But you did hear one disturbing report lately from Landevin who had just returned from a trip across the great mountains to the east.
Landevin visited two cities there called Bulton and Emond’s Field that have been at war for years. As Bulton is closer to the end of the telegraph route, its people have been crossing the mountains to make requests for data. Their requests were pretty bland: information about chemicals and metal and manufacturing and so on. But they used it to gain an advantage in the war. And what’s more, they had been doing this for years already! By the time you found out about it, they had already won. Hopefully they were the good guys…
What else are people using it for that you are unaware of?
Well, there is no stopping progress now. Speaking of which, it’s time to get back to the map service you are putting together to make travel easier for the average citizen.
In this chapter we can reveal something that you may have already figured out yourself: the town of Toria where Aeon lives is located at the same spot as the city of Victoria, British Columbia in modern-day Canada! The population of the world has dropped by quite a bit since then, and many names have changed. The secret has been now been revealed because in this chapter we will be dealing with so many pieces of location data that it is now obvious what part of the world we are looking at.
The world felt like a very large place in the distant past, and the same goes for the people of Aeon’s time. Take the distance between Toria and Bulton, known as Victoria and Calgary in our time, as an example. If you ask Google Maps or a similar service how long it takes to walk from one to the other, the answer is about 270 hours. But this is assuming the infrastructure of the modern day such as paved roads and ferries to cross the water. Things are different in Aeon’s time. Boats are less common, roads are less developed, the entire Rocky Mountains have to be crossed first, and safety is not guaranteed either. Such a journey could have taken a month or longer.
It’s no wonder that cities simply lost contact with each other over the centuries between the present day and Aeon’s time.
But as the telegraph system continues to grow, people are becoming aware of locations that they handly knew anything about before. A message can now be passed from Toria to the end of the telegraph line — halfway to Bulton, at a town called Revelry — within half an hour. News of any happenings that take place in Bulton only have to cross the mountains and reach Revelry for Aeon to be aware of it.
The most fortunate thing for Aeon’s team is that the library inside the tunnel contains information on these locations, including their exact coordinates. So instead of having to come up with a system to measure the coordinates themselves, all they need to do is find out which city matches which city in the past.
As a result, Aeon has been able to put together a basic dataset for all of the locations nearby as well as two of the cities across the mountains. You can see the raw data for the towns inside this dataset which adds them inside a single INSERT statement. The location data is precise, making it easy to put it into the geo functions that we learned in the last chapter. Let’s give two of them a try!
LET $city1 = { name: "Edmond's Field", old_name: "Edmonton", population: 6000, location: (-113.602, 53.697) }; LET $city2 = { location: (-122.282, 47.682), name: 'Redmont', old_name: 'Seattle (Redmond)', population: 30000 }; RETURN geo::distance($city1.location, $city2.location);
Response904941.0861035966f
This will be a big help, because common people during medieval times generally traveled using something known as an itinerary: a set of places that you needed to travel to get from one point to another.
A person with an itinerary in hand would then go to the first destination, ask the locals which road to take to reach the next destination, and so on until the final destination is reached. An itinerary is more of a list of places to go than an actual map. If you are curious about the subject, check out this video which explains the experience for the medieval traveler.
But with the precise location data in Aeon’s database, people will now be able to construct their own maps instead. Let’s use our knowledge from the last few chapters to do so.
We’ll start with a few define statements for the fields of our town records to make sure that the data is as we expect.
DEFINE FIELD name ON TABLE town TYPE string; DEFINE FIELD old_name ON TABLE town TYPE option<string>; DEFINE FIELD population ON TABLE town TYPE option<int>; DEFINE FIELD location ON TABLE town TYPE geometry<point>; DEFINE INDEX unique_town ON TABLE town FIELDS name UNIQUE;
Each town will be connected to one or more towns by some sort of route. We can use a RELATE
statement for this. We’ll just call this graph table to
. Since we know that a town
will always have a location
, we can use this to calculate the bearing inside the table to
.
DEFINE FIELD bearing ON TABLE to VALUE geo::bearing(in.location, out.location);
Unlike the other RELATE
statements we have used so far, it’s fine to relate Town 1 to Town 2, followed by Town 2 to Town 1.
RELATE town:one->to->town:two SET ... // Some route data RELATE town:two->to->town:one SET ... // More route data
This is because the route from town:one
to town:two
might be an gentle downward slope the whole way, while going back from town:two
to town:one
will be all uphill. Or maybe town:two
has stricter border controls than town:one
, so you might want to add a note about that and how best to bribe the guards to get in. This information won’t be needed for the route to town:one
.
Each to
relation should be unique though, to make sure that we can’t type something like RELATE town:one->to->town:two
more than once. We can do this with the DEFINE INDEX...UNIQUE
technique we learned in Chapter 8, except that it’s a bit simpler this time. Instead of needing to create a separate key
field composed of the sorted in
and out
fields, we can just define the index on the in
and out
fields together. This will make a single index composed of the two fields.
DEFINE INDEX unique_key ON TABLE to FIELDS in, out UNIQUE;
With this index set up, we can relate one town to another via to
, and from the other direction from the second town to the first, but no more.
DEFINE INDEX unique_key ON TABLE to FIELDS in, out UNIQUE; RELATE town:one->to->town:two; RELATE town:two->to->town:one; RELATE town:one->to->town:two; RELATE town:two->to->town:one;
Response-------- Query -------- [ { id: to:12ym9nxe64xjh9kxixl0, in: town:one, out: town:two } ] -------- Query -------- [ { id: to:x9i854iofrzc8uomb4x0, in: town:two, out: town:one } ] -------- Query -------- 'Database index `unique_key` already contains [town:one, town:two], with record `to:12ym9nxe64xjh9kxixl0`' -------- Query 5 -------- 'Database index `unique_key` already contains [town:two, town:one], with record `to:x9i854iofrzc8uomb4x0`'
Next, we should add some fields to the to
table to describe the route. Aeon and the team have the exact location data for these towns, but no precise information on what the routes are like connecting them. As a result, their only choice is to send messages to these locations to ask how the road is to the next location. Questions like “How straight is it?” and “Is there a road? Is it by land, or over water?” are about as good as things can get at this point.
To represent the state of a route from one destination to another, we’ll make a few string
fields that have to be of a certain value. The options we will allow are as follows:
So if a local from The Naimo tells Aeon that “Yep, there’s a real nice straight road going to The Hill, no complaints”, it can be turned into this object that can be used to make a to
graph table.
{ from: "The Naimo", to: "The Hill", crookedness: "straight", terrain: "road", slope: "flat" }
And if another local from Black Bay complains about all the islands that get in the way when crossing the sea to Gaston, then this object can be used to represent the trip.
{ from: "Black Bay", to: "Gaston", crookedness: "crooked", terrain: "water", slope: "flat" }
One way we could define these fields is by adding an assertion that the $value
of the field is contained within a number of values that we have decided are valid. The definitions in this case would look like this:
DEFINE FIELD crookedness ON TABLE to TYPE string ASSERT ["straight", "crooked", "very crooked"] CONTAINS $value; DEFINE FIELD terrain ON TABLE to TYPE string ASSERT ["road", "normal", "hard", "water"] CONTAINS $value; DEFINE FIELD slope ON TABLE to TYPE string ASSERT ["flat", "up", "down", "steep"] CONTAINS $value;
But there is another type that we can use that is a bit simpler (and which was added in SurrealDB 2.0) called a literal type. A literal type is a simple yet powerful way of telling SurrealDB that “this is what the type can be” - no need to choose a type name to do it.
Let’s make our first literal as simple as possible: a type that can only be “Aeon”, nothing else.
LET $aeon: "Aeon" = "Aeon"; LET $aeon: "Aeon" = "Aeon!";
The first query will work, but the second doesn’t quite match so SurrealDB will return an error.
"Found 'Aeon!' for param $aeon, but expected a 'Aeon'"
One possible value is fairly uninteresting, though. To add more allowed values, we can put a |
in between each one.
LET $database_expert: "Aeon" | "Landevin" = "Aeon";
A literal can include type names too (string, numbers, or durations), as well as arrays and objects.
LET $place: "Uninhabited" | { type: "town", population: int } | { type: "city", population: int } = { type: "town", population: 3150 };
A literal is pretty similar to an enum or a union in a lot of programming languages, but is of the more powerful type because it contains data instead of being just a list of options.
Our literal types for the table to
will be pretty simple, just a list of possible string values. All together they look like this.
DEFINE FIELD crookedness ON TABLE to TYPE "straight" | "crooked" | "very crooked"; DEFINE FIELD terrain ON TABLE to TYPE "road" | "normal" | "hard" | "water"; DEFINE FIELD slope ON TABLE to TYPE "flat" | "up" | "down" | "steep";
With our first definitions done, let’s get to the raw trip data compiled by Aeon and the team. We now have the raw town data and the trip data, plus some defined fields.
To put them all together, we can then use a FOR
loop on all of these objects to start a RELATE
statement. First we will use a SELECT
along with ONLY
and LIMIT 1
to let SurrealDB know to return a single object instead of an array.
FOR $trip IN [ // trip objects here... ] { LET $begin = (SELECT * FROM ONLY town WHERE name = $trip.from LIMIT 1); LET $end = (SELECT * FROM ONLY town WHERE name = $trip.to LIMIT 1); }
Then we will start the RELATION
statement:
RELATE $begin->to->$end
And then SET
some values for convenience.
The first three are pretty straightforward:
crookedness = $trip.crookedness, terrain = $trip.terrain, slope = $trip.slope,
Then calculate the distance:
distance = geo::distance($begin.location, $end.location),
Next is something we will call speed_modifier
. The default speed for a trip will be 1.0, while road conditions can increase this modifier (which is good) or decrease it (which is bad).
To represent this, we can follow a pretty simple IF ELSE
pattern.
speed_modifier = (IF crookedness = "straight" { 1.0 } ELSE IF crookedness = "crooked" { 0.8 } ELSE IF crookedness = "very crooked" { 0.5 }) * (IF slope = "flat" { 1.0 } ELSE IF slope = "up" { 0.8 } ELSE IF slope = "down" { 1.2 } ELSE IF slope = "steep" { 0.5 } ) * (IF terrain = "road" { 1.2 } ELSE IF terrain = "normal" { 1.0 } ELSE IF terrain = "hard" { 0.7 } ELSE IF terrain = "water" { 1.0 } )
As a result, a person could have a speed modifier of up to 1.44 when traveling on a road on a gentle downward slope (12 * 1.2), and as low as 0.175 when traveling on a path that is very crooked, steep, and in difficult terrain (0.5 * 0.5 * 0.7).
Finally, we will add a days_travel
field. This assumes that people will move at a default of 20 km per day on land. Water is slightly more complicated. It is faster (100 km per day), but assumes an extra day to find a boat in the first place. The speed modifier for water will only involve crookedness, because the terrain is always the same and water can only be flat. Any other conditions have to do with weather, which can’t be put into a database ahead of time.
days_travel = IF $trip.terrain = "water" { 1 + (distance / 100000) / speed_modifier } ELSE { (distance / 20000) / speed_modifier }
Putting all of this together gives us a complete dataset for these few towns. You can copy and paste the dataset into Surrealist and then take a look at the results for yourself! Let’s take a look at the places that you can get to from Toria:
SELECT terrain, distance, crookedness, slope, days_travel, bearing, in.name AS from, out.name AS to FROM to WHERE in.name = 'Toria';
Response[ { bearing: -112.00711670161101f, crookedness: 'straight', days_travel: 1.84021258451263f, distance: 30670.20974187717f, from: 'Toria', slope: 'flat', terrain: 'road', to: 'Sukh' }, { bearing: 117.39553589330698f, crookedness: 'straight', days_travel: 1.9529820601422698f, distance: 95298.20601422698f, from: 'Toria', slope: 'flat', terrain: 'water', to: 'Marysville' }, { bearing: -39.94767014691305f, crookedness: 'straight', days_travel: 1.8040428991166035f, distance: 30067.381651943393f, from: 'Toria', slope: 'flat', terrain: 'road', to: 'The Hill' }, { bearing: -13.622896156189338f, crookedness: 'straight', days_travel: 1.421191744500681f, distance: 23686.529075011353f, from: 'Toria', slope: 'flat', terrain: 'road', to: 'Black Bay' } ]
If you change ‘Toria’ to ‘Gaston’ (today’s Vancouver), you can see that there are two places to go: one to the town of Abeston to the east, and another across the water.
Wait a second, both of these routes go to the east. What about the route that goes south into the modern-day United States?
[ { bearing: 111.22813997318887f, crookedness: 'straight', days_travel: 3.619233451805639f, distance: 60320.55753009399f, from: 'Gaston', slope: 'flat', terrain: 'road', to: 'Abeston' }, { bearing: -160.6283333598338f, crookedness: 'crooked', days_travel: 1.5559320538648187f, distance: 69491.50673310233f, from: 'Gaston', slope: 'flat', terrain: 'water', to: 'Black Bay' } ]
Oops! Looks like we forgot to add Redmont (modern day Seattle). As this old map shows, the trip between these two cities is pretty easy and just involves walking south along the coast a bit. People in Aeon’s day will have an easy time traveling between the two locations as well.
We could accomplish this in a few ways:
So let’s learn how to do that!
Not only does SurrealDB come with a large set of functions to pick and choose from, but also lets you define your own. To start defining your own function, just type DEFINE FUNCTION
and the name of the function, which starts with fn::
. After this you need to add parentheses to hold the arguments, then braces (curly brackets) to hold the function body, and finally a semicolon to finish the statement.
Putting that all together, a function without any arguments will look like this:
DEFINE FUNCTION fn::add_trip() { };
This function only returns an empty value at the moment, but it still works!
SELECT * FROM fn::add_trip();
Response[ NONE ]
Our function will simply take the arguments that we have been using in our objects to construct the to
tables. Note that even at this point there is some type safety involved, and the function can no longer be called without providing all five arguments.
DEFINE FUNCTION fn::add_trip($from: string, $to: string, $crookedness: string, $terrain: string, $slope: string) { }; SELECT * FROM fn::add_trip();
Response"Incorrect arguments for function fn::add_trip(). The function expects 5 arguments."
The body of the function is not a challenge, as all we have to do is copy and paste the content we already have and modify a few parameter names. In addition, we can have the function return the relation that was successfully created instead of just returning NONE
. To do this, we can assign the RELATE
statement to a parameter, and return a message at the end of the function to let the user know that the new trip has been added.
DEFINE FUNCTION fn::add_trip($from: string, $to: string, $crookedness: string, $terrain: string, $slope: string) { LET $begin = (SELECT * FROM ONLY town WHERE name = $from LIMIT 1); LET $end = (SELECT * FROM ONLY town WHERE name = $to LIMIT 1); LET $relation = RELATE $begin->to->$end SET crookedness = $crookedness, terrain = $terrain, slope = $slope, distance = geo::distance($begin.location, $end.location), speed_modifier = (IF crookedness = "straight" { 1.0 } ELSE IF crookedness = "crooked" { 0.8 } ELSE IF crookedness = "very crooked" { 0.5 }) * (IF slope = "flat" { 1.0 } ELSE IF slope = "up" { 0.8 } ELSE IF slope = "down" { 1.2 } ELSE IF slope = "steep" { 0.5 }) * (IF terrain = "road" { 1.2 } ELSE IF terrain = "normal" { 1.0 } ELSE IF terrain = "hard" { 0.7 } ELSE IF terrain = "water" { 1.0 }), days_travel = IF $trip.terrain = "water" { 1 + (distance / 100000) / speed_modifier } ELSE { (distance / 20000) / speed_modifier }; RETURN <string>$relation.in.name + " to " + <string>$relation.out.name + " trip created!"; };
And now the function will work!
fn::add_trip("Gaston", "Redmont", "straight", "road", "flat");
Response"['Gaston'] to ['Redmont'] trip created!"
We can now see the road from Gaston to Redmont that we have just added.
Can we make the function a little bit more rigorous though? What if we enter a town that isn’t in the database, or put the parameters in the wrong order, or something else?
Indeed we can, by using a keyword called THROW
, which stops the execution of a query and returns an error instead. Here is a simple example of a dividing function that first checks to see if the second number is zero. SurrealDB already checks to see if a user tries to divide by zero (it returns NaN
, which stands for “Not a Number”), but by using THROW
we can make the error message a little more personal.
Since this function returns a value, we can make it extra rigorous by adding -> number
to instruct SurrealDB that this function must return a number, and nothing else. Adding a return value also improves readability for anyone reading your code, as now it won’t be necessary to read through the content of the function to know what type of value is going to be returned.
DEFINE FUNCTION fn::divide($one: number, $two: number) -> number { IF $two == 0 { THROW "Can't divide by zero, you should know that by now"; }; RETURN $one / $two; }; RETURN fn::divide(100, 0); RETURN fn::divide(100, 10);
The output shows that the first attempt to use fn::divide
did indeed stop at THROW
:
Response"An error occurred: Can't divide by zero, you should know that by now" 10
We can now use THROW
for the fn::add_trip
function, which will now make four checks along the way to ensure that the parameters are correct. The first two checks are to make sure that both $crookedness
and $terrain
are valid input, as there is no reason to continue at this point if they are not. If these two pass, then the function will query for the town at $begin
and the town at $end
. It will then check to see if either of the towns do not exist, by using the count
function to see if the length is zero. If either of these cases is true, then we don’t have two towns that can be related, and THROW
is used again to stop executing the function.
And since the function returns a string, we can add -> string
as its signature to make its output clear.
DEFINE FUNCTION fn::add_trip($from: string, $to: string, $crookedness: string, $terrain: string, $slope: string) -> string { IF $crookedness NOT IN ["straight", "crooked", "very crooked"] { THROW "Third argument is crookedness: straight, crooked, or very crooked"; }; IF $terrain NOT IN ["road", "normal", "land", "water"] { THROW "Fourth argument is terrain: road, normal, land, or water"; }; IF $slope NOT IN ["flat", "up", "down", "steep"] { THROW "Fifth argument is slope: flat, up, down, or steep"; }; LET $begin = (SELECT * FROM ONLY town WHERE name = $from LIMIT 1); LET $end = (SELECT * FROM ONLY town WHERE name = $to LIMIT 1); IF count($begin) = 0 { THROW "Could not find town " + $from + " in the database" }; IF count($end) = 0 { THROW "Could not find town " + $to + " in the database" }; LET $relation = RELATE $begin->to->$end SET crookedness = $crookedness, terrain = $terrain, slope = $slope, distance = geo::distance($begin.location, $end.location), speed_modifier = (IF crookedness = "straight" { 1.0 } ELSE IF crookedness = "crooked" { 0.8 } ELSE IF crookedness = "very crooked" { 0.5 }) * (IF slope = "flat" { 1.0 } ELSE IF slope = "up" { 0.8 } ELSE IF slope = "down" { 1.2 } ELSE IF slope = "steep" { 0.5 }) * (IF terrain = "road" { 1.2 } ELSE IF terrain = "normal" { 1.0 } ELSE IF terrain = "hard" { 0.7 } ELSE IF terrain = "water" { 1.0 }), days_travel = IF $trip.terrain = "water" { 1 + (distance / 100000 * speed_modifier) } ELSE { distance / 20000 * speed_modifier }; RETURN <string>$relation.in.name + " to " + <string>$relation.out.name + " trip created!"; };
Some experimenting with the function shows that it is now a lot stricter than before. Here is the output when none of the arguments are correct:
fn::add_trip("one", "two", "three", "four", "five");
Response'An error occurred: Third argument is crookedness: straight, crooked, or very crooked'
And when only one of the arguments is correct:
fn::add_trip("one", "two", "straight", "four", "five");
Response'An error occurred: Fourth argument is terrain: road, normal, land, or water'
Output when one town exists but the other does not:
CREATE town SET name = "one", location = (-123.338, 48.481); fn::add_trip("one", "two", "straight", "road", "down");
Response'An error occurred: Could not find town two in the database'
And finally a case where we have managed to satisfy the add_trip
function by adding the second town.
CREATE town SET name = "one", location = (-123.338, 48.481); CREATE town SET name = "two", location = (-123.4, 48.5); fn::add_trip("one", "two", "straight", "road", "down"); fn::add_trip("two", "one", "straight", "road", "up"); SELECT * OMIT id, in, out FROM to WHERE in.name = 'one' OR out.name = 'one';
These two made-up towns are just a few hours’ distance from each other, though it takes you a bit longer when the slope is uphill.
Response[ { bearing: -65.16106044294295f, crookedness: 'straight', days_travel: 0.36243605117221106f, distance: 5033.834044058487f, slope: 'down', speed_modifier: 1.44f, terrain: 'road' }, { bearing: 114.79251111210614f, crookedness: 'straight', days_travel: 0.24162403411480737f, distance: 5033.834044058487f, slope: 'up', speed_modifier: 0.96f, terrain: 'road' } ]
With this information, people can now use the information from queries like this that Aeon or the team will put together for them.
SELECT bearing, crookedness, days_travel, distance, slope, terrain FROM to WHERE in.name = "The Hill" AND out.name = "Toria";
The days_travel
can then help travelers choose how fast to travel. A number like 1.25 for the route from The Hill to Toria means either one hard day’s travel, or two two leisurely days of travel with camping outside for the one night. The bearing
and distance
fields make it easy to construct itineraries with better visuals, and other fields can be used to indicate the difficulty of the route.
This sort of graph query that returns all the names of towns connected to The Hill is quite familiar to us by now.
SELECT ->to->town.name AS next_routes FROM town WHERE name = 'The Hill';
Response[ { next_routes: [ 'Toria', 'The Naimo', 'Honeymoon' ] } ]
We were able to filter using WHERE
on the towns, but is there a way to filter from the next_routes
result so that for example only a single town is returned?
Indeed there is. In the same way that we can use []
to index an array, we can also use it with a WHERE
clause to filter them. Here is a quick example:
RETURN [8,9,0][0]; RETURN [8,9,0][WHERE $this != 9];
Response-------- Query -------- 8 -------- Query -------- [ 8, 0 ]
You can filter as many times as you want in a single statement, and even split it over multiple lines for better readability.
RETURN [1,2,3,4,5,6,7,8,9,0] [WHERE $this != 2] [WHERE $this != 4] [WHERE $this % 2 = 0];
Response[ 6, 8, 0 ]
If the items in the array have named fields, you can just refer to those fields instead of using $this
:
RETURN [ { name: 'Toria', temperature: 10.0 }, { name: 'The Hill', temperature: 12.5 } ][WHERE temperature > 10.0];
Response[ { name: 'The Hill', temperature: 12 } ]
Typing $this.temperature
would have worked too.
This can be combined with a graph query to check to see if a planned route is valid and where to go next. The following query would be helpful for a traveler from The Hill who wants to go to Toria, but hasn’t decided where to go next.
SELECT ->to->town[WHERE name = 'Toria'] ->to->town.name FROM town WHERE name = 'The Hill';
Response[ { "->to": { "->town": { "->to": { "->town": { name: [ 'Marysville', 'Sukh', 'Black Bay', 'The Hill' ] } } } } } ]
This can be extended as far as we like. For the traveler looking to go from The Hill all the way to Hope, we can follow the graph relations all the way to the end to make sure that the route exists, as well as to find some information for the traveler at the end. The output is a bit bland because we don’t have much info about Hope, but in Aeon’s time each town record probably has a lot more information than this.
SELECT ->to->town[WHERE name = 'Toria'] ->to->town[WHERE name = 'Black Bay'] ->to->town[WHERE name = 'Gaston'] ->to->town[WHERE name = 'Abeston'] ->to->town[WHERE name = 'Hope'].* AS town_info FROM town WHERE name = 'The Hill';
Response[ { town_info: [ { id: town:2ewhmu753guo0wodmhp3, location: (-121.459, 49.388), name: 'Hope', old_name: 'Hope', population: 50 } ] } ]
We are pretty good at SurrealDB’s geo functions by now, so it’s now time to move on to the next chapter to see what else Aeon and the team have in store. What other quality of life changes are coming to the world?
Let’s say that the function should be called fn::set_writer()
and is used to insert an author and the ID of a book that the author has written.
There are many ways to do this, but two quick casts into a <record<person>>
and <record<book>>
inside of a RELATE
statement is probably the shortest.
DEFINE FUNCTION fn::set_writer($person: string, $book: string) { RELATE (<record<person>>$person)->wrote->(<record<book>>$book); };
An INSERT RELATION
could be an option too, since it can be written over multiple lines for readability.
DEFINE FUNCTION fn::set_writer($person: string, $book: string) { INSERT RELATION INTO wrote { in: <record<person>>$person, out: <record<book>>$book }; };
Assuming that we are working with the following schema that requires wrote
to go from a record person
to a record book
:
DEFINE TABLE wrote TYPE RELATION in person out book;
The original messages are fairly good, but could they be improved?
fn::set_writer("person:one", "blog:two"); -- Query 1 (execution time: 2.510667ms) 'Found blog:two for field `out`, with record `wrote:te9onqg9xnif9625apk4`, but expected a record<book>' fn::set_writer("personone", "bookone"); -- Query 1 (execution time: 1.398708ms) "Expected a record but cannot convert 'personone' into a record"
SurrealDB has a function that lets you check whether a string is a properly formatted Record ID or not.
Here is one way to do it, with a single error message for each parameter if the input is incorrect:
DEFINE FUNCTION fn::set_writer($person: string, $book: string) { IF "person" NOT IN $person OR !$person.is_record() { THROW "First argument must be a person record, such as `person:one`" }; IF "book" NOT IN $book OR !$book.is_record() { THROW "Second argument must be a book record, such as `book:one`" }; INSERT RELATION INTO wrote { in: <record<person>>$person, out: <record<book>>$book }; };
Here are two examples of the new error output:
fn::set_writer("personone", "berktwo"); -- Query 1 (execution time: 1.529375ms) 'An error occurred: First argument must be a person record, such as `person:one`' fn::set_writer("person:one", "berktwo"); -- Query 1 (execution time: 820.291µs) 'An error occurred: Second argument must be a book record, such as `book:one`'
You could try making the errors even more precise if you like. For example, you could first return an error message if the string::is::record()
method fails, and then uses the record::tb()
to check the table name of the record.
Here is an example of the function in action. It should establish that Philip II is the parent of Alexander the Great, that Amyntas III is the parent of Philip II, and so on. To make it simple, we’ll assume that all of these people are already in the database but aren’t related to each other yet.
LET $tree = ["Alexander the Great", "Philip II", "Amyntas III", "Arrhidaeus"]; FOR $name IN $tree { CREATE person SET name = $name }; fn::set_ancestry($tree);
SurrealDB has an array function that makes it easy to slide one index at a time down an array.
The array::windows()
function can help here. You can pass in the array as well as the number 2 to instruct it to return a sliding window of two items at a time: Alexander to Philip, then Philip to Amyntas, and so on.
RETURN ["Alexander the Great", "Philip II", "Amyntas III", "Arrhidaeus"].windows(2);
Response[ [ 'Alexander the Great', 'Philip II' ], [ 'Philip II', 'Amyntas III' ], [ 'Amyntas III', 'Arrhidaeus' ] ]
With this function it’s now easy to move from one parent-child pair down the entire list.
LET $tree = ["Alexander the Great", "Philip II", "Amyntas III", "Arrhidaeus"]; FOR $name IN $tree { CREATE person SET name = $name }; DEFINE FUNCTION fn::set_ancestry($input: array<string>) { FOR $names in $input.windows(2) { LET $person1 = SELECT * FROM ONLY person WHERE name = $names[0] LIMIT 1; LET $person2 = SELECT * FROM ONLY person WHERE name = $names[1] LIMIT 1; RELATE $person2->parent_of->$person1; } }; fn::set_ancestry($tree);
Finally, let’s see who the parent of each person is.
SELECT name, <-parent_of<-person.name as parent_name FROM person;
Response[ { name: 'Alexander the Great', parent_name: [ 'Philip II' ] }, { name: 'Amyntas III', parent_name: [ 'Arrhidaeus' ] }, { name: 'Arrhidaeus', parent_name: [] }, { name: 'Philip II', parent_name: [ 'Amyntas III' ] } ]
The array of objects looks like the data below, in which sometimes age is set to a string that doesn’t parse into a number, and sometimes doesn’t include a name. How would you filter out everything unless the object has a proper age
field and a name
?
[ { age: 'Ten', name: 'Billy' }, { age: 15, name: 'Barney' }, { age: 20, name: "Jergon" }, { age: 20 }, { age: 45, name: "Johnny" } ];
You can use an array filter at the end with two requirements: that name IS NOT NONE
and that age.is_number()
.
RETURN [ { age: 'Ten', name: 'Billy' }, { age: 15, name: 'Barney' }, { age: 20, name: "Jergon" }, { age: 20 }, { age: 45, name: "Johnny" } ][WHERE name IS NOT NONE and age.is_number()];
Response[ { age: 15, name: 'Barney' }, { age: 20, name: 'Jergon' }, { age: 45, name: 'Johnny' } ]
Here is an example of a possible error type in some other programming language that holds some data inside each possible error.
type Error { InvalidRole(string), NotAllowed { actor: string, action: string, resource: string } }
A literal could be a good option here. You could define it on a field to match the original code pretty closely, as follows:
DEFINE field error ON TABLE data TYPE { InvalidRole: string } | { NotAllowed: { actor: string, action: string, resource: string } };
After this, you could use IF LET
statements to see if the first field in the error
field is InvalidRole
or NotAllowed
. Here is a simple check of this type inside a function that takes a single record that holds this error
data, and creates a separate record to show that the match has worked.
DEFINE FUNCTION fn::match_error($data: record<data>) { IF $data.error.InvalidRole { CREATE stuff SET it_worked = "It worked! Got an InvalidRole", content = $data.error; } ELSE IF $data.error.NotAllowed { CREATE stuff SET it_worked = "It worked! Got a NotAllowed", content = $data.error; } ELSE { THROW "Nothing matched!" } };
We can follow this up with two sample data
records…
CREATE data:one SET error = { InvalidRole: "BadRole" }; CREATE data:two SET error = { NotAllowed: { actor: "Bad actor", action: "Bad action", resource: "Bad resource" } };
And then three queries to show that the error handling worked.
fn::match_error(data:one); fn::match_error(data:two); SELECT * FROM stuff;
Response[ { content: { InvalidRole: 'BadRole' }, id: stuff:w30i3vvfct7koet1txv1, it_worked: 'It worked! Got an InvalidRole' }, { content: { NotAllowed: { action: 'Bad action', actor: 'Bad actor', resource: 'Bad resource' } }, id: stuff:xon52v8zurodv3d5lnit, it_worked: 'It worked! Got a NotAllowed' } ]