Benjamin Geer

Writing a hypermedia app in Rust

Benjamin Geer
Table of Contents

This is the third post in a series:

  1. Writing safe hypermedia apps for a resource-constrained world, which introduces the series
  2. Writing a hypermedia app in Go
  3. this post

Overview of the app #

The example application in Hypermedia Systems is a simple CRUD application that manages a list of contacts. It illustrates how a Hypermedia-Driven Application can provide a good user interface as an alternative to single-page applications. With a tiny JavaScript library and some declarative attributes on HTML elements, all sorts of user interactions can fetch fragments of HTML from the server and update only certain parts of the page. For example, as we page through the list of contacts, or delete a contact from the list, the list changes without reloading the page.

Let’s implement this app in Rust, with two objectives:

  1. Make it more realistic by adding an internationalised UI, a relational database with a full-text search index, better input validation, cursor-based pagination, and UI tests.
  2. Make it easy to maintain, with a minimum of boilerplate, and an emphasis on compile-time checks to prevent bugs.

We’ll start with a SQLite database, and switch to PostgreSQL in a later step. You can find the complete source code of both versions of the app here:

Libraries #

Our first task is to choose some libraries.

HTML templates #

Our user interface is based on HTML templates. There’s a layout template that provides a header and shows informational messages (like ‘Contact added’) in response to user actions. Other templates, such as a form for editing a contact, can be embedded in this layout. There are also templates for fragments of HTML that replace one or more parts of the currently displayed page. For example, when you click on a pagination link, the list of contacts changes, along with the pagination links.

So we’ll need a template engine, preferably a type-safe one. There are basically two kinds of type-safe template engines: the kind that lets you write something close to standard HTML, and the kind that lets you express the structure of an HTML document in a programming language. I prefer the first kind, because the closer the template syntax is to HTML, the easier it will be for a UX designer, or anyone who knows HTML, to work on the templates without having to know Rust.

I’ve chosen Askama, which is easy to use, and compiles templates to Rust code. Since its syntax is based on Jinja, editor tooling that works with Jinja will also work with Askama. To use it, we write our templates in HTML files; for each template, we write a Rust struct definition that contains the template’s variables and refers to the HTML file via a macro. In our HTTP endpoint implementations, we call that struct’s render method to generate HTML.

Internationalisation #

We’d like our app to be available in multiple languages; in this example, I’ve implemented support for English and French. To facilitate maintenance, we want to avoid having different versions of the same template for different languages, or cluttering up the templates with different translations of the same messages. So we need a library that allows us to call a function from a template and get the translation of a message in the target language.

One important consideration is what the library’s designers chose to use as a message identifier. Some libraries (like gettext) use the whole message text in the default language as the message identifier. The limitations of this approach are well expressed in the social contract of Project Fluent:

First of all, it means that any change to the source string invalidates all translations of the string. This severely increases the burden on the developers to never alter messages in the source language as it results in all translations having to be updated.

Secondly, it makes it harder to introduce multiple messages with the same source string which should be translated differently.

Therefore, I prefer libraries that use unique message identifiers chosen by developers.

Another important criterion in choosing a library is how it handles plurals. While plurals are relatively simple in English (there are just two categories, singular and plural), many languages have more categories, along with syntactic rules that require a sentence to be adjusted in more complex ways depending on the number expressed. A good internationalisation library should implement the Unicode CLDR plural rules, which support over 200 languages.

I was interested in trying Project Fluent, but it looks as if that project may have been abandoned. So I’ve chosen karatepe, which has the features we need. Karatepe provides a simple DSL for defining messages and writing translations, and a macro that compiles message definitions into Rust functions. For example, here’s a message definition that takes a numerical parameter amount:

1
contacts_count(amount: ..)

And here’s its English translation:

1
2
3
4
contacts_count(amount) = ({amount} total {amount => {
    one => contact
    _ => contacts
  }})

The message definition is compiled to a Rust function, contacts_count, which we can call directly from a template. At run time, we can access a message translation via a Translation for the target language; we’ll see below how we get the correct one.

