Skip to main content

Core concepts

Language overview

Rell is a language for relational blockchain programming. It combines the following features:

  1. Relational data modelling and queries similar to SQL. People familiar with SQL should feel at home once they learn the new syntax.
  2. Normal programming constructs: variables, loops, functions, collections, etc.
  3. 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 the key keyword underscores this uniqueness. text designates that the ISBN is stored as textual data.

  • title: text and author: text are self-explanatory attributes containing textual information about the book's title and author, respectively.

Examples:

ActionEntityDescription
Define basic entityentity 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 retrievalentity user { key name; index pubkey; }Add an index for the pubkey attribute to enable fast retrieval in the user entity.
Ensure uniqueness with multiple keysentity user { key name; key pubkey; }Define both name and pubkey as keys to ensure uniqueness within the user entity.
Establish one-to-many relationshipentity 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 uniquenessentity 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 tableentity 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:

  1. Data-modifying requests (Operations): These operations modify the database state.
  2. 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 the library_name parameter to retrieve the library object.
  • Using the previously retrieved library object, the function calls get_shelf_by_number with library and shelf_number as arguments to get the specific shelf within that library.
  • The create statement is used to create a new book entity with the given title, and it associates this book with the shelf 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 } () ;
  1. FROM: represents the table from where to make the query. Same as SQL FROM.

  2. 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
  3. WHERE: represents a filter similar to the WHERE part of an SQL query.

  4. 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.

  5. TAIL: Additional SQL-related statements such as limit or offset 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) where c is channel and m is message
  • find those where c.owner equals given_user and m.channel equals c
  • extract text and timestamp from m

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

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:

result
[
[
"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
]
]
note

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.