Models

Models in loco mean entity classes that allow for easy database querying and writes, but also migrations and seeding.

Fat models, slim controllers

loco models are designed after active record. This means they're a central point in your universe, and every logic or operation your app has should be there.

It means that User::create creates a user but also user.buy(product) will buy a product.

If you agree with that direction you'll get these for free:

  • Time-effective testing, because testing your model tests most if not all of your logic and moving parts.
  • Ability to run complete app workflows from tasks, or from workers and other places.
  • Effectively compose features and use cases by combining models, and nothing else.
  • Essentially, models become your app and controllers are just one way to expose your app to the world.

We use SeaORM as the main ORM behind our ActiveRecord abstraction.

  • Why not Diesel? - although Diesel has better performance, its macros, and general approach felt incompatible with what we were trying to do
  • Why not sqlx - SeaORM uses sqlx under the hood, so the plumbing is there for you to use sqlx raw if you wish.

Example model

The life of a loco model starts with a migration, then an entity Rust code is generated for you automatically from the database structure:

src/
  models/
    _entities/   <--- autogenerated code
      users.rs   <--- the bare entity and helper traits
    users.rs  <--- your custom activerecord code

Using the users activerecord would be just as you use it under SeaORM see examples here

Adding functionality to the users activerecord is by extension:

impl super::_entities::users::ActiveModel {
    /// .
    ///
    /// # Errors
    ///
    /// .
    pub fn validate(&self) -> Result<(), DbErr> {
        let validator: ModelValidator = self.into();
        validator.validate().map_err(validation::into_db_error)
    }
}

Migrations

To add a new model you have to use a migration.

$ cargo loco generate model posts title:string! content:text user:references

When a model is added via migration, the following default fields are provided:

  • created_at (ts!): This is a timestamp indicating when your model was created.
  • updated_at (ts!): This is a timestamp indicating when your model was updated.

These fields are ignored if you provide them in your migration command. In addition, create_at and update_at fields are also ignored if provided.

For schema data types, you can use the following mapping to understand the schema:

("uuid", "uuid"),
("string", "string_null"),
("string!", "string"),
("string^", "string_uniq"),
("text", "text_null"),
("text!", "text"),
("tiny_integer", "tiny_integer_null"),
("tiny_integer!", "tiny_integer"),
("tiny_integer^", "tiny_integer_uniq"),
("small_integer", "small_integer_null"),
("small_integer!", "small_integer"),
("small_integer^", "small_integer_uniq"),
("int", "integer_null"),
("int!", "integer"),
("int^", "integer_uniq"),
("big_integer", "big_integer_null"),
("big_integer!", "big_integer"),
("big_integer^", "big_integer_uniq"),
("float", "float_null"),
("float!", "float"),
("double", "double_null"),
("double!", "double"),
("decimal", "decimal_null"),
("decimal!", "decimal"),
("decimal_len", "decimal_len_null"),
("decimal_len!", "decimal_len"),
("bool", "bool_null"),
("bool!", "bool"),
("tstz", "timestamptz_null"),
("tstz!", "timestamptz"),
("date", "date_null"),
("date!", "date"),
("ts", "timestamp_null"),
("ts!", "timestamp"),
("json", "json_null"),
("json!", "json"),
("jsonb", "jsonb_null"),
("jsonb!", "jsonb"),

Using user:references uses the special references type, which will create a relationship between a post and a user, adding a user_id reference field to the posts table.

You can generate an empty model:

$ cargo loco generate model posts

You can generate an empty model migration only which means migrations will not run automatically:

$ cargo loco generate model --migration-only posts

Or a data model, without any references:

$ cargo loco generate model posts title:string! content:text

This creates a migration in the root of your project in migration/. You can now apply it:

$ cargo loco db migrate

And generate back entities (Rust code) from it:

$ cargo loco db entities

Configuration

Model configuration that's available to you is exciting because it controls all aspects of development, testing, and production, with a ton of goodies, coming from production experience.

# .. other sections ..

database:
  uri: postgres://localhost:5432/rr_app
  # uri: sqlite://db.sqlite?mode=rwc
  enable_logging: false
  min_connections: 1
  max_connections: 1
  auto_migrate: true
  dangerously_truncate: true
  dangerously_recreate: true

By combining these flags, you can create different expriences to help you be more productive.

You can truncate before an app starts -- which is useful for running tests, or you can recreate the entire DB when the app starts -- which is useful for integration tests or setting up a new environment. In production, you want these turned off (hence the "dangerously" part).

Testing

If you used the generator to crate a model migration, you should also have an auto generated model test in tests/models/posts.rs (remember we generated a model named post?)

A typical test contains everything you need to set up test data, boot the app, and reset the database automatically before the testing code runs. It looks like this:

async fn can_find_by_pid() {
    configure_insta!();

    let boot = testing::boot_test::<App, Migrator>().await;
    testing::seed::<App>(&boot.app_context.db).await.unwrap();

    let existing_user =
        Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
    let non_existing_user_results =
        Model::find_by_email(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await;

    assert_debug_snapshot!(existing_user);
    assert_debug_snapshot!(non_existing_user_results);
}