Since we’re internationalising the app, we can also improve the cross-cultural validity of our field names: instead of ‘first name’ and ‘last name’, which can lead to misunderstandings (in Chinese names, the family name comes first), we’ll follow the recommendation in the FOAF ontology and use ‘given name’ and ‘family name’.

Database access and schema migration #

We could use an object-relational mapping (ORM) library for database access, but I think it’s better to write our SQL ourselves. ORMs can produce inefficient queries.1 Writing our own SQL allows us to take full advantage of the features of modern SQL and its opportunities for query optimisation, particularly by designing queries and indexes together, and by refining queries in light of the execution plans of the database’s query optimiser. (See my post Optimising PostgreSQL queries with an open dataset for an example.)

I think the ideal is to have a library that provides type safety for SQL queries, so queries are checked at compile time and automatically return Rust structs. The SQLx crate does just this, and can return our own structs by deriving its FromRow trait.

For example, suppose we misspell a column name (familyname instead of family_name) in an SQL query:

1
2
3
4
5
6
7
8
let contact: Contact = sqlx::query_as!(
    Contact,
    "SELECT id, familyname, given_name, phone, email
    FROM contact WHERE id = $1",
    id
)
.fetch_one(&mut *tx)
.await?;

By default, the query_as! macro will connect to the development database at compile time to check the query, and we’ll get a compile error:

1
2
error: error returned from database: (code: 1) no such column: familyname
   --> src/db.rs:236:23

SQLx also uses prepared statements to prevent SQL injection attacks, and provides a command-line tool for managing database schema migrations.

HTTP server framework #

We’ll use axum, which conveniently implements type-safe mappings between requests and Rust structs via Serde, and is easy to use with Askama.

UI testing #

If we were using the SPA approach, our server would have a JSON API, and we could write tests that check whether it returns the correct JSON for various requests. But our application returns HTML. So we’ll write tests that are analogous to JSON API tests, focusing on the functionality of our HTML responses. Using the dom_query crate, we’ll parse the HTML responses and use CSS selectors to check the contents of specific elements. This allows the graphic design to change without breaking our tests.

Database design #

(If you’ve already read the Go version of this post, you can skip this section.)

Our database is very simple. There’s just one table, contact:

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS contact (
  id INTEGER PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL
) STRICT;

But there are a few details to think about.

I’ve defined the contact table as STRICT. By default, SQLite has a very flexible type system, which some people like. I prefer to get errors when there’s a bug in my program. With the STRICT option, SQLite will do strict type checking at run time, in addition to the compile-time checks that sqlc provides.

Nullable columns add a bit of complexity to our code, so to keep things simple for this example, we’ll allow only the phone column to be null.

The app should display the contact list sorted in some order; we’ll use ORDER BY family_name, given_name, email. To make this efficient, we’ll add an index with that sort order:

1
2
CREATE INDEX IF NOT EXISTS idx_contact_sort
ON contact (family_name, given_name, email);

Since the app requires email addresses to be unique, we have a UNIQUE constraint on the email field, and we’ll need a query like this:

1
SELECT 1 FROM contact WHERE email = ?;

To make this query efficient, we’ll put an index on the email column:

1
CREATE INDEX IF NOT EXISTS idx_contact_email ON contact (email);

We want a full-text search index that allows us to find contacts whose name matches a string. We’ll let the user type the first letters of given_name and/or family_name and get a list of matching contacts. Fortunately, SQLite has a full-text search module, FTS5. To use it, we create a virtual table to store the index:

1
2
3
4
5
6
7
8
CREATE VIRTUAL TABLE IF NOT EXISTS contact_fts USING fts5(
    family_name,
    given_name,
    phone UNINDEXED,
    email UNINDEXED,
    content = 'contact',
    content_rowid = 'id'
);

We’ll use database triggers to keep the FTS5 table up to date with the content table. And that completes our database schema.

Application architecture #

