Core concepts
Language overview
Rell is a language for relational blockchain programming. It combines the following features:
- Relational data modelling and queries similar to SQL. People familiar with SQL should feel at home once they learn the new syntax.
- Normal programming constructs: variables, loops, functions, collections, etc.
- Constructs that specifically target app backends and, in particular, blockchain-style programming, including request routing, authorization, etc.
Rell aims to make programming as ergonomic as possible. It minimizes boilerplate and repetition. At the same time, as a A static type system can detect and prevent many kinds of defects.
Blockchain programming
There are many different styles of blockchain programming. In the context of Rell, we see blockchain as a method for securely synchronizing databases on the system nodes. Thus, Rell is very database-centric.
Programming in Rell is identical to programming app backends: you need to handle requests to modify the data in the database and other requests to retrieve data from a database. Handling these two types of requests is all that a backend does. But, of course, before you implement request handlers, you need to describe your data model first.
Entity definitions
You usually define your data model using SQL's CREATE TABLE
syntax. In Java, you can define data objects using class
definition.
In Rell, we define them as entity
. An entity is similar to a table in relational databases. So, let's create our
Book
entity. We will first add the Rell code needed to establish the entity and then delve into the specifics.
Rell uses persistent objects. Thus, entity definition automatically creates the storage (e.g. a table) necessary to persist objects of an entity. As you might expect, Rell’s entity definition includes a list of attributes:
entity book {
key isbn: text;
title: text;
author: text;
}
-
The
entity
keyword initiates the definition of our entity, similar to creating a table in a database. -
Attributes, like columns in a database table, each hold specific data types related to the book.
-
key isbn: text
this is our unique index for each book, and is defined in Rell as the key for the entity. Each book possesses a distinct ISBN, and thekey
keyword underscores this uniqueness.text
designates that the ISBN is stored as textual data. -
title: text
andauthor: text
are self-explanatory attributes containing textual information about the book's title and author, respectively.
Examples:
Action | Entity | Description |
---|---|---|
Define basic entity | entity user { pubkey; name; } | Create an entity user with attributes pubkey and name . |
Add unique identifier (key) | entity user { key name; } | Define a unique key based on the name attribute for the user entity. |
Create an index for efficient retrieval | entity user { key name; index pubkey; } | Add an index for the pubkey attribute to enable fast retrieval in the user entity. |
Ensure uniqueness with multiple keys | entity user { key name; key pubkey; } | Define both name and pubkey as keys to ensure uniqueness within the user entity. |
Establish one-to-many relationship | entity channel { index owner: user; key name; } | Create an entity channel with an index on owner and a key based on name . |
Create a composite key for contextual uniqueness | entity channel { key owner: user, name; } | Define a composite key (owner and name ) for ensuring uniqueness within a user's context. |
Make user reference unique per channel table | entity channel { key owner: user; key name; } | Use separate keys (owner and name ) to enforce unique references in the channel entity. |
Managing data requests
In Rell, we handle requests through two types of actions:
- Data-modifying requests (Operations): These operations modify the database state.
- Data-retrieving requests (Queries): These are requests for retrieving data.
But we will need to refer to things in the database for both types of requests, so let’s consider relational operators first.
Relational operator basics
First, let’s look at how we create objects:
create user (pubkey=x"0373599a61cc6b3bc02a78c34313e1737ae9cfd56b9bb24360b437d469efdf3b15", name="Alice");
This is essentially the same as SQL's INSERT
operation, but the syntax is slightly different. Rell is smart enough to
identify the connection between arguments and attributes based on their type. x"..."
notation is a hexadecimal
byte_array
literal compatible with the pubkey
type. On the other hand, the name is provided via text
literal.
Thus, we can write:
create user("Alice", x"0373599a61cc6b3bc02a78c34313e1737ae9cfd56b9bb24360b437d469efdf3b15");
The order of arguments does not matter here; they are matched with attributes based on type.
How do we find that object now?
val alice = user @ { .name == "Alice" };
The @
operator retrieves a single record (or an object in this case), satisfying the search criteria you provided. If
no such record exists or more than one exists, it raises an error. It’s recommended to use this construct when an
operation needs a single record to operate on. If this requirement is violated, the operation will be aborted, and all
its effects will be rolled back. Thus, it is a concise and effective way to deal with requirements.
(val
defines a read-only variable, which can later be used in an expression. A variable defined using var
can be
reassigned later.)
You can use the @*
operator to retrieve a list of users. For example:
val all_users = user @* { };
This returns a list of all users (since no filter expression was provided, all users match it). Value declarations can
include a type, for example; we can specify that all_users
is of type list<user>
like this:
val all_users: list<user> = user @* { };
Since the Rell compiler knows the type of every expression, it does not need a type declaration. However, if one is provided, it will check against it. Type declarations are mostly valid as documentation for programmers reading the code and should be omitted in cases where there is no ambiguity.
@
and @*
correspond to SELECT
in SQL.
Operations
An operation in Rell is similar to the functionality of API endpoints using POST
, UPDATE
, and DELETE
methods.
These operations allow you to make changes to the database, including adding new data, updating existing records, and
deleting entries. Unlike queries, which only allow data retrieval (similar to the GET
method in APIs), operations
enable modifications to the database.
This capability is crucial for maintaining and updating data, ensuring that the database reflects the most current information. For instance, adding a new book entity, updating an existing book’s details, or removing a book from the database would be performed through Rell operations, akin to how these actions are executed in APIs.
Let's create a create_book
operation, which enables us to store book entries by committing transactions to the
blockchain.
In Rell, operations serve as functions that can be included in a transaction.
Let's define the operation for creating a new book:
operation create_book(isbn: text, title: text, author: text) {
create book(isbn = isbn, title = title, author = author);
}
Let’s go through this line by line. First, we declare the operation name and a list of parameters:
operation create_book(isbn: text, title: text, author: text)
This is very similar to function definitions in other languages. An operation is a function of a particular kind: it can be invoked using a blockchain transaction by its name.
Whenever you encounter the operation
keyword, think of it as a function where the function body contains logic to
change the dapp table state. A transaction can contain one or many operations and the logic of each operation will alter
the dapp table state.
operation create_book(isbn: text, title: text, author: text)
Our operation create_book
is designed to accept three parameters: an ISBN, a title, and an author for the book. Inside
the operation, we have the create
command with parameters from the operation.
Queries
A query in Rell is similar to performing a SELECT
operation in a SQL database and can be thought of as analogous to an
API endpoint using the GET
method. Queries in Rell allow you to retrieve specific data from the database, just as a
SELECT
operation would in SQL.
Let’s consider a simple example: retrieving a collection of book entities. This Rell query would be used to select all books from a database, much like how you would use a SQL query to select rows from a table. This approach enables efficient data access and manipulation, similar to how API endpoints allow data retrieval in web applications.
query get_all_books() {
return book @* { } (
.isbn,
.title,
.author
);
}
This query employs the @*
operand in book @*
to retrieve a collection of book entities. @*
means that we expect 0
or more objects of this type from the query. The curly braces at the end, as seen in book @* {}
, is where we would
specify our filter criteria for the query. We'll keep it simple and talk more about the filter criteria in later
lessons.
The subsequent segment of the query defines which attributes we wish to retrieve. It's analogous to specifying columns
in a SELECT
statement.
In this case, we intend to retrieve:
.isbn
.title
.author
Combining these components, we formulate a query that retrieves all books in a collection, with each item containing the
attributes isbn
, title
, and author
.
Functions
Sometimes, multiple operations (or queries) need the same piece functionality, e.g., a validation code or code which
retrieves objects in a particular way. To avoid repeating yourself, you can use function
. Functions work similarly to
operations: they get some input, perform validations, and work with data. They also have a return type that can be
specified after the list of parameters.
Example:
function create_book(title: text, library_name: text, shelf_number: integer) {
val library = get_library_by_name(library_name);
val shelf = get_shelf_by_number(library, shelf_number);
create book ( title, shelf );
}
The function create_book
created a new book entity within a specific library and shelf.
- The function calls
get_library_by_name
with thelibrary_name
parameter to retrieve the library object. - Using the previously retrieved
library
object, the function callsget_shelf_by_number
withlibrary
andshelf_number
as arguments to get the specific shelf within that library. - The
create
statement is used to create a new book entity with the giventitle
, and it associates this book with theshelf
identified in the previous step.
Relational expressions
In general, a relational expression consists of five parts, some of which can be omitted:
FROM CARDINALITY WHERE WHAT TAIL
entity_name @ { condition } () ;
-
FROM: represents the table from where to make the query. Same as SQL FROM.
-
CARDINALITY : specifies the number of results that are expected from the query. The query will fail if this condition can't be matched.
- @ exactly one result
- @? zero or one result
- @+ more than one result
- @* zero or many results
-
WHERE: represents a filter similar to the WHERE part of an SQL query.
-
WHAT: The actual values to retrieve from the query. It can be one or several attributes or left empty to get a reference (rowid) of the entity. Here, you can also sort, group, or omit fields.
-
TAIL: Additional SQL-related statements such as
limit
oroffset
that can be used when retrieving multiple results.
In SQL, the logical processing order does not match the order in which clauses are written. For example, FROM
is
logically processed before SELECT
even though SELECT
comes first. (SQL logical processing order can be found, e.g.
in SQL Server documentation).
The order of components of a relational expression in Rell matches the logical processing order. So, first, a set is defined, then it is filtered, and then it is post-processed. Of course, the query planner can perform operations in a different order, but that shouldn’t affect the results. Thus, a relational expression can be understood as a pipeline.
Let’s see an example. In this example, there is a message entity from where we'll search the messages of a specific user.
entity message {
index channel;
index timestamp;
text;
}
We can retrieve all messages of a given user:
(channel, message) @* {
channel.owner == given_user,
message.channel == channel
}(message.text);
So, we joined the channel
with the message
. We can shorten the expression using entity aliases:
(c: channel, m: message) @* {
c.owner == given_user,
m.channel == c
}(m.text, m.timestamp);
We can easily read this expression from left to right:
- consider all pairs
(c, m)
wherec
ischannel
andm
ismessage
- find those where
c.owner
equalsgiven_user
andm.channel
equalsc
- extract
text
andtimestamp
fromm
The result of this expression is a list of tuples with text
and timestamp
attributes.
The above expression can be easily modified to retrieve the latest 25 messages:
(c: channel, m: message) @* {
c.owner == given_user,
m.channel == c
}(m.text, -sort m.timestamp) limit 25;
Here, we sorted results by timestamp in a descending order using -sort
(minus prefix means descending) and limited the
number of returned rows.
Keys and indexes
Indexes are crucial in improving database performance by allowing faster data retrieval. To understand the significance of indexes in Rell, let's start with the basics.
Imagine that you have a large dataset and need to find a specific entry within it. Without an index, searching through all the data can be time-consuming with a worst-case time complexity of O(n). However, when an attribute is indexed in Rell, it dramatically enhances query performance, reducing the time complexity to O(log(n)), thanks to PostgreSQL's underlying binary tree structure.
In Rell, you have two options to mark an attribute for indexing: key
and index
. While both improve query
performance, they serve different purposes:
Key
A key
is an index that enforces a unique constraint on the indexed attribute. This uniqueness constraint guarantees
that entities with the same attribute value will not exist in your database, making it less error-prone when creating
entries.
Index
An index
is used to improve query performance for non-unique attributes.
Note that both keys and indexes introduce extra time in creating entities, so be sure to mark your attributes cautiously.
Key and index example
Consider the scenario where multiple houses share the same street, each with its unique id. This could be modelled like:
entity house {
key id: integer;
index street: text;
}
Now, the following queries would be high performant:
val unique_house = house @ {
.id == 10
};
val houses_on_main_street = house @* {
.street == "main street"
};
Composite index
To further optimize your database queries, you can add a composite index. This is particularly useful when finding entries based on a combination of attributes. For instance, if your app often queries all houses owned by a specific owner on a particular street, you can create a multi-column inde
index owner, street;
The order of the fields in a multi-column index is crucial for performance. This is how composite indexes work in SQL, where each column precedes the latter. To create the most efficient index, place the most significant attribute first. For instance, if you want to query all houses with exactly four rooms and a floor area greater than 100:
house@ { .number_of_rooms == 4, .floor_area > 100 };
In this case, an optimal index configuration would be:
index number_of_rooms, floor_area;
Composite keys work the same way. If a specific combination of entries can only exist once, adding a composite key will ensure this constraint in the database. Place the attribute yielding the fewest results first to achieve optimal performance.
Example:
This example is focused on creating the house
records and requesting specific house
records.
Add the house
entity:
entity house {
index street;
number: integer;
key street, number;
number_of_rooms: integer;
number_of_floors: integer;
floor_area: integer;
index number_of_rooms, floor_area;
}
Let's query the newly created house
records:
query get_specific_houses(
number_of_rooms: integer,
floor_area: integer
) = house @* { .number_of_rooms == number_of_rooms, .floor_area >= floor_area } ( $.to_struct() );
The result of the query above is as follows:
[
[
"floor_area": 200,
"number": 30,
"number_of_floors": 1,
"number_of_rooms": 4,
"street": 1
],
[
"floor_area": 109,
"number": 32,
"number_of_floors": 1,
"number_of_rooms": 4,
"street": 1
]
]
While common with SQL databases, this approach is vital in resource-constrained decentralized systems like Rell. As a developer, it would be best to comprehend query behavior and index utilization for optimal results.