Elyra
Elyra The coding agent e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra

Models

#[derive(Model)] is Elyra's Eloquent — a thin Active-Record layer over the Database pool (not a second ORM engine). Feature-gated behind database.

use elyra::Model;

#[derive(Model, serde::Serialize, serde::Deserialize, specta::Type)]
#[model(table = "todos", timestamps)]
struct Todo {
    #[model(id)] id: i64,
    title: String,
    done: bool,                          // <-> INTEGER 0/1 column
    #[model(column = "body")] text: String,
    created_at: i64,
    updated_at: i64,
}

Because models are plain structs, a command returning Vec<Todo> becomes Promise<Todo[]> through codegen.

Generated methods

Todo::all(&db).await?;                        // Vec<Todo>
Todo::find(&db, 1).await?;                     // Option<Todo>

let mut t = Todo { id: 0, title: "milk".into(), done: false, text: "".into(),
                   created_at: 0, updated_at: 0 };
t.insert(&db).await?;                          // sets t.id (+ timestamps)
t.done = true;
t.save(&db).await?;                            // insert-if-new (id == 0) else update
t.update(&db).await?;                          // UPDATE ... WHERE id = ?
t.delete(&db).await?;                          // DELETE ... WHERE id = ?
  • insert sets the primary key from the database (RETURNING on sqlite/postgres, last_insert_id() on MySQL).
  • save inserts when id == 0, otherwise updates.

Query builder

Todo::query()
    .where_eq("done", false)
    .where_gt("id", 10)
    .where_in("id", [1, 2, 3])
    .order_by("id")            // or .order_by_desc("id")
    .limit(50)
    .get(&db).await?;          // Vec<Todo>

Todo::query().where_eq("title", "milk").first(&db).await?;   // Option<Todo>

Comparisons: where_eq, where_ne, where_lt, where_gt, where_lte, where_gte, plus where_in. Values implement Into<Value> (i64, i32, bool, f64, &str, String).