For this simple CRUD app, a model-view-controller architecture is sufficient. The views are HTML templates, and the controllers are HTTP routes. The main type in the model is a struct called Contact, which we’ll get to in a moment. The database abstraction layer is just a struct called Database that contains the connection pool and has methods like this:

1
2
3
pub async fn get(&self, id: ContactId) -> Result<Contact, DatabaseError> {
    // ...
}

Parsing, not validating #

We’ll apply Alexis King’s principle, Parse, don’t validate. In short, instead of representing, say, an email address as a &str, we use the newtype pattern and create a type for it, called Email, which can only be constructed from valid input. This has several beneficial effects:

We’ll use the nutype crate to define our newtypes. It offers a variety of built-in validation functions, e.g. for validating with a regular expression. Since this is an internationalised application, we’ll use Unicode regular expressions to support:

For example, my simple attempt at a name regex accepts groups of Unicode letters and marks, separated by spaces and/or punctuation:

1
^[\p{L}\p{M}]+(?:(?:\p{P}\p{Zs}|[\p{P}\p{Zs}])[\p{L}\p{M}]+)*\p{P}?$

So if your given name is 曼玉-فاتن, all is well.

Now we can define a newtype:

1
2
3
4
5
6
7
#[nutype(
    validate(regex = NAME_REGEX, len_char_max = 50),
    derive(Debug, Display, PartialEq, Clone, AsRef, Serialize),
    derive_unchecked(sqlx::Type),
)]
#[sqlx(transparent)]
pub struct GivenName(String);

This generates a constructor, GivenName::try_new, which takes a string and returns a Result<GivenName, GivenNameError>. We’ve also specified that we want SQLx to be able to construct a GivenName without validation, because we assume that the data in the database has already been validated. We also have the option of not trusting the database, and parsing GivenName in the normal way in the database layer.

After defining a few other newtypes in the same way, we can define Contact:

1
2
3
4
5
6
7
8
#[derive(Debug, PartialEq, sqlx::FromRow)]
pub struct Contact {
    pub id: ContactId,
    pub family_name: FamilyName,
    pub given_name: GivenName,
    pub phone: Option<Phone>,
    pub email: Email,
}

When the user is creating a new contact, it doesn’t have an ID yet, so we also define ContactContent:

1
2
3
4
5
6
7
#[derive(Debug, PartialEq, Serialize)]
pub struct ContactContent {
    pub family_name: FamilyName,
    pub given_name: GivenName,
    pub phone: Option<Phone>,
    pub email: Email,
}

What should happen when the user submits a form to create a contact? We don’t want axum to deserialise the form data directly to a ContactContent, because that would stop parsing at the first error. Instead, we want to collect multiple validation errors and report them all back to the user. The form has a <span class="error"> under each input field for displaying these errors. We can represent the unparsed form input as a struct like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use serde_with::{NoneAsEmptyString, serde_as};

#[serde_as]
#[derive(Debug, Default, Deserialize)]
pub struct ContactForm {
    pub family_name: String,
    pub given_name: String,

    #[serde_as(as = "NoneAsEmptyString")]
    pub phone: Option<String>,

    pub email: String,
}

We’d like to parse a ContactForm and get either a ContactContent or a collection of errors. There isn’t yet a way to automate this completely, but we can write a short function to do it using the frunk crate of functional programming tools. We’ll use its Validated type, which collects the results of several operations. If there are no errors, Validated::into_result produces an HList, a statically typed list whose elements can have different types. Given an HList, we can easily construct our ContactContent. If any errors occurred, we get them in a Vec.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use frunk::prelude::*;
use frunk_core::hlist_pat;

impl ContactForm {
    pub fn parse(&self) -> Result<ContactContent, Vec<ParseError>> {
        let validated = FamilyName::try_new(&self.family_name)
            .map_err(ParseError::from)
            .into_validated()
            + GivenName::try_new(&self.given_name).map_err(ParseError::from)
            + self.phone.as_ref()
                .map(|phone| Phone::try_new(phone)
                    .map_err(ParseError::from))
                .transpose()
            + Email::try_new(&self.email).map_err(ParseError::from);

        validated
            .into_result()
            .map(
                |hlist_pat!(family_name, given_name, phone, email)|
                    ContactContent {
                        family_name,
                        given_name,
                        phone,
                        email,
                    },
            )
    }
}

