Previously, I explored how to set up a project without relying on an ORM (see the previous post).

To recap, not using an ORM can be a good thing, as it reduces the number of dependencies in your binary. There are many benefits to it. It also helps gain clarity into the queries executed (easier to debug performance issues). The downside is that boilerplate needs to be copied for simple manipulations, and tests should always be added. This makes the data layer more verbose.

Tradeoffs

While handwritten SQL queries make sense in many use cases, simple operations are often performed. Those are most likely going to be a CRUD one.

At first, I wanted to use sqlx-crud, which was precisely what I aspire to do. But the library seems unmaintained, as the last release was a few years ago. So bugs were not addressed, and dependencies were not updated. This is why I decided to release a Rust library, tiny-orm, that focuses on CRUD SQL operations. I also took the time to look at the different libraries and their approach.

My main goals and philosophy were:

  • To have the least amount of dependencies
  • Tackle all the boilerplate of the CRUD operations
  • Add no complexity by default
  • Can fit real-world problems
  • It is easy to maintain

Thus, I decided to make the following technical tradeoffs.

Tradeoff 1: Only support CRUD operations

ORMs can get complex very quickly. Looking at a full ORM like SeaORM, it can do a lot, from generating the schemas to handling relations between tables.
Because most of the boilerplate I had to copy and paste over and over in my production services was around CRUD operations (aka inserting, updating, and deleting a record). And even more, handling the logic about partially updating only the values of your struct that are different (as seen in my previous post).
Thus, I decided to focus only on CRUD operations (bulk insert/update, as of the time of writing) and leave the rest aside (schema generation, migrations, table relations, etc.).
And even then, I ended up adding extra features like the ability to skip fields that are not set through a wrapper called SetOption.
Supporting all the databases and their differences (e.g., handling UUIDs, RETURN statements, etc.) was more than I thought.

Tradeoff 2: Use SQLx QueryBuilder

Looking at a few repositories, I realized that many had to reinvent the wheel. How to parse parameters? How to deal with datetime values? How to parse input and sanitize it? etc.
Since I wanted to keep maintenance of the library to a minimum and since it’s built on top of SQLx, I decided to use their QueryBuilder. This allows me to be compatible with PostgreSQL, MySQL, and SQLite right away.

Tradeoff 3: Do not use compile-time checked queries

SQLx allows you to check queries at compile time against a local database. That’s an excellent feature that makes the code more robust.
While I like the idea of compile-time checks, I don’t want to impose such restrictions on downstream users of this library (and even if SQLx’s compile-time checks can run offline after initializing a metadata JSON file).

Tradeoff 4: Supports for multiple versions of SQLx

When I started this library, SQLx was on 0.7.*. Then they release 0.8.* soon after. A lot of libraries update to the latest dependencies and call it a day. Libraries should be flexible and avoid enforcing a specific version of their dependencies when possible.
Even if there were breaking changes between 0.7.* and 0.8.*, I decided to add a feature flag sqlx-0.7 to work around some limitations when upgrading SQLx.

Note: I did not find a way to make my code and macros dependent on the version of SQLx installed by the user. There should be logic to detect the SQLx version being used automatically. I see the feature flag as a workaround only.

Feedback from the community

I started designing the library’s API layer based on some of my assumptions. One of the best things that happened was when I asked for feedback in the #rust topic of Reddit. joshka opened an issue with detailed feedback and ideas.
Even if I have no real ambition for this library, this was a fantastic moment that forced me to get back to the whiteboard. Some of his ideas were to add smart defaults with good examples. Now the crate is easy to use and understand by default, while keeping a lot of flexibility for others.

For example, we can make assumptions by just looking at the name of the struct (see this example).

#[derive(Debug, FromRow, Table, Clone)]
// Same as adding the following manually
// #[tiny_orm(table_name = "todo", return_object = "Self", exclude = "create,update")]
struct Todo {
    id: i32,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
    description: String,
    done: bool,
}

#[derive(Debug, FromRow, Table, Clone)]
// Because the struct name starts with "New", it would automatically use the following options
// #[tiny_orm(table_name = "todo", return_object = "Todo", only = "create")]
// The primary key will be auto-generated by the database and returned once the record is created.
struct NewTodo {
    description: String,
}

#[derive(Debug, FromRow, Table, Clone)]
// Because the struct name starts with "Update", it would automatically use the following options
// #[tiny_orm(table_name = "todo", return_object = "Todo", only = "update")]
struct UpdateTodo {
    id: i32,
    done: bool
}

I like the idea of multiple structs for different sets of actions (e.g., adding a new record vs. updating it). It makes the code very explicit and makes certain types of mistakes impossible (eg, updating the created_at field).

It is still possible to have all the methods available in the same struct. The following would provide all CRUD methods for the unique struct Todo.

#[derive(Debug, FromRow, Table, Clone)]
#[tiny_orm(all)]
struct Todo {
    id: i32,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
    description: String,
    done: bool,
}

In the end, I learned a lot from building my own Rust crates. I found that dealing with AST and macros was painful at first, but it got easier once I understood how to debug them. But that should be its own post.