Placeholders are rendered per driver; column identifiers are validated (they can't be bound), so where_eq("a; DROP TABLE", ..) is rejected, not executed. An empty where_in([]) matches nothing.

Attributes

Attribute On Meaning
#[model(table = "..")] struct Table name (default: lowercased struct name)
#[model(timestamps)] struct Auto-manage created_at / updated_at (unix seconds)
#[model(id)] field Mark the primary key (default: a field/column named id)
#[model(column = "..")] field Map the field to a differently-named column
#[model(has_many(T, fk="..", as=".."))] struct Relation (accessor + load_* map)
#[model(has_one(T, fk="..", as=".."))] struct Relation (accessor + load_* map)
#[model(belongs_to(T, fk="..", as=".."))] struct Relation (accessor + load_* map)
#[model(has_many(T, fk=".."))] field Relation hydrated into the field (with_*)
#[model(has_one(T, fk=".."))] field Relation hydrated into the field (with_*)
#[model(belongs_to(T, fk=".."))] field Relation hydrated into the field (with_*)

Relations

#[derive(Model)]
#[model(table = "users", has_many(Post, fk = "user_id", as = "posts"))]
struct User { id: i64, name: String }

#[derive(Model)]
#[model(table = "posts", belongs_to(User, fk = "user_id"))]
struct Post { id: i64, user_id: i64, title: String }

let posts = user.posts(&db).await?;        // has_many -> Vec<Post>
let owner = post.user(&db).await?;         // belongs_to -> Option<User>
  • has_manyVec<T>; has_oneOption<T>; belongs_toOption<T>.
  • fk and as (the method name) are optional; defaults are derived from the type names ({type}_id, pluralized/singular lowercase name). Multi-word type names need explicit fk/as.

Eager loading

Relation accessors (user.posts(&db)) are lazy — one query each, so calling them in a loop is N+1. For a batch of parents, each relation also generates a load_<name> method that runs one query and returns a HashMap for joining:

let users = User::all(&db).await?;

// has_many: keyed by parent PK -> Vec<child>
let by_user = User::load_posts(&db, &users).await?;   // HashMap<i64, Vec<Post>>
for user in &users {
    let posts = by_user.get(&user.id).cloned().unwrap_or_default();
}

// belongs_to: keyed by owner PK -> owner
let posts = Post::all(&db).await?;
let owners = Post::load_user(&db, &posts).await?;      // HashMap<i64, User>
let owner = owners.get(&posts[0].user_id);

has_one generates load_<name> returning HashMap<i64, T> (first child per parent). Under the hood these use where_in + grouping; you can also drop to the primitive directly:

let posts = Post::query().where_in("user_id", ids).get(&db).await?;

Auto-hydration (relation fields)

Instead of joining a HashMap yourself, declare the relation on a field and let the data hydrate straight into the struct. The field is not a column (it is skipped by COLUMNS, insert, and from_row, and defaults to empty):

#[derive(Model, Debug)]
#[model(table = "authors")]
struct Author {
    id: i64,
    name: String,
    #[model(has_many(Book, fk = "author_id"))]
    books: Vec<Book>,          // filled by `with_books`, empty otherwise
}

#[derive(Model, Debug, Clone)] // belongs_to targets must be `Clone`
#[model(table = "books")]
struct Book {
    id: i64,
    author_id: i64,
    title: String,
    #[model(belongs_to(Author, fk = "author_id"))]
    author: Option<Author>,
}

Each relation field generates a with_<field> batch hydrator that runs one query and assigns the result into every element:

let mut authors = Author::all(&db).await?;
Author::with_books(&db, &mut authors).await?;   // authors[i].books now populated

let mut books = Book::all(&db).await?;
Book::with_author(&db, &mut books).await?;      // books[i].author == Some(..)
  • has_manyVec<T>, has_one / belongs_toOption<T>.
  • fk defaults as for struct-level relations ({self}_id, or {target}_id for belongs_to).
  • belongs_to clones the shared owner into each child, so the target type must derive Clone.

bool columns

bool fields map to an INTEGER 0/1 column: bind 0/1, decode != 0. This is portable across all three drivers — the Any driver can't read SQLite's native BOOLEAN type, so models never use one. Declare such columns INTEGER in your migration.

Primary keys

The default is a single i64 autoincrement column: the database assigns it and insert reads it back (RETURNING on SQLite/Postgres, last_insert_id on MySQL); save() treats 0 as unsaved.

A non-i64 single-column key (e.g. String) is also supported — mark it with #[model(id)]:

#[derive(Model)]
#[model(table = "settings")]
struct Setting {
    #[model(id)]
    key: String,   // app-supplied; included in INSERT, not read back
    value: String,
}

Setting { key: "theme".into(), value: "dark".into() }.insert(&db).await?;
let s = Setting::find(&db, "theme".to_string()).await?;

For an app-supplied key the value is inserted as-is (no key retrieval), and find takes that key type. save() uses the type's Default as the "unsaved" sentinel ("" for String), so prefer explicit insert / update when the key is always set. Relation eager-loading still assumes i64 keys.

Query builder

// Filtering
let users = User::query()
    .where_eq("active", true)
    .where_like("email", "%@example.com")
    .where_between("age", 18, 65)
    .where_not_null("verified_at")
    .or_where_eq(&[("role", "admin".into()), ("role", "owner".into())])
    .order_by("name")
    .order_by_desc("created_at")        // chains: ORDER BY name ASC, created_at DESC
    .limit(20)
    .offset(40)
    .get(&db)
    .await?;

// Aggregates (limit/offset are ignored, filters are not)
let total   = User::query().count(&db).await?;
let any     = User::query().where_eq("active", true).exists(&db).await?;
let sum     = Order::query().sum(&db, "total").await?;      // Option<f64>
let average = Order::query().avg(&db, "total").await?;
let oldest  = User::query().min(&db, "created_at").await?;  // Option<i64>

// Pagination
let page = User::query().order_by("id").paginate(&db, 2, 25).await?;
page.data;         // Vec<User>
page.total;        // matching rows
page.last_page;    // page count
page.has_more();   // bool
(page.from(), page.to());

// Joins (identifiers may be table-qualified)
let recent = Product::query()
    .join("orders", "orders.product_id", "products.id")
    .where_gte("orders.quantity", 2)
    .get(&db)
    .await?;

// Bulk writes
let updated = User::query()
    .where_eq("active", false)
    .update(&db, &[("active", true.into()), ("note", "reactivated".into())])
    .await?;
let removed = User::query().where_lt("age", 13).delete(&db).await?;

// Batching without loading everything at once
User::query().chunk(&db, 500, |batch| {
    for user in batch { /* … */ }
    Ok(())
}).await?;

Soft deletes

#[derive(Model)]
#[model(table = "accounts", soft_deletes)]
struct Account {
    id: i64,
    email: String,
    deleted_at: Option<i64>,
}

deleted_at (unix seconds, nullable) makes every query skip trashed rows:

Account::query().count(&db).await?;                        // live rows only
Account::query().with_trashed().count(&db).await?;         // include trashed
Account::query().only_trashed().get(&db).await?;           // just the trashed

Account::query().where_eq("email", &email).soft_delete(&db).await?;  // set deleted_at
Account::query().where_eq("email", &email).restore(&db).await?;      // clear it
Account::query().where_eq("email", &email).delete(&db).await?;       // hard delete

Add the column with the schema builder's t.soft_deletes().

v1 assumptions

  • Composite (multi-column) primary keys are not supported.
  • group_by/having, first_or_create/update_or_create, attribute casts and factories are not implemented yet.
  • Column name equals field name unless overridden with #[model(column)].
  • SQLite is test-covered; MySQL/Postgres run in CI against real servers.

Related