Our ParseError type contains the original error (GivenNameError, etc.), so we can easily determine which fields had errors. We can then pass the ContactForm and a collection of translated error messages to the template.

If validation was successful, we can pass the ContactContent to Database::add, which looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub async fn add(&self, contact: &ContactContent) -> Result<(), Error> {
    let mut tx = self.pool.begin().await?;

    sqlx::query!(
        r#"INSERT INTO contact (family_name, given_name, phone, email)
            VALUES ($1, $2, $3, $4)"#,
        &contact.family_name,
        &contact.given_name,
        &contact.phone,
        &contact.email
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

Error handling #

Whose fault is it? #

Our error handling strategy distinguishes between errors that are the client’s fault and ones that are the server’s fault. We can use the thiserror crate to simplify wrapping errors in other errors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Client error: {0}")]
    Client(#[from] ClientError),

    #[error("Server error: {0}")]
    Server(ServerError),
}

#[derive(Error, Debug)]
pub enum ClientError {
    #[error("Page not found")]
    NotFound,

    // ...
}

#[derive(Error, Debug)]
pub enum ServerError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),

    // ...
}

#[derive(Error, Debug)]
pub enum DatabaseError {
    #[error("{0}")]
    Sqlx(#[from] sqlx::Error),
}

impl<E> From<E> for Error
where
    E: Into<ServerError>,
{
    fn from(err: E) -> Self {
        Error::Server(err.into())
    }
}

If an error is the client’s fault, we’ll just report it to the user, e.g. by displaying ‘Page not found’. If it’s the server’s fault (e.g. it’s a DatabaseError), we’ll display ‘Internal server error’ and log the error details.

Centralising error handling #

I like to centralise error handling so it doesn’t clutter up the rest of the code. With axum, a route handler returns a Result whose error type is anything that implements the IntoResponse trait. Therefore, with a bit of plumbing, we can use the ? operator in our route handler functions to log the error if necessary and return an error page using our error.html template. We make an error type called ErrorResponse that just holds an HTTP status code and some HTML, and we implement IntoResponse for it. The we write a method on our Error type that produces an ErrorResponse using the template, with a translated error message, and logs the error if it’s the server’s fault. This method has to take a &Translation parameter so the template can get the error message in the target language:

1
2
3
4
5
impl Error {
    pub fn to_error_response(self, t: &Translation) -> ErrorResponse {
        // ...
    }
}

Finally, we write an extension trait for Result to convert any error into an ErrorResponse:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub trait ResultErrorTranslationExt<T, E>
where
    E: std::error::Error,
    Error: From<E>,
{
    fn tr_err(self, t: &Translation) -> Result<T, ErrorResponse>;
}

impl<T, E> ResultErrorTranslationExt<T, E> for Result<T, E>
where
    E: std::error::Error,
    Error: From<E>,
{
    fn tr_err(self, t: &Translation) -> Result<T, ErrorResponse> {
        self.map_err(|err| Error::from(err).to_error_response(t))
    }
}

Now we can write route handler functions that return Result<Html<String>, ErrorResponse> and handle errors very concisely. For example, if the db.get method can return DatabaseError, we can call it like this:

1
2
let t: &Translation = // ...
let contact = app.db.get(id).await.tr_err(t)?;

Routes and languages #

Before we define HTTP endpoints, we should decide how the app will know which language to use. The Accept-Language request header allows the browser to provide a ranked list of the languages that the user prefers, but we also have to allow the user to select a language just for this app:

This header serves as a hint when the server cannot determine the target content language otherwise (for example, use a specific URL that depends on an explicit user decision). The server should never override an explicit user language choice. The content of Accept-Language is often out of a user’s control (when traveling, for instance). A user may also want to visit a page in a language different from the user interface language.

Let’s put a language switcher in the page header, and put a BCP 47 language tag at the beginning of every endpoint’s path. For the root path, we can use the Accept-Language header, and redirect to a path containing a language tag.

With the karatepe crate, we load a Translation for each language on startup. We’ll keep these in a HashMap (with icu::locale::LanguageIdentifier as the key type) in the application’s shared state, along with the Database. This is our AppState struct:

1
2
3
4
pub struct AppState {
    pub db: Database,
    pub translations: Arc<HashMap<LanguageIdentifier, Translation>>,
}

axum’s Router can hold onto this state for us so the route handler functions can access it. We set it up like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pub fn init_router(app: AppState) -> Router {
    Router::new()
        .route("/", get(index)) // checks Accept-Language
        .route(
            "/{lang}/contacts",
            get(contacts::contacts_get).delete(contacts::contacts_delete),
        )
        .route(
            "/{lang}/contacts/{id}",
            get(contacts::contact_get).delete(contacts::contact_delete),
        )
        // ...
        .with_state(app)
}

axum provides extractors that handler functions can use to extract and parse data in the request. We use the Path extractor to parse the language code as a LanguageIdentifier (which implements serde::Deserialize), so we don’t need to make our own newtype for this. Then we just need a little function called translation that takes a LanguageIdentifier and returns the corresponding Translation from the AppState.

Let’s not make one of those annoying websites whose language switcher takes you back to the home page. We can write a function, uris_with_langs, that takes the current URL path, adjusts it for every available language, and returns a Vec of the resulting paths. In the page header, we display those URLs as links.

Our template for displaying a contact is called show.html, and its variables are in struct ShowContactTemplate. So our contact_get route now looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
pub async fn contact_get(
    State(app): State<AppState>,
    Path((lang, id)): Path<(LanguageIdentifier, ContactId)>,
    uri: Uri,
) -> Result<Html<String>, ErrorResponse> {
    let t = app.translation(&lang);
    let contact = app.db.get(id).await.tr_err(t)?;

    let template = ShowContactTemplate {
        t,
        lang,
        lang_uris: localisation::uris_with_langs(uri).tr_err(t)?,
        contact,
    };

    Ok(Html(template.render().tr_err(t)?))
}

Pagination #

(If you’ve already read the Go version of this post, you can skip this section and the next.)

The pagination in Hypermedia Systems is based on page numbers, which is inefficient (the database always has to retrieve all the pages preceding the one you asked for) and can produce unexpected results if the data is being modified while you’re paging through it. A better approach is cursor-based pagination. When returning a page of results, the database layer can return two cursors, called prev and next, which the view can present as links to the previous and next pages. There are a few tricks involved in making this work.

Our cursor will contain the values of the columns family_name, given_name, and email, which are the same ones we use to sort contacts. Since we already have an index on those three columns, in that order, the database can efficiently retrieve a page of rows before or after a cursor.

We’d like to avoid returning a prev cursor if we’ve reached the first page, or a next cursor if we’ve reached the last one. The solution is as follows: in a ‘page after’ request, if our maximum page size is N, we request N + 1 rows. If we get that many rows, we know a next page exists, so we return a next cursor based on the last row in the returned page. If we were given a next cursor in the request, we can assume there’s a previous page, so the first row in the returned page is the prev cursor.

Our SQL queries for getting a page of contacts have to take into account the fact that given names and family names aren’t necessarily unique, although email addresses are. So our ‘page after’ query looks like this (with a Common Table Expression so we don’t have to pass the same parameters more than once):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
WITH vars AS (SELECT ? AS family_name, ? AS given_name, ? AS email)
SELECT id, family_name, given_name, phone, email
FROM contact
WHERE (family_name = (SELECT family_name FROM vars)
       AND given_name = (SELECT given_name FROM vars)
       AND email > (SELECT email FROM vars))
OR (family_name = (SELECT family_name FROM vars)
    AND given_name > (SELECT given_name FROM vars))
OR family_name > (SELECT family_name FROM vars)
ORDER BY family_name, given_name, email
LIMIT ?;

Full-text search queries #

There isn’t much left to do for full-text search. The /{lang}/contacts endpoint receives a query string consisting of one or more words or word prefixes. We parse this string and adapt it to the FTS5 query syntax. For example, G O'Mal becomes "G"* + "O'Mal"*, and will match a contact whose name is Grace O’Malley. We return a maximum of one page of full-text search results, so the route passes None cursors to the template, which removes the pagination links via an htmx out-of-band swap.

Integration tests #

There’s nothing remarkable about this application’s unit tests (unless your name happens to be 曼玉-فاتن), but the integration tests are more interesting. I’ve written two sets of integration tests: one for the database layer, and one for the HTTP endpoints. In both cases, we use sqlx::test, which creates a new database for each test and initialises it with a fixture, so the tests are isolated from each other. As mentioned above, the HTTP endpoint tests parse the HTML responses and check the contents of specific elements. We use the same axum router as in the application, and parse the responses using dom_query. For example, to test a GET request to /{lang}/contacts/{id}/edit, the endpoint for editing an existing contact, we can do something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#[sqlx::test(fixtures("test-contacts"))]
async fn edit_contact_get(pool: SqlitePool) {
    let server = init_server(pool).await;
    let response = server.get("/en/contacts/1/edit").await;

    assert_eq!(response.status_code(), StatusCode::OK);
    let doc = Document::from(response.text());

    let email_label = doc.select("label[for='email']").text().to_string();
    assert_eq!(email_label, "Email");

    let email = doc.select("#email").attr("value").unwrap().to_string();
    assert_eq!(email, "foo1@bar.com");

    // ...
}

Since we’re just looking at the text in labels and form fields using simple CSS selectors, test like this are unlikely to be affected by changes in the graphic design of the page.

PostgreSQL #

We don’t need to do much to adapt our app to use PostgreSQL instead of SQLite.

(If you’ve already read the Go version of this post, you can skip this section.)

Like SQLite, Postgres has built-in full-text search, and it’s even easier to set up. We can store tsvector values (preprocessed documents) in a generated column in the contact table, with a GIN index to speed up queries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
CREATE TABLE IF NOT EXISTS contact (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL,
  textsearch tsvector GENERATED ALWAYS AS (
    to_tsvector(
      'simple',
      COALESCE(family_name, '') || ' ' || COALESCE(given_name, '')
    )
  ) STORED
);

CREATE INDEX textsearch_idx ON contact USING GIN (textsearch);

The SQL query to find matching rows uses the full-text match operator, @@:

1
2
3
4
5
SELECT id, family_name, given_name, phone, email
FROM contact, to_tsquery($1) query
WHERE query @@ textsearch
ORDER BY ts_rank_cd(textsearch, query)
LIMIT $2;

The syntax of the string that we pass to to_tsquery just needs minor adjustments to work with Postgres: if the user types G O'Mal, we pass the argument 'G':* & 'O''Mal':*.

Integration tests with Testcontainers #

We can use Testcontainers to run Postgres in an ephemeral Docker container. #[sqlx:test] runs the tests in parallel, and isolates them from each other by creating a new database in the same container for each test.

There’s a small problem to solve here: #[sqlx:test] generates code that reads the test database URL from an environment variable or an .env file, but we don’t know that URL until after Testcontainers starts the Docker container. The solution, shown in an example project by Matilda Smeds, is to use the linktime crate to define a module initialisation function that runs before the tests do. This function starts the test container and sets the DATABASE_URL environment variable via std::env::set_var.

Conclusion #

By following the principle of ‘Parse, don’t validate’, and by using deterministic code generation that takes advantage of the guarantees provided by Rust’s type system, we’ve kept boilerplate to a minimum, and improved the chances that if we make a mistake, we’ll get an error at compile time rather than at run time. This should help keep our code maintainable and prevent bugs.


  1. See the N+1 selects problem and selecting all columns without thinking about it. ↩︎

Categories:
Topics: