Skip to content

Feedback #280

@joeydewaal

Description

@joeydewaal

Hi, so I've been experimenting with toasty. Trying to build some stuff with axum, tonic and just generally figuring out what toasty is about. I thought i'd leave some feedback.

While talking about missing features I try to explain while giving an example of how I could see/would want to use this
in toasty. Note, I have not tried to implement the missing features. I'm not sure if they are achievable, so I'd say take them with a grain of salt.

Compile time safety

Typestate (nice to have)

While toasty focusses more on ease of use rather than type state, which I like, I do think that there is sweetspot that currenly is a bit out of balance. For example. After adding a field to a model, i'd like toasty to error out at compile time when I try to create the model without the new field.

Sure this would mean that the builders would need typestate to track which fields are and are not set, which would make them more complex. But I think this should be fine. The complexity only exists if the user tries to add a method to the builder or tries to store the builder somewhere. But given that the builders can't be referenced, (similar to what happens in #276), this is not a problem. So I'd love for toasty to experiment with typestate in the builders a bit. I think this would improve toasty while still being easy to use.

Implicit compile time safety (nice to have)

There are also ways to get compile time safety without adding more generics and traits. For example when trying to filter/include, to reference a field you need to do something like this:

User::all()
  .filter(User::FIELDS.email().eq(...))

Personally I like the idea of using a callback here e.g:

User::all()
  .filter(|user| user.email().eq(...)) // `user` is a User::FIELDS here.
  .include(|user| user.posts())        // This would only yield the associations of the model.

It's intuitive, readable and also implicitly leads to compile time safety. The user of the api can't by accident reference the wrong columns of the model.
Also when someone changes the User to Post this would not compile if the Post model doesn't have the email field. While this is just a small example, further down in this issue there are some more examples how features could use this to make the api more resistant to misuse.

Datatypes (blocker)

There has been a lot of work on this from what i've seen. So I think only enum's are missing.

Missing features

Docs (nice to have)

This one is pretty self explanatory I think, since there are only the examples. (I always look at the unit tests and discover new features.)
It would be nice if there is some kind of mdbook or a "real-world-toasty-axum" (or any other webframework) repo like this one that showcases what a real world application would look like. (auth, CRUD, pagination, good practices,...)

Errors (nice to have)

Toasty currently builds on top of anyhow which means that it's difficult to do anything with the errors.
It'd be nice if the errors are a bit more strongly typed, so we know how to handle them (connection error, query error, driver error,...).

(high value)
Constraint errors for example are at the moment (I think) impossible to filter out/ match on. So having support for constraints would be pretty nice, and I think toasty can do something pretty sweet here. For example, expose a constant that has all the constraints of the model:

#[derive(Model)]
struct User {
    id: i32,
    #[unique] // <- toasty knows about the constraint.
    email: String,
}

// Toasty generates a User::CONSTRAINTS struct where the unique constraint is stored.
// This would make it easy to see what went wrong and handle it correctly. For example:
match User::create()
        .email("lela@example.com")
        .exec(&db).await {
    Ok(_) => StatusCode::CREATED,
    Err(e) if e.is_constraint(User::CONSTRAINTS.unique_email) => StatusCode::CONFLICT,
    Err(_) => StatusCode::INTERNAL_SERVER_ERROR
}

Transactions (blocker)

At the moment there is no support for transactions, but there are some situations where I miss this.

Functions in queries (high value)

Because functions have to start from a model, it's not possible to create a query like SELECT COUNT(id) FROM .....
I'd be nice if there is something like:

// Get the number of users.
let num_users = count(User::all()) // -> usize
                  .exec(&db).await?;

// Check whether of not a user with this email exists.
let email_taken = exists(User::filter_by_email(&email)) // -> bool
                    .exec(&db).await?;

User::filter_by_email(&email) // -> bool
    .exists() // or have them as methods, potentially?
    .exec(&db).await?;


// --- Functions in filters ---

// Get all users who have posted something. (Where one user has many posts.)
User::all()
  .filter(count(User::FIELDS.posts()).ge(1))

More complex includes. (blocker)

Currently a model can only include a direct association. E.g. a user with their book, but you cant get the users' books' pages.

User::all()
  .include(User::FIELDS.books()))        // Get the books of the user. (currently supported)
  .include(User::FIELDS.books().pages()) // And get the pages of the books of the user. (not supported)

Also what about filtering the included relation?
I think that would be nice to have. For example, what if I want the books of the user that aren't deleted. E.g:

User::all()
  .include(User::FIELDS.books().deleted().eq(false))

(I'm not sure if im a big fan of filtering in an include. Mostly because there is an explicit filter method. But that is a discussion for another time.)

More complex filters (blocker)

Imagine you have a User that has one Profile. Currently it's not possible to filter based on that relation.
Here is how I would imagine this feature working:

// --- Getting all users that are older than 18. ---
//   I imagine that this would implicitly join the profile table on the user table
//   so it can evaluate the filter, but not select it. It would only select the `User` columns.
//   (you can ofcourse still use `include` to include and select it as well)

// Reference `Profile` directly.
User::all()
  .filter(Profile::FIELDS.age().ge(18))

// Go through `User` to `Profile` so toasty knows where to look for the association.
User::all()
  .filter(|User::FIELDS.profile().age().ge(18))

// (With the callback api)
User::all()
  .filter(|u| u.profile().age().ge(18))

Submodels

At the moment when working with models we always include all their fields. But in reality most of the times you only need a small set of them.

I had an idea where toasty could have submodels, a model that is based on a base model where it derives from. A submodel isn't a table but more a type that has a subset of the fields of the base model.

Some normal (base) models for the example:

#[derive(Model)]
struct User {
    #[key]
    #[auto]
    id: i32,
    email: String,

    #[unique]
    profile_id: Option<i32>,
    #[belongs_to(key = profile_id, references = id)]
    profile: BelongsTo<Option<Profile>>,

    dob: Timestamp,
    created_at: Timestamp,
    first_name: String,
    last_name: String,
    // ... more fields
}

#[derive(Model)]
struct Profile {
    #[key]
    #[auto]
    id: i32,
    bio: Option<String>,        // <-- optional field
    is_deleted: bool,

    #[has_one]
    user: HasOne<Option<User>>, // <--- optionally has a profile

    #[has_many]
    posts: HasMany<Post>,

    created_at: Timestamp,
    updated_at: Timestamp,
    // ... more fields
}

A submodel of the Profile model. (high value)

#[derive(Model)]
#[toasty(from(Profile))] // What model this model derives from.
struct ProfileBio {
    id: i32,     // Comes from Profile::FIELDS.id()

    bio: String, // Comes from Profile::FIELDS.bio()
                 //
                 // Because this is an optional field in the Profile model,
                 // toasty implicitly adds an `IS NOT NULL` expression in the generated
                 // queries so it filters out all the bio's that are NULL.
}

// I would imagine that the Model macro would only generate the select api's/traits.
// Updating, deleting or creating should probably be done using the _base_ model.
ProfileBio::all() // generates: SELECT id, bio
                  //            FROM profile
                  //            WHERE bio IS NOT NULL


// Works together with the calback api. You don't have to worry about what fields you can or can't use
// while filtering because the macro only yields the fields you can use.
ProfileBio::all()
  .filter(|p| p.is_deleted().eq(false)) // Even though `ProfileBio` doesn't have an is_deleted field,
                                        // `Profile` does, so we should be able to filter based on all the
                                        // fields of the _base_ model.

Submodels with relations. (nice to have)

#[derive(Model)]
#[toasty(from(User))]
struct UserEmail {
    id: i32,
    email: Option<String>,

    #[has_one]
    profile: HasOne<ProfileBio>, // In the base model, a User optionally has a Profile. Because in this
                                 // derived model it is not optional, toasty implicitly uses an inner join
                                 // to filter out all the users without a Profile.
}

UserEmail::all()
  .include(UserEmail::FIELDS.profile())
// generates:
//   SELECT user.id, email, profile.id, bio
//   FROM user
//   INNER JOIN profile ...   ; filter out all the users without a profile
//   WHERE bio IS NOT NULL    ; because bio is not nullable in the `ProfileBio` submodel


// Works together with the callback idea. Relations that exist but you can't include are not available.
UserEmail::all()
  .include(|u| u.profile().posts()) // Does not compile. Eventhough a Profile has many Posts,
                                    // because a `ProfileBio` has no place to store them, you can't
                                    // select them.

Submodel that joins two models together. (nice to have)

#[derive(Model)]
#[toasty(from(User, Profile))]
struct UserBio {
    #[toasty(field = Profile::FIELDS.id())] // Make sure toasty knows where to look.
    profile_id: i32,
    #[toasty(field = User::FIELDS.id())] // Same here
    user_id: i32,

    email: String,       // from the User model.
    bio: Option<String>, // from the Profile model.
}

UserBio::all() // This would do an implicit INNER JOIN between the models
               // and only select the fields it needs.


// With the callback api
UserBio::all()
  .filter(|user, profile| // Because it derives from multiple models, this has access to all their fields.
      user.email().eq(...)
     .or(profile.bio().eq(...))
   )

Again, take the examples with a grain of salt. This is mostly my thoughts/imagination written down.

While toasty is in it's early stages, there is defninitely a lot of potential. It has been a good experience working with the library so far.
Hopefully this feedback is usefull